Compare commits

..
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 f85ccb2bd2 Main builds again: one Attack arm in the hub, anticheat first, then the queued-move break
#5123 added a second S::Attack arm after the anticheat's, so clippy -D warnings
fails on main (unreachable pattern) and its queued-move break never ran. The
break now sits in the anticheat's arm, after an attack it forwards.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:20:43 +01:00
rdbt d6e8bd67aa Merge pull request #5143 from arbelonson-source/fix/locator-bar-reconfigure
Clear waypoints when the server reconfigures the client
2026-10-31 17:11:35 +00:00
Ludovic Marie 30615b890d Merge pull request #3214 from I114rk/feature/boot-loading-screen
🤖 Agent: boot loading screen (GDI paint before the first wgpu frame, Windows); review points fixed (elythepreciouscat's commits), main merged, clippy clean.
2026-10-31 18:09:00 +01:00
Ludovic Marie 4a475325b4 Merge pull request #5032 from Sticky-Bug/feat/flower-pot-plant
🤖 Agent: requeued on the author's merge of main (mergeable)
2026-10-31 18:08:54 +01:00
Ludovic Marie 39fa71cfe8 Merge pull request #5068 from doodlyyousuf/fix/4988-ui-label-centering
🤖 Agent: requeued: the new review is an approval
2026-10-31 18:07:43 +01:00
rdbt d932f5d96d Merge pull request #5122 from k3yh/fix/nbt-network-nan
Keep a NaN's bits in network NBT, as vanilla does (#5064, point 4)
2026-10-31 17:07:04 +00:00
Ludovic Marie ed829ea5f3 Merge pull request #4690 from calclb/fix/4529-player-death-message
🤖 Agent: requeued after main's Audio build break (failed jobs rerun); change unchanged.
2026-10-31 18:06:29 +01:00
Ludovic Marie bf0c334ddc Merge pull request #3580 from GIGABAIT93/feat/launch-api-prepare-inputs
🤖 Agent: horizon-prepare names the game files one by one (--version-json, --client-jar, --assets-dir…), still checked against research-lock.json pins; merged #3574's fix and main; horizon-prepare tests and clippy pass.
2026-10-31 18:06:24 +01:00
Ludovic Marie 8b4dc0dacc Merge pull request #3574 from GIGABAIT93/feat/launch-api-prepare-complete
🤖 Agent: horizon-prepare --complete and --progress json, documented in docs/api/launch.md; --check now refuses --progress/--complete (rdbtCVS's point). Merged main; horizon-prepare tests and clippy pass.
2026-10-31 18:06:19 +01:00
Ludovic Marie 9fd6279f36 Merge pull request #5149 from xFedeT/fix/chat-validation-error
🤖 Agent: requeued at the author's rebase on main (same change, one line less): vanilla's handlePlayerChat checks (global index, unknown sender, SignedMessageValidator) with chat.validation_error.
2026-10-31 18:06:16 +01:00
Ludovic Marie b1d9565677 Merge pull request #5269 from horizon-rust/sweep-c/finish-5107
🤖 Agent: follow-up to merged #5107: the LAN fallback needs a signed-in account (Mojang's rules); net test passes
2026-10-31 18:06:12 +01:00
Ludovic Marie 0749fa97e6 Merge pull request #4976 from Detimes/feature-4963-velocity-forwarding
🤖 Agent: read the diff: Velocity modern forwarding only with forwarding.secret and online-mode false (the proxy does the Microsoft login); HMAC-SHA256 checked in constant time (RFC 4231 vectors), version, lengths and trailing bytes checked, empty secret stops the start, in-memory joins never forwarded. Wire format from black-box recordings of Velocity 4.3.1. verify green; merging once parity passes.
2026-10-31 18:06:08 +01:00
Ludovic Marie ef1658625c Merge pull request #5155 from xFedeT/fix/sulfur-cube-hit-angles
🤖 Agent: read the diff against vanilla's SulfurCube.knockback helpers (applyHorizontalHitAngleScale, applyVerticalHitAnglePowerTransfer, the position rotation) and Projectile.calculateHorizontalHurtKnockbackDirection; unit test matches the 26.3 helpers to 1e-9, author ran mob_ai arrow damage_source living_hurt server_knockback mob_attributes. CI green, parity runs on the head merged into main.
2026-10-31 18:06:03 +01:00
Ludovic Marie e05a09220f Merge pull request #5123 from mulareal/fix-attack-queued-moves-4524
🤖 Agent: read the diff: an attack or interaction now holds back a move queued behind it until the next tick, as PlayerAction and UseItemOn already do, so the game sees the player as vanilla's in-order packet handling does (falling crit). lan_bot test added; CI green, parity runs on the head merged into main.
2026-10-31 18:05:59 +01:00
rdbt 571ec1b76e Merge pull request #5060 from MSedgeMC/fix/impulse-liquid-reset
End a wind charge's fall context when the player lands in water
2026-10-31 16:57:24 +00:00
Ludovic MarieandClaude Opus 5.5 498aea7864 Boot atlas: set main's new HudAtlas::text_shader field
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:57:16 +01:00
Ludovic Marie b8f8c2515d Merge pull request #5202 from socksprox/fix/native-build-nonascii-path
🤖 Agent: read the diff: native-mod builds run under an ASCII path (resolved data dir, 8.3 short name, or a ProgramData folder named by hash) so MinGW-w64's ANSI tools work for a non-ASCII Windows user name (#5189); tests for the prefix stripping and ASCII check. Approved as code owner; CI green.
2026-10-31 17:56:14 +01:00
Ludovic Marie 0b93c4e79e Merge pull request #4935 from ErikKalikas/mod-block-entity-sync
🤖 Agent: native mods' block entities reach clients in the chunk and BlockEntityData (empty update tags dropped as BlockEntityInfo.create does); arismoko's replaced-block case fixed and checked in game; main merged and the hook API renumbered to 43. Merging once CI and parity pass.
2026-10-31 17:56:09 +01:00
Ludovic Marie 258a11a817 Merge pull request #4787 from WooCaash/fix/4510-falling-block-ghost
🤖 Agent: merged main into the branch: main's dedicated event records (chat, join, leave) moved into the shared lan_process_events, and main's flush order (#4765) kept with the packet gate reset at the end of tick_lan. Merging once CI and parity pass.
2026-10-31 17:56:04 +01:00
Ludovic Marie 546c1c71fc Merge pull request #4539 from kacpherek/fix/trackers-batch
🤖 Agent: merged main into the branch (item_tooltip.rs: kept both the tropical fish bucket lines and main's map_id_lines). 15 mob fixes, rdbtCVS's points fixed; merging once CI and parity pass on this head.
2026-10-31 17:56:00 +01:00
Ludovic Marie 07d7887bef Merge pull request #5101 from kacpherek/fix/trackers-rendering
🤖 Agent: seven render fixes checked against vanilla (EndermanRenderer.getRenderOffset, CopperGolemModel.translateToHand, Conduit/EnchantTable crumbling, DolphinCarryingItemLayer, CompositeBlockModel special models, Phantom size save/sync, getBlockLightLevel overrides). Parity rerun once for world_spawn#3/pearl_wind_charge, which it doesn't touch. Thanks!
2026-10-31 17:55:55 +01:00
Ludovic Marie 29826a7558 Merge pull request #4434 from horizon-rust/fix/mod-registry-kind-and-retry
🤖 Agent: mod registry: a mod may turn native once; a retry takes up its own form branch, proven by an HMAC submission tag under its own derived key (elythepreciouscat's fix for rdbtCVS's point). Registry fmt/clippy/tests pass.
2026-10-31 17:55:51 +01:00
Ludovic Marie e48e87a422 Merge pull request #5258 from horizon-rust/sweep-c/finish-5062
🤖 Agent: #5062 by MSedgeMC (TamableAnimal.getTeam, verified) plus an O(n²) fix in mob_team; cargo check clean. Thanks MSedgeMC!
2026-10-31 17:55:47 +01:00
Ludovic Marie 6efd5166c2 Merge pull request #5257 from horizon-rust/agent/finish-4923
🤖 Agent: #4923 finished: calibration ends off the Options screen and its fullscreen is not saved as the option (rdbtCVS's review). HDR off by default; merging once CI and parity pass.
2026-10-31 17:55:43 +01:00
Ludovic Marie d7709584f0 Merge pull request #4933 from socksprox/fix/4912-macos-keychain-items
🤖 Agent: one macOS keychain item per account (the 1 KB split was a Windows Credential Manager limit); rdbtCVS's point fixed in f7f274772: a refused delete writes the whole old layout back, tested halfway and at once. verify green.
2026-10-31 17:55:37 +01:00
Ludovic Marie 4cac6f30db Merge pull request #4868 from socksprox/fix/fake-player-hitbox
🤖 Agent: scaled hitboxes (LivingEntity.getDimensions), isPickable = !isRemoved, interaction-range attributes in GameRenderer.pick; guest reach per acting player. rdbtCVS's points fixed (CR dismissed). verify green.
2026-10-31 17:55:32 +01:00
Ludovic Marie a19985b75f Merge pull request #3827 from abit-foggy/perf/rd32-tick-stutter
🤖 Agent: incremental tick chunk membership and fresh-chunk tracking; rdbtCVS approved after three rounds (remove/re-add replay, key order, foreign revisions). verify green; parity runs on the merged head.
2026-10-31 17:55:27 +01:00
Ludovic Marie 3eb3e44ba6 Merge pull request #3499 from horizon-rust/codex/release-launcher-warning
🤖 Agent: game release notes repeat the launcher warning above the disclaimer; launcher notes unchanged; tests updated; CI green.
2026-10-31 17:55:23 +01:00
Ludovic Marie 1363bd83b6 Merge pull request #5120 from Sylviromi/native-recipes-on-servers
🤖 Agent: read the diff: the three recipe helpers now return nothing only on a client with no player, so a server's Game answers them. Tested in game by arismoko (before/after), approved by rdbtCVS; CI green, parity runs on the head merged into main.
2026-10-31 17:55:19 +01:00
Ludovic Marie f21ce0241f Merge pull request #5235 from horizon-rust/feat/official-smp-stats
🤖 Agent: CI green on 8c913dff6 (clippy, launcher, tests, python, verify), merges cleanly on main after #5217. Telemetry server checked in its own workspace (fmt, clippy -D warnings, tests); the status ping was checked against the real official server. Official SMP count in the launcher and its history on the telemetry server.
2026-10-31 17:55:16 +01:00
Ludovic Marie 1fed25618f Merge pull request #5239 from horizon-rust/chore/discord-invite
🤖 Agent: Ludovic's decision (2026-10-31). A one-line or link-only change; merges once CI is green.
2026-10-31 17:55:10 +01:00
rdbt 236219d649 Merge pull request #3832 from horizon-rust/fix/update-zip-security
Update zip to 9.1.3
2026-10-31 16:54:25 +00:00
Ludovic Marie cf83188a6d Merge remote-tracking branch 'origin/main' into ely3214 2026-10-31 17:53:47 +01:00
Sticky-Bug 06d1db6295 Merge remote-tracking branch 'origin/main' into feat/flower-pot-plant 2026-10-31 22:22:44 +05:30
Ludovic Marie 5595b0225f Merge branch 'pr3574' into pr3580 2026-10-31 17:51:32 +01:00
Ludovic Marie 7fcec44100 Merge remote-tracking branch 'origin/main' into pr3574 2026-10-31 17:51:31 +01:00
Ludovic Marie b305a5a5a0 Merge branch 'pr3574' into pr3580 2026-10-31 17:51:24 +01:00
Ludovic Marie 9816939333 Merge pull request #5251 from levzzz5154/fix/audio-share-device-fields
Main builds again: splitscreen audio shares and the stew particles
2026-10-31 17:50:33 +01:00
Ludovic Marie 9c9ab69366 Merge remote-tracking branch 'origin/main' into pr5107 2026-10-31 17:50:23 +01:00
Ludovic MarieandClaude Opus 5.5 87e3e1d155 LAN login: never join without an account
A failed session join goes on for a LAN entry only when an account is
signed in, as vanilla's client always has one (Mojang's rules: no play
without an account that owns the game).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:47:00 +01:00
rdbtCVSandClaude Opus 5.5 5963751fe9 Merge origin/main: keep both the services-key and own-profile enums in remote.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:44:52 +00:00
Ludovic Marie a56109fc75 Merge remote-tracking branch 'origin/main' into sweepB/4787
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
2026-10-31 17:44:06 +01:00
rdbtCVSandClaude Opus 5.5 9dba2508fa Merge main: the pot answers through useItemOn's branch, and the generic arm fires item_used_on_block
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:42:09 +00:00
Ludovic Marie c90b0cd869 Merge remote-tracking branch 'origin/main' into sweepB/4539
# Conflicts:
#	crates/horizon-client/src/item_tooltip.rs
2026-10-31 17:38:22 +01:00
Ludovic MarieandClaude Opus 5.5 9c63ab14e9 HDR calibration: end it off the Options screen; don't save its fullscreen
The test picture and borderless window stayed when the screen left Options
without a Close event (a server opening the death screen), and
observe_fullscreen saved the calibration's own fullscreen as the player's
Fullscreen option. Review points from #4923.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:33:23 +01:00
Ludovic Marie 995c7aa817 Merge remote-tracking branch 'origin/main' into fix4434 2026-10-31 17:32:30 +01:00
ElyTheCatandCodex ed9c111323 Separate submission tags from session cookies
Co-Authored-By: Codex <[email protected]>
2026-10-31 17:32:30 +01:00
levzzzandClaude Opus 5.5 be7490c9ba Main builds again: the spawn egg test passes the block registry argument
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:32:24 +05:00
rdbtCVSandClaude Opus 5.5 28cbd6c861 Merge main; renumber mod block entity update tags to API 45
Main took API 43 (native_level_random) and 44 (server starting and stopping).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:32:06 +00:00
rdbt 384197a498 Merge pull request #5214 from horizon-rust/fix/unique-random-uuids
Unique random UUIDs: mobs generated in a pack no longer share one
2026-10-31 16:31:48 +00:00
rdbt b625ba5360 Merge pull request #5210 from iseaprojects/fix/5201-windows-version
Tools: read cmd /C ver in the console's code page on Windows
2026-10-31 16:31:25 +00:00
rdbt ffc8183484 Merge pull request #5209 from iseaprojects/fix/5132-burst-setback
Anticheat prediction: a burst keeps the setback point before its lie
2026-10-31 16:31:10 +00:00
levzzz 5a23ab721e Merge remote-tracking branch 'hr/main' into fix/audio-share-device-fields 2026-10-31 21:31:07 +05:00
rdbt b15c3fad35 Merge pull request #5196 from horizon-rust/server-admin/mcp
horizon-server mcp: an MCP server for AI agents to run and moderate a server
2026-10-31 16:30:40 +00:00
rdbt 2a8a736b4a Merge pull request #5193 from vivescene/audio/macos-device-fingerprint
Audio: list output devices only when the Core Audio devices change (macOS)
2026-10-31 16:30:25 +00:00
rdbt 0181d47bcb Merge pull request #5191 from horizon-rust/perf/path-shape-changes-once
Mobs: compare each block write's collision shapes once per tick, not once per pathing mob
2026-10-31 16:30:11 +00:00
rdbt 35a69e1eb6 Merge pull request #5182 from 1038april/fix/eat-food-that-cannot-be-placed
Food that can't be placed on the clicked block is eaten on this client too
2026-10-31 16:29:49 +00:00
rdbt 6d3f22638d Merge pull request #5180 from Akirabane/fix/service-test-windows
horizon-server: the launchd test no longer assumes `/` in paths
2026-10-31 16:29:28 +00:00
rdbt fd7c46c225 Merge pull request #5177 from xFedeT/fix/port-map-3513
Port map: seven rows of #3513 are ported already
2026-10-31 16:29:13 +00:00
rdbt 73b547d762 Merge pull request #5176 from xFedeT/fix/creeper-lingering-cloud
A creeper with effects leaves a lingering cloud when it explodes
2026-10-31 16:28:58 +00:00
rdbt bd71e987ef Merge pull request #5175 from reservedbytes/fix/native-set-property-no-change
fix(mods): property setters return false when nothing changed
2026-10-31 16:28:44 +00:00
rdbt bba758386d Merge pull request #5173 from reservedbytes/fix/primed-sulfur-cube-knockback
fix(mobs): a primed sulfur cube is still knocked about
2026-10-31 16:28:30 +00:00
Ludovic MarieandClaude Opus 5.5 54f11f1744 Pet teams: look a mob up only when its own name has no team
mob_team scanned every mob for each call, and mob_teams (every tick) and the
name tags and outlines (every frame) call it once per mob: O(n^2) whenever a
scoreboard team exists. The own team is checked first, the tick builds the
tamed owners in one pass, and callers holding the mob pass it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:28:25 +01:00
rdbt 125a880ee5 Merge pull request #5126 from Akirabane/fix/4904-recipe-result-string
Recipe results: a bare item id names the item, as vanilla reads it
2026-10-31 16:27:21 +00:00
rdbt 0c41429b4b Merge pull request #5117 from oskal675-design/fix/mace-pack-models
Resolve enchantment-dependent component item models
2026-10-31 16:26:56 +00:00
rdbt e4f07bb4c6 Merge pull request #5116 from MSedgeMC/fix/display-shadow
Draw a display's shadow, interpolated as vanilla draws it
2026-10-31 16:26:42 +00:00
rdbt 6daec26c3d Merge pull request #5114 from MSedgeMC/fix/projectile-power-packet
Read the projectile power the server sends with a projectile's motion
2026-10-31 16:26:28 +00:00
rdbt ef0c899c29 Merge pull request #5111 from oskal675-design/fix/remote-offhand-model
Render each remote hand with its own item stack
2026-10-31 16:26:05 +00:00
rdbt d13980b43a Merge pull request #5107 from oskal675-design/fix/lan-login-authentication
Preserve vanilla LAN authentication fallback
2026-10-31 16:25:43 +00:00
rdbt ce44d05eea Merge pull request #5106 from masa-masa-0314/fix/ui-localization
Localization: the merchant screen follows the language, Horizon's strings in Chinese
2026-10-31 16:25:29 +00:00
rdbt 1574e9b150 Merge pull request #5104 from apeirs/fix/recipe-book-space-replace
Recipe book: Space and Enter place the last clicked recipe again
2026-10-31 16:25:14 +00:00
rdbt 910ba97315 Merge pull request #5096 from oskal675-design/fix/srv-login-handshake
Fix Velocity forced-host routing after SRV redirects
2026-10-31 16:24:47 +00:00
rdbt ccbc71879c Merge pull request #5087 from MSedgeMC/fix/mob-pick-up-item
Let a mob pick up and equip what it finds, as vanilla's looting pass does
2026-10-31 16:24:19 +00:00
rdbt adc65f2983 Merge pull request #5082 from MSedgeMC/fix/gamemode-ground-flight
Land a spectator who becomes creative while standing on the floor
2026-10-31 16:23:50 +00:00
rdbt cc332d5e7d Merge pull request #5081 from MSedgeMC/fix/egg-entity-data
Let a spawn egg carry entity data, as vanilla's does
2026-10-31 16:23:36 +00:00
rdbt 8a2d942360 Merge pull request #5079 from reservedbytes/fix/entities-only-template
fix(worldgen): a template with entities and no blocks places them
2026-10-31 16:23:21 +00:00
rdbt 46ed0adc78 Merge pull request #5078 from MSedgeMC/fix/spectator-chunks-rule
Let the spectators_generate_chunks rule decide who loads terrain
2026-10-31 16:23:08 +00:00
levzzzandClaude Opus 5.5 f80c0360d3 Main builds again: a splitscreen share has the device tracker's fields, the stew particles reach served players
#3727's Audio::share predates #4527's device tracking fields, and #4515's stew
particles call lan_send_particles with #4761's old arguments.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:23:05 +05:00
rdbt cda954ab2a Merge pull request #5077 from k3yh/fix/region-chunk-trailing-bytes
Load a region chunk whatever follows its root (#5064, point 6)
2026-10-31 16:22:55 +00:00
rdbt 2c2435d64d Merge pull request #5071 from reservedbytes/fix/player-creeper-blast-type
fix(combat): a player hurt by a creeper's blast takes a player_explosion
2026-10-31 16:22:41 +00:00
rdbt 0d80a814b8 Merge pull request #5062 from MSedgeMC/fix/pet-owner-team
Give a tamed pet its owner's scoreboard team
2026-10-31 16:22:14 +00:00
rdbt c64445140f Merge pull request #5050 from MSedgeMC/fix/nausea-blend
Start a restored nausea at full strength, as vanilla does
2026-10-31 16:21:40 +00:00
rdbt 048f0b280e Merge pull request #5044 from mulareal/fix-armor-stand-water-push-4849
entities: push armor stands with flowing water and lava
2026-10-31 16:21:26 +00:00
rdbt a1d4937336 Merge pull request #5040 from apeirs/column-bulk-section-read
Generated columns: read a whole section in one pass
2026-10-31 16:21:11 +00:00
rdbtCVSandClaude Opus 5.5 f3dd9dec81 horizon-prepare: --check refuses --progress and --complete
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:20:59 +00:00
rdbt 993b05456b Merge pull request #5039 from apeirs/fix/tnt-minecart-spares-rails
TNT minecart: a primed cart's blast spares rails and the blocks under them
2026-10-31 16:20:57 +00:00
rdbt c9feaa1ecd Merge pull request #5037 from TiggerTakkyTTT/fix-particles-tick-freeze
fix(client): stop particles during tick freeze
2026-10-31 16:20:44 +00:00
rdbt d2da889891 Merge pull request #5036 from ludovicthenot/docs/player-tick-notes
Native mods: say when the player tick hook runs and when it doesn't
2026-10-31 16:20:30 +00:00
rdbt 4740c146cf Merge pull request #5034 from navinor/fix/chunk-io-test-race
Keep the chunk_io tests off the queue the save tests also fill
2026-10-31 16:20:16 +00:00
rdbt 428ad90224 Merge pull request #5022 from apeirs/fix/elytra-vanilla-trig
Elytra: glide with Mth's sine table and float drag, as vanilla does
2026-10-31 16:19:54 +00:00
rdbt 9c97099864 Merge pull request #5017 from Username59138/fix/melee-attack-damage-attribute
Melee damage reads the attack_damage attribute
2026-10-31 16:19:41 +00:00
rdbt 0139c8596a Merge pull request #5011 from doodlyyousuf/fix/4986-halloween-pumpkin-head
Give zombies and skeletons pumpkin heads on Halloween
2026-10-31 16:19:27 +00:00
rdbt ec151d869e Merge pull request #5007 from repositorykreml1n/fix/boat-hand-jitter-4583
The hands stop shaking while a ridden boat turns
2026-10-31 16:19:05 +00:00
rdbt 3836642a67 Merge pull request #5006 from MSedgeMC/fix/golem-creeper-refusal
Keep an iron golem from fighting the creepers it refuses
2026-10-31 16:18:50 +00:00
rdbtCVSandClaude Opus 5.5 9b74114b8a Clear the projectile attacker after a refused hit; name the host by its own name
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:18:38 +00:00
rdbt 5092ba0f5a Merge pull request #5005 from MSedgeMC/fix/universal-anger
Let the universal_anger rule reach a wolf's target goal
2026-10-31 16:18:36 +00:00
rdbt 66ab78011a Merge pull request #4997 from reservedbytes/fix/firework-damage-type
fix(combat): firework rockets hurt as fireworks
2026-10-31 16:18:23 +00:00
rdbt bb63d3f310 Merge pull request #4991 from elythepreciouscat/perf/clamp-vertical-occlusion-search
Renderer: clamp vertical occlusion search grid height to non-air sections
2026-10-31 16:18:09 +00:00
rdbt 45b71aa863 Merge pull request #4980 from doodlyyousuf/fix/4887-offhand-container-swap
Swap F in a chest with the off hand
2026-10-31 16:17:56 +00:00
rdbt 005a8c3792 Merge pull request #4969 from MSedgeMC/fix/pufferfish-enderman-save-data
Save a pufferfish's puff state and an enderman's carried block
2026-10-31 16:17:42 +00:00
rdbt 80a52907b0 Merge pull request #4959 from MSedgeMC/fix/allay-no-ai-dance
Stop a NoAI allay dancing, fetching and delivering
2026-10-31 16:17:29 +00:00
rdbt 6d353618d3 Merge pull request #4936 from elythepreciouscat/fix/sprint-knockback-reset
Combat Parity: reset sprint and slow attacker on sprint-knockback hit
2026-10-31 16:17:08 +00:00
rdbt 4031e5063b Merge pull request #4930 from k3yh/fix/item-custom-model-data
Send custom_model_data and item_model to the client (#4916)
2026-10-31 16:16:55 +00:00
levzzz d4c3a8bd4c Merge pull request #5140 from levzzz5154/mods/native-server-starting
Native mods: hook points for the server starting and stopping (API next)
2026-10-31 21:16:43 +05:00
rdbt 60134bd895 Merge pull request #4923 from senjinthedragon/hdr-calibration
HDR: calibration, full screen, one test picture at a time
2026-10-31 16:16:31 +00:00
rdbt 871c4691a3 Merge pull request #4911 from sirrayi/fix/guest-passenger-swim
Player swimming needs !isPassenger(), in both of Entity.updateSwimming's branches
2026-10-31 16:16:10 +00:00
rdbt fa7dbecb43 Merge pull request #4834 from Dufru/loc/add-pt-br-translation
i18n(launcher): add Brazilian Portuguese (pt-BR) translation
2026-10-31 16:15:57 +00:00
rdbt ad49dcc284 Merge pull request #4765 from mulareal/fix-hitsounds-4330
Fix missing hit and hurt sounds on server thread and multiplayer (#4330)
2026-10-31 16:15:43 +00:00
rdbt 6e446359bd Merge pull request #4737 from WooCaash/fix/3852-cape
Show the signed-in account's active cape in single player
2026-10-31 16:15:30 +00:00
rdbt 7027d1437b Merge pull request #4667 from posok124521/fix/entity-chest-open
Fix opening chest boats, rafts and container minecarts
2026-10-31 16:15:16 +00:00
rdbt 4b04fb40e8 Merge pull request #4631 from AurelienBelloir/parity/display-entity-transforms
Decompose matrix display transformations like MatrixUtil
2026-10-31 16:15:03 +00:00
levzzz 5017aa5ab0 Merge pull request #5026 from Qwerner88880/fix-native-followups
Native mods: on_equip for /item's main hand, recipes in subfolders, no sends while reconfiguring
2026-10-31 21:14:47 +05:00
rdbt a543fffdc1 Merge pull request #4555 from AurelienBelloir/parity/data-pack-dialogs
Send the world's data pack dialogs in the dialog registry
2026-10-31 16:14:34 +00:00
rdbt 7c808bba52 Merge pull request #4543 from AurelienBelloir/parity/default-and-any-block-use
Fire the default_block_use and any_block_use advancement triggers
2026-10-31 16:14:20 +00:00
rdbt e0b41b8481 Merge pull request #4515 from MSedgeMC/fix/mooshroom-stew
Let a brown mooshroom take a flower's stew effects
2026-10-31 16:14:06 +00:00
rdbt db3e803087 Merge pull request #4505 from kacpherek/fix/seam-batch
Server path parity: armour swap, boat paddling, guest scoreboards, respawn message, fence leads, boat air bar, xp orbs
2026-10-31 16:13:52 +00:00
rdbt d401b63cdc Merge pull request #4462 from elythepreciouscat/perf/worldgen-density-scratch-and-position-seed
Worldgen: optimize terrain fill, aquifer cell caching, and 48-bit positional seeding
2026-10-31 16:13:38 +00:00
rdbt 076582df26 Merge pull request #4380 from xFedeT/fix/explorer-map-references
Exploration maps skip the structures a map already pointed at
2026-10-31 16:13:24 +00:00
rdbt 562feb2581 Merge pull request #4371 from iLostName/core-text-shader
resource packs: a pack's core/text shader draws the GUI's text, so server HUDs show
2026-10-31 16:13:11 +00:00
rdbt fce73cb0b4 Merge pull request #4341 from MSedgeMC/fix/4301-respawn-immunity
Make the client-loaded immunity term real
2026-10-31 16:12:58 +00:00
rdbt b0bede7fc6 Merge pull request #4186 from Vaspyyy/weather-glass-shader-inputs
Shader API: sprite tags and the weather at the camera for the materia…
2026-10-31 16:12:44 +00:00
rdbt 6a7519dde9 Merge pull request #3938 from DUzzL/fix/issue-3915-atlauncher-import
Launcher: copy ATLauncher instances into Java instances
2026-10-31 16:12:09 +00:00
rdbt b8fc236a5d Merge pull request #3727 from martipops/split/02-audio
Splitscreen: share one audio output between local-player sound engines
2026-10-31 16:11:48 +00:00
rdbt 8f76982036 Merge pull request #3468 from AHHhHHHHH-H/pr/world-wide-lod-slider
LOD Distance: a slider up to the whole world, with heightmap far tiles
2026-10-31 16:11:35 +00:00
Ludovic Marie 138bf9d166 Merge pull request #5217 from horizon-rust/feat/live-stats-exact
🤖 Agent: CI green on eccfe3d9b (clippy, launcher, tests, python, verify). Telemetry server checked in its own workspace (fmt, clippy -D warnings, tests). Exact live count, the live count's history and /v1/live/history; #5235 is stacked on it.
2026-10-31 17:11:31 +01:00
rdbt 6e2102e2cb Merge pull request #5224 from DmytroChup/fix/5213-port-pack-windows-tests
fix(port-pack): gate unix symlink tests with #[cfg(unix)]
2026-10-31 16:09:07 +00:00
Ludovic MarieandClaude Opus 5.5 df7ef012e4 Discord: the new official invite discord.gg/playhorizon
Ludovic (2026-10-31): replace the old invite everywhere. The launcher
reads launcher/links.json from main, so installed launchers pick it up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:09:05 +01:00
rdbt 01049b3eab Merge pull request #5222 from some-du6e/feat/debug-options-screen
Debug options screen (F3+F6): choose what the F3 overlay shows
2026-10-31 16:09:00 +00:00
rdbt 3faaef6693 Merge pull request #5161 from arismoko/hook-fragments
Batch native hook API numbers from fragments
2026-10-31 16:08:30 +00:00
ErikKalikasandClaude Opus 5.5 f755be19d8 Merge upstream/main; renumber mod block entity update tags to API 43
The detached encoder for saved chunks past the view distance (lan::far)
keeps the mods' update tag hook, which may then run off the server thread.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:08:27 +02:00
rdbt 22ba7eac80 Merge pull request #5102 from hauserdew-ui/fix/alsa-device-polling
Audio: list output devices only when the ALSA cards or Core Audio devices change
2026-10-31 16:08:09 +00:00
rdbt c820aaba1a Merge pull request #5033 from navinor/fix/sprint-blindness
Blindness stops a sprint and keeps one from starting
2026-10-31 16:07:43 +00:00
rdbt f115979414 Merge pull request #3945 from kacpherek/fix/split-maps-villager-trades
Map banner and frame markers
2026-10-31 16:07:06 +00:00
levzzz 46a0cf3eea Merge remote-tracking branch 'hr/main' into mods/native-server-starting
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-10-31 21:02:38 +05:00
levzzz 5062f22592 Merge pull request #5146 from levzzz5154/mods/native-level-random
Native mods: the server level's random (API next)
2026-10-31 21:01:52 +05:00
Ludovic MarieandClaude Opus 5.5 8c913dff6f Official SMP count: in the launcher and in the telemetry server's history
- horizon_telemetry::server_status: the server-list status ping (handshake, status
  request, JSON answer) and OFFICIAL_ADDRESS, the one place the official server's
  address is kept (the launcher's and the game's server lists use it).
- Launcher: "· M on the official SMP" beside "N playing now", pinged once a minute on its
  own thread with a 3-second timeout, hidden when it doesn't answer; it opens the Servers
  page.
- Telemetry server: pings the official server every minute (HORIZON_TELEMETRY_OFFICIAL_SERVER)
  and keeps its player count as the "official" series, like the live count;
  GET /v1/live/history takes series=players|official and answers the latest minute too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:01:32 +01:00
rdbtCVSandClaude Opus 5.5 d5ca93d09e horizon-prepare: list --version-json and --assets-dir in the asset subcommands' usage
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:59:15 +00:00
levzzz 6e09a5d817 Merge fix: close native_level_random 2026-10-31 20:51:25 +05:00
levzzz 2283c9cc25 Docs: one blank line before the bundle section 2026-10-31 20:45:27 +05:00
levzzz 5c7cf4f4c1 Merge remote-tracking branch 'hr/main' into mods/native-level-random
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-10-31 20:45:27 +05:00
levzzz 85be6c20bb Merge remote-tracking branch 'hr/main' into mods/native-server-starting
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-10-31 20:45:23 +05:00
Dmytro ChuprynaandAntigravity 31046b9b61 fix(port-pack): gate unix symlink tests with #[cfg(unix)]
Tests a_home_behind_a_link_takes_its_packs and a_link_below_the_base_is_refused imported std::os::unix without #[cfg(unix)], breaking cargo test on Windows.

Fixes #5213

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 16:45:21 +01:00
levzzz d91cab68de Merge pull request #5151 from levzzz5154/mods/native-item-entities
Native mods: read and change item entities near a block (API next)
2026-10-31 20:44:37 +05:00
some-du6eandClaude Opus 5.5 e47213a981 Debug options screen (F3+F6): choose what the F3 overlay shows
Ports DebugOptionsScreen, DebugScreenEntries and DebugScreenEntryList
from 26.3: every entry can be off, in the overlay or always shown, the
default and performance profiles, debug-profile.json beside options.txt.
The overlay, F3+B, F3+G and the 3D crosshair follow the entries, and the
screen's GUI Scale button edits debugGuiScale.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 09:44:24 -06:00
ElyTheCatandCodex 2f9b57f78e Keep sprint input held during knockback check
Use a raw mouse click instead of the deferred attack agent task, which releases movement keys before reporting. Prove the click landed from the victim's server hurt log while measuring the first post-hit speed sample.

Co-Authored-By: Codex <[email protected]>
2026-10-31 19:13:42 +03:30
Ludovic MarieandClaude Opus 5.5 eccfe3d9b7 Live count: exact numbers, a history of it, and a link to playhorizon.dev/stats
- GET /v1/live serves exact playing, lan, versions and platforms (live::round is gone);
  rare versions are still summed under "other".
- The telemetry server writes the live count down once a minute (totals only: playing,
  lan, per platform) into live_minute (7 days), live_hour (90 days) and live_day (kept).
- GET /v1/live/history?range=24h|7d|30d|all serves the series ([start, average, peak]),
  the range's peak, the all-time peak and the average per platform, cached a minute, on
  the live count's rate limit.
- The launcher's "N playing now" opens playhorizon.dev/stats.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:40:33 +01:00
mularealandAntigravity 5c8b864105 Fix missing hit and hurt sounds on server thread and multiplayer (#4330)
- Dispatch HealthEvent::DamageBroadcast and MotionEvent::HurtSound for guests in leave_guest and guest_server_tick (along with record_combat and wear_armor in leave_guest) so every unblocked player hurt immediately sends ClientboundDamageEventPacket with its exact DamageTypeId and broadcasts the hurt sound to nearby players.
- Run tick_guests and the deferred guest attack/effect flushes before lan_flush_effects and lan_swings in tick_lan so captured sounds and mob damage events from guest turns are flushed on the same tick and in the right dimension.
- Attack live mobs directly via attack_mob in guest_attack after within_attack_range instead of turning the guest's view to the mob's center with look_at_entity and re-running raycast pick checks.
- Handle ClientboundSoundEntityPacket in remote.rs and prefer inline RegistryData effects in hurt_sound_for.

Fixes #4330

Co-Authored-By: Antigravity (Gemini) <[email protected]>
2026-10-31 16:39:59 +01:00
ElyTheCatandCodex 4705bc2aa8 Reuse the heightmap update loop
Keep the dynamic per-map bounds introduced for prefilled heightmaps, but restore the shared MAPS loop so the optimization does not duplicate the two heightmap paths.

Co-Authored-By: Codex <[email protected]>
2026-10-31 19:06:01 +03:30
Ludovic MarieandClaude Opus 5.5 fa0de01c99 Unique random UUIDs: mobs generated in a pack no longer share one
random_uuid() seeded each UUID from the clock alone, so calls in the same
clock tick (a microsecond on macOS) returned the same UUID: a pack of mobs
generated together shared one, and a vanilla client keeps one entity per
UUID, so it never moved the others. Seed once, then step one shared
counter through SplitMix64.

Fixes #5207.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:34:11 +01:00
levzzz 0d2a9d8402 Docs: API 41 stays bundles' data files 2026-10-31 20:30:54 +05:00
levzzz 9021ffb4f5 Merge remote-tracking branch 'hr/main' into mods/native-item-entities
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-10-31 20:30:30 +05:00
levzzz 93d78e0c53 Merge remote-tracking branch 'hr/main' into mods/native-level-random
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-10-31 20:30:19 +05:00
iseaprojectsandDeepSeek V4.1 Flash a257a4221e tools: read cmd /C ver in the console's code page on Windows
`perf_bench.windows_version` asked subprocess for text, which decodes with
the locale encoding: `pr_ready` runs the tests with PYTHONUTF8=1, so the
reader thread decoded `cmd /C ver`'s OEM output as UTF-8, failed, and left
`stdout` None. `test_report_without_a_load_average` then raised, so every
Windows contributor's self-check showed `Python tests: fail`.

Decode the command's output as OEM, with replacement for stray bytes, and
keep the `platform.version()` fallback for an empty result. The regression
test runs the import in a child process with PYTHONUTF8=1 and checks it still
prints a Windows version; it fails on main with a CalledProcessError.

Fixes #5201

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-31 11:28:02 -04:00
Ludovic Marie 979aa64e51 Merge pull request #5190 from horizon-rust/server-admin/console
🤖 Agent: verified: rebased on main after #5166/#5186, tools/server_admin_e2e.py passed (45 checks: the console attaches, runs say/list, shows their lines, detaches), pr_ready green, CI green.
2026-10-31 16:26:00 +01:00
levzzz e46f743fa4 Merge remote-tracking branch 'hr/main' into mods/native-server-starting
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-10-31 20:25:08 +05:00
Ludovic Marie 1629fa2dfc Merge pull request #5125 from horizon-rust/feat/anticheat-on-by-default
🤖 Agent: proven on fdfa4fbfa (rebased on main 125875b84): pr_ready --quick green (1427 tests, clippy, fmt, 495 Python tests); CI green (parity 35/35 after a rerun of the known lunge_motion flake, untouched here); anticheat_e2e default set (combat with no server.toml, ground, off, prediction, timer) green; a real vanilla 26.3 client hits a cow with combat on and is never flagged; MSPT 60 bots: anticheat off 3.68/3.69, default 4.11/4.13, 0 flags.
2026-10-31 16:24:18 +01:00
ErikKalikasandClaude Opus 5.5 dd0c770b86 Drop empty chunk update tags; replace a mod block entity on another block
BlockEntityInfo.create sends no tag when getUpdateTag is empty, for every
block entity. The client replaces a mod block entity when the block changes
to another one with the same block entity id, as LevelChunk.setBlockState
removes the old block's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:23:53 +02:00
levzzz 85d1165657 Merge pull request #3964 from levzzz5154/launcher/native-mod-crates
Native mods: own crates, Horizon's lock as their allow list, data files, named hooks, post-build steps
2026-10-31 20:20:37 +05:00
simonandClaude Opus 5.5 3fde6d64dd Food that can't be placed on the clicked block is eaten on this client too
BlockItem.useOn falls back to Item.use for a consumable whose placement
failed. The server already did, but a server client ended the click
without starting the use, so sweet berries, glow berries, carrots and
potatoes were eaten with no animation, and a released click couldn't stop
it. In adventure mode ItemStack.useOn passes, so UseItem now goes out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:12:37 +10:00
socksproxandDevin 873ca4fdaf Native mods: build under a path MinGW-w64 can read (#5189)
A Windows user name like "Кириченко Павло" puts the launcher's data under
a non-ASCII path, and the build's MinGW-w64 tools — gcc, as, ld, dlltool —
open files through ANSI APIs, so linking failed with exit code 101 and a
player's own redirection of %APPDATA% changed nothing (dirs::data_dir
reads the known folder, not the variable).

Every path the build's tools see — the toolchain, its Cargo home, the
native/ build tree and now the tools' temp dir, where gcc, as and
cargo install put their scratch files — comes from backend::build_dir:
the data folder resolved (so a junction to a drive with an ASCII path
already works), and when that still isn't ASCII, the folder's short (8.3)
name, or a folder of ProgramData's named for it.

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 16:11:10 +01:00
valuttaandClaude Opus 5.5 8961611cbe Audio: list output devices only when the Core Audio devices change
On macOS, cpal's output_devices() opens every device to see whether it
plays: about 90 ms with twenty devices, once a second on the render
thread. Naming the devices takes 2 or 3 ms, so card_fingerprint now
returns their names there, and the list is looked at only when they
change, as on Linux.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:10:49 +02:00
Ludovic MarieandClaude Opus 5.5 640f459087 horizon-server mcp: an MCP server for AI agents to run and moderate a server
JSON-RPC 2.0 on stdin and stdout, one message per line, as horizon-ctl mcp.
Each tool is one control API call on the server running in the folder, with
the token read from .horizon/api.json: status, players, run_command, logs,
events, kick, ban, pardon, ban_ip, pardon_ip, op, deop, whitelist, backups,
restore, get_config, set_config, start, restart and stop. stop, restore and
delete_backup need confirm: true; tools that send someone away or can't be
undone carry destructiveHint. The admin e2e bans, pardons, reads events and
is refused an unconfirmed stop through it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:08:14 +01:00
Ludovic Marie caf83890c8 Merge pull request #5198 from horizon-rust/fix/resourcepacks-linked-home
🤖 Agent: resource packs install for a home behind a link (Silverblue /home, macOS /var), asked for by the orchestrator. directory(base, path) accepts links at or above the owning folder and still refuses a link below it; the canonical path must stay inside the canonical folder. 2 new tests; #5094's test workaround reverted (the tests run under the linked /var again). pr_ready --quick green (port-pack 99, launcher 316); CI green. Builds on gravitacia's #4579 check, credited there.
2026-10-31 16:06:06 +01:00
iseaprojectsandDeepSeek V4.1 Flash 53171431d0 Anticheat prediction: a burst keeps the setback point before its lie
A catch-up plays every tick recorded since the last one, and `play` moves the
replay to the position each tick claimed. A tick farther than the tolerance
was counted, but a tick after it that agreed from the spot the lie claimed
still replaced `agreed`, so `guest_replay` read a setback point already at the
cheated position: a one-tick lie arriving in a burst with the honest tick
after it counted once and set nothing back.

A catch-up that counts a tick now keeps the agreed point where it was for the
rest of that catch-up. The regression test plays ticks 20 and 21 in one
catch-up, tick 20 claiming +3.0 and tick 21 honest from there, next to
`a_cheat_counts_from_where_it_started`.

Fixes #5132

Co-Authored-By: DeepSeek V4.1 Flash <[email protected]>
2026-10-31 11:04:13 -04:00
Ludovic MarieandClaude Opus 5.5 fd091af76a horizon-server console: attach to the running server
The recent log, then every new line as it happens (the control API's event
stream), a header with the TPS, MSPT, players and RAM refreshed every second on
a terminal (an ANSI scroll region; no TUI framework), and a command line whose
lines run as console commands. Ctrl-D detaches and leaves the server running;
when stdout isn't a terminal it prints plain lines. The admin e2e attaches
through a pipe and runs two commands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:59:57 +01:00
Ludovic Marie d55e66d656 Merge pull request #5105 from horizon-rust/fix/lan-e2e-reopen-player
🤖 Agent: #5092 is merged, so this regression check (lan_e2e reopen_player) can land now: it fails on the old main and passes with #5092. CI is green.
2026-10-31 15:58:49 +01:00
Ludovic Marie aa27eca326 Merge pull request #5186 from horizon-rust/server-admin/events
🤖 Agent: verified: pr_ready green (36 harnesses affected, all passed), the e2e's 39 event checks passed on the Mac mini; CI caught a race in the stream test (an event between the headers and the first cursor read was skipped), fixed in bef1eff27, CI green.
2026-10-31 15:52:34 +01:00
Ludovic Marie 7ccd9406af Merge pull request #5166 from horizon-rust/server-admin/cli
🤖 Agent: verified: rebased on main after #5165, pr_ready green (64 horizon-server tests), tools/server_admin_e2e.py passed with the CLI checks on the Mac mini, CI green.
2026-10-31 15:52:30 +01:00
Ludovic MarieandClaude Opus 5.5 3f7e92d6ab Resource packs: a home folder behind a link takes its packs
resourcepacks::directory refused a link anywhere up to /, so a launcher
home behind one (Fedora Silverblue's /home -> /var/home, macOS's /var ->
/private/var) installed no packs. It now takes the folder that owns the
packs (the work folder or the profile home): links at or above it are the
player's layout and are accepted, every step below it must be an ordinary
directory, and the canonical path must stay inside the canonical folder.
Builds on gravitacia's check (#4579), revised by levzzz5154 (b49925118).
#5094's test-only workaround (resolving the temp dir) is reverted: the
tests run under macOS's linked /var again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:51:11 +01:00
Federico Tagliani 97d4bbf06c Merge remote-tracking branch 'origin/main' into fix/creeper-lingering-cloud 2026-10-31 15:49:48 +01:00
Federico TaglianiandClaude Sonnet 5.5 722603631b Mob effects keep their ambient flag, and the creeper's cloud copies it
MobEffectInstance.CODEC saves ambient and the NBT loader dropped it. A mob keeps
ambient_effects now: loaded, saved, kept through conversion and expiry,
set by /effect give as an instance with ambient false, and the faint
swirls and DATA_EFFECT_AMBIENCE_ID follow it. add_effect_with applies
MobEffectInstance.update's rules for the flags of an effect already there, and
the cloud applies the creeper's effects with theirs.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 15:49:47 +01:00
Federico Tagliani 6d6715fd11 Merge remote-tracking branch 'origin/main' into fix/creeper-lingering-cloud 2026-10-31 15:47:16 +01:00
Ludovic MarieandClaude Opus 5.5 c8146cdba6 Mobs: compare each block write's collision shapes once per tick, not once per pathing mob
ServerLevel.sendBlockUpdated compares the old and new collision shapes once
per write and only then asks each navigating mob's shouldRecomputePath. The
mob loop rebuilt both shapes for every pathing mob and every write; the shapes
use no entity context and the world is fixed during the loop, so the list of
changed positions is the same for every mob.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:47:05 +01:00
Ludovic MarieandClaude Opus 5.5 fdfa4fbfab Anticheat on by default: a combat check that acts, the other checks log only
Ludovic, 10-31: turn the anticheat on. Checks a vanilla client can't trip act by default; the
others run but only log until real players show no false positive. `[anticheat] enabled = false`,
or `enabled = false` in a check's table, turns them off.

New `combat` check, acting by default: vanilla's client attacks only from Minecraft.startAttack,
once a client tick, on the entity of the tick's hitResult, and swings (ServerboundPunchPacket) with
each attack. A client tick with more attacks than swings, or a second entity attacked in the same
tick, flags; from `setback` (2) on the attacks are dropped, `kick` 20. The swing counts before or
after the attack (older clients behind a version proxy swing first).

`timer`, `ground` and `prediction` default to setback, alert and kick 0 (log only); a log-only ground
check keeps the claim. The thresholds once trusted are in docs/anticheat.md.

horizon-bots swing after each attack as vanilla does; --no-swing and --attack A,B are the cheats for
tools/anticheat_e2e.py --check combat, which runs with no server.toml (the defaults).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:44:15 +01:00
Ludovic MarieandClaude Opus 5.5 bef1eff277 horizon-server events: a stream starts when it's asked for, not when its headers are out
An event emitted between the reply's headers and the stream's first read of the
cursor was skipped (CI caught it in a_stream_sends_what_happens).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:37:22 +01:00
Federico Tagliani 138b02a242 Merge remote-tracking branch 'origin/main' into fix/sulfur-cube-hit-angles 2026-10-31 15:35:30 +01:00
Ludovic MarieandClaude Opus 5.5 928cc155fb horizon-server: players, kick, ban, pardon, ban-ip and pardon-ip commands
`players [--json]` shows who is online, the operators, the whitelist and the
bans (a stopped server's from its files); `kick`, `ban`, `pardon`, `ban-ip`
and `pardon-ip` moderate the running server through the control API. The
admin e2e runs them too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:30:02 +01:00
valuttaandClaude Opus 5.5 30365e778a Audio: list output devices only when the Core Audio devices change
On macOS, cpal's output_devices() opens every device to see whether it
plays: about 90 ms with twenty devices, once a second on the render
thread. Naming the devices takes 2 or 3 ms, so card_fingerprint now
returns their names there, and the list is looked at only when they
change, as on Linux.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:27:56 +01:00
levzzzandClaude Opus 5.5 da50e90690 Merge main into launcher/native-mod-crates
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:25:37 +05:00
Federico TaglianiandClaude Sonnet 5.5 645ee4c546 A projectile knocks a mob the way it travelled
dealDefaultKnockback takes the direction from the projectile's motion
(Projectile.calculateHorizontalHurtKnockbackDirection, negated), not from its
position. The mob takes it from Mob::projectile_motion, set where a
projectile's hit is dealt (arrows, thrown items, the trident), for every mob
and for the sulfur cube's SulfurCube.knockback and LivingEntity.knockback
branches. Checked on six blows against the vanilla helpers, two from an arrow
with its motion.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 15:14:06 +01:00
AkirabaneandClaude Sonnet 5.5 095c6c48cd horizon-server: the launchd test no longer assumes / in paths
service::tests::units_run_the_supervisor expected the agent's log path
as `/srv/R&amp;D/logs/supervisor.log`, but `launchd_plist` builds it with
`dir.join("logs/supervisor.log")`, which Windows prints with a `\` where
the join adds a separator. The code is right (the launchd agent only
exists on macOS); the test assumed the separator, so it failed on Windows,
where CI only builds and runs no tests.

The test builds the expected string from the same `Path::join`, with the
`&` escaped as `&amp;`, and keeps the literal macOS path under
`#[cfg(unix)]`.

Fixes #5168

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 15:10:14 +01:00
Federico TaglianiandClaude Sonnet 5.5 5b49acb01b Port map: seven rows of #3513 are ported already
HoglinAi and Zoglin initFightActivity (MeleeAttack 40 and 15), AbstractCubeMob
push and dealDamage (hurt_touching_golems), EndCrystal.defineSynchedData
(both sides), Enderman.dropCustomDeathLoot (carried_block_drops) and
Strider.getDismountLocationForPassenger (strider_dismount_location). Each
read against the vanilla method.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 15:10:09 +01:00
Federico Tagliani 39e0ed339e Merge remote-tracking branch 'origin/main' into fix/creeper-lingering-cloud 2026-10-31 15:02:19 +01:00
Federico TaglianiandClaude Sonnet 5.5 bbd87091bf Creeper cloud: the effects keep their visibility on those it affects
MobEffectInstance's copy keeps isVisible, so an effect the creeper had
with hideParticles stays hidden on the player or mob the cloud affects.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 15:02:18 +01:00
Federico Tagliani 23f6145aff Merge remote-tracking branch 'origin/main' into fix/sulfur-cube-hit-angles 2026-10-31 15:01:07 +01:00
Federico TaglianiandClaude Sonnet 5.5 f684653658 Sulfur cubes: the launch follows any causing entity, and the tags vanilla checks
SulfurCube.knockback reads source.getEntity(): the player, a mob, a
projectile's owner. Game::attacker_aim gives its eye, view direction and feet
for the blows the cube takes from them (melee, spear, mace, arrows, thrown
items, trident, a mob's attack), and the rotation by height uses the
entity's feet, not the projectile's position. As hurtServer does, a blow of
a type tagged no_knockback (explosions, falls...) does not knock the cube,
and one with no causing entity goes through LivingEntity.knockback.
Checked against vanilla's helper bodies on six blows, two from an arrow.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 14:54:26 +01:00
Ludovic MarieandClaude Opus 5.5 48ccd93267 horizon-server: an event stream in the control API
GET /api/v1/events streams server-sent events: every log line, the
supervisor's phase, and the game's typed join, leave, kick, chat and death
events with the player's name and UUID (?types= picks some; Last-Event-ID or
?since= resumes). Without Accept: text/event-stream it answers the events
after since as JSON. The dedicated server records the typed events where it
logs them and answers `server-events SINCE` on its control endpoint; the
supervisor fetches them every 250 ms. The admin e2e streams a bot's joins,
chat (a raw chat packet), kick, leave and death.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:49:11 +01:00
Federico TaglianiandClaude Sonnet 5.5 21b8d1f527 A creeper with effects leaves a lingering cloud when it explodes
Creeper.spawnLingeringCloud: right after the blast an AreaEffectCloud of
radius 2.5, wait time 10 and 300 ticks stays where the creeper stood,
shrinking by radius / duration a tick and by 0.5 for each entity it
affects, with the creeper's active effects at 0.25 of their remaining
duration. The potion cloud takes its radius per tick and its effects as
data now, and keeps an infinite effect infinite (withScaledDuration).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 14:42:32 +01:00
Federico Tagliani 6b333b2754 Merge remote-tracking branch 'origin/main' into fix/sulfur-cube-hit-angles 2026-10-31 14:35:18 +01:00
ElyTheCatandCodex 89a68f75de Fix boot loading screen merge regressions
Co-Authored-By: Codex <[email protected]>
2026-10-31 17:03:11 +03:30
Federico TaglianiandClaude Sonnet 5.5 3bb2c7d3e4 Sulfur cubes: a blow turns the launch by where the attacker aims
SulfurCube.knockback with a block now applies applyHorizontalHitAngleScale
(1.6) and applyVerticalHitAnglePowerTransfer (0.5) with the attacker's eye
and view, then the position rotation, in vanilla's order and float
arithmetic (Vec2.rotate through Mth's tables, Math.atan2). The player's
melee, spear and mace blows carry their aim. Checked against the bodies of
the vanilla helpers run on the real Vec2, Vec3 and Mth classes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 14:28:54 +01:00
arismoko baf72e46c1 Clarify fragment documentation 2026-10-31 08:23:58 -05:00
ElyTheCatandCodex c78d4495f0 Merge current main into boot loading screen
Co-Authored-By: Codex <[email protected]>
2026-10-31 16:53:50 +03:30
chi4a 5e0af16e10 Merge remote-tracking branch 'upstream/main' into fix/entity-chest-open 2026-10-31 15:21:12 +02:00
arismoko 17ca3981f0 Support helper-only API fragments 2026-10-31 08:21:12 -05:00
levzzzandClaude Opus 5.5 fc9326b05e Native mods: read and change item entities near a block
native_item_entities(dimension, min, max) lists the item entities whose
box meets a box (Level.getEntitiesOfClass(ItemEntity.class, box), as a
hopper's getItemsAtAndAbove asks); native_set_item_entity(dimension, id,
stack) changes one's stack or removes it when empty (ItemEntity.setItem,
discard), as a hopper's addItem does. On the level in place. API 41.

Fixes #5150. Part of #4953.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:19:18 +05:00
Federico Tagliani 0a6ecf0231 Merge remote-tracking branch 'origin/main' into fix/chat-validation-error 2026-10-31 14:15:42 +01:00
arismoko e7c2951d4c Batch native hook API numbers from fragments 2026-10-31 08:15:39 -05:00
levzzzandClaude Opus 5.5 ce12d07965 Native mods: the server level's random (native_level_random)
native_level_random(dimension) hands a mod the level's own random
(vanilla's Level.random, level.getRandom()), the stream Horizon's ports
of vanilla draw from, for the level in place and for a parked one. None
on a client of a server and for a level that isn't open. API 41.

Fixes #5145. Part of #4953.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:12:47 +05:00
arbelonson-sourceandClaude Opus 5.5 da62023123 Clear waypoints when the server reconfigures the client
Vanilla's handleConfigurationFinished builds a new ClientPacketListener,
whose constructor makes an empty ClientWaypointManager. Horizon kept the
last server's waypoints, so behind a proxy the locator bar stayed up on
a server with the locator_bar game rule off.

Refs #4760

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:08:32 +02:00
Federico TaglianiandClaude Sonnet 5.5 7cec464d45 Chat from other players is validated as vanilla's client does
ClientPacketListener.handlePlayerChat checks the global chat index (a mismatch
disconnects), the sender's PlayerInfo, and the sender's SignedMessageValidator:
keyed by the chat session the services' profile-key keys vouch for, or the
fallback of enforcesSecureChat. A message that fails shows the red italic
chat.validation_error in its chat type, tagged chatError
(ChatListener.handleChatMessageError); one a validator takes without a
signature shows as unsigned. The services' keys are fetched once per
connection off the game thread.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 14:06:51 +01:00
levzzzandClaude Opus 5.5 cf57da8885 Native mods: hook points for the server starting and stopping
native_server_about_to_start(dedicated) fires before the worlds load (the
start of MinecraftServer.loadLevel; NeoForge's ServerAboutToStartEvent),
native_server_stopping(dedicated) when an orderly stop begins (the start of
MinecraftServer.stopServer; ServerStoppingEvent), with the players still
connected and not yet saved. Both on the single player server thread and on
a dedicated server. API 41.

Fixes #5138. Part of #4953.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:04:04 +05:00
huiandZCode AI agent (GLM-5.3) 9cf6a6d154 Recipes load in id order, unreadable folders warn, the book reads subfolders
recipe_files sorts by the recipe's id, as vanilla lists resources in a map
keyed by it: crushing before crushing/iron, where a path's components put the
subfolder's file first. A subfolder or an entry that can't be listed is warned
and skipped, as PathPackResources keeps what it found; only the top folder's
read still fails. RecipeIndex::load walks the same files, so vanilla's
brewing/ recipes (279 of them, minecraft:brewing/<name>) reach the recipe
book's ids; a brewing recipe has no display of its own, like the special ones.

Review: reservedbytes in the pull request.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 14:03:09 +01:00
sirrayiandDeepSeek Harness f5d8035626 Player swimming needs !isPassenger(), in both of Entity.updateSwimming's branches
Horizon keeps two ports of that method. crates/horizon-world/src/swimming.rs's
`update` carries `!is_passenger()` in both branches and is compared against the
real class by tools/test_swimming_parity.py, whose oracle varies the flag; the
client's copy in motion/fluids.rs::fluid_base_tick left the term out (#4427).

Only the term and the flag that feeds it: main already sits a riding guest up
through passenger_pose_tick, so this drops the duplicate of that.

The new test warms the fluid tick once first — fluid_base_tick samples
was_eye_in_water before it updates the fluids, so a fresh body could not report
is_under_water and the test panicked wherever .local/ existed.

Generated with DeepSeek Harness (deepseek-v4.1-flash)

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 13:00:02 +00:00
socksproxandDevin (SWE-2 High) d27432c461 Bound the painting and mob picks in attack() by the unfiltered block hit
The last filtered pick()s that compare a distance: a painting or mob
behind a wall in the band between block and entity interaction range
could still be broken or attacked through it. before_block() runs the
same comparison against pick_unfiltered(), as the other entity picks do.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 13:56:07 +01:00
socksproxandDevin (SWE-2 High) f7f2747724 Launcher: put split secrets back when the keychain refuses a delete
set_item wrote the merged secret over key before removing key.1, key.2,
...; a removal that then failed left the merged item beside leftover
pieces, and the next read joined them into a secret that doesn't parse.
The account looked signed out, and the fold retried on the corrupted
string until a delete went through and made it permanent. The same gap
was in set_pieces' leftover clean-up on Windows.

Both now snapshot the items first and write the old layout back when a
delete is refused, so a failed write leaves the store reading the
secret it answered before — pieces removed before the failure included.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 13:52:31 +01:00
socksproxandDevin (SWE-2 High) a4ecd4ebb6 Bound entity picks by the unfiltered block hit
`GameRenderer.pick` bounds the entity ray by the block hit before
`filterHitResult` drops it past block reach: a wall between the two
ranges still hides the entity. `pick_unfiltered` keeps that traversal
for `before_block` and the deflect and mob-interact occlusion checks;
`pick` keeps the filter only where a block target is consumed.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 13:47:06 +01:00
mularealandAntigravity 0b179e13dc lan: keep moves queued behind an attack or interaction for the next tick
Fixes #4524.

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 13:41:35 +01:00
AkirabaneandClaude Sonnet 5.5 57c54aac90 Recipe results: a bare item id names the item, as vanilla reads it
Vanilla's ItemStackTemplate.CODEC (and TransmuteResult.CODEC) is
Codec.withAlternative(<id, count, components>, Item.CODEC): a recipe's
"result" is either the stack's fields or just the item's id, so
"result": "mod:item" makes that item. Horizon read only result.id, so a
recipe written that way loaded and made minecraft:air.

horizon_world::loot::recipe_result_id reads both forms, and every place
that reads a recipe's result uses it: Template::parse (shaped, shapeless,
cooking and the special recipes), the banner duplicate and shield
decoration recipes, smithing and stonecutting, the recipe book, the
stonecutter and advancement packets, and the smelting loot modifier.

A result with no "id" (such as the "item" key of older versions) still
loads here as an empty stack; vanilla refuses the recipe. That is left
for its own change.

Tests: the helper, Recipes::load with a bare result, and a content mod
whose recipes make its own items either way (the full content root test).

Fixes #4904

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 13:39:32 +01:00
k3yhandClaude Opus 5.5 eb932b91a3 Keep a NaN's bits in network NBT, as vanilla does (#5064)
Vanilla writes network NBT (FriendlyByteBuf.writeNbt) through Netty's
ByteBuf, whose writeFloat and writeDouble keep a NaN's bits; only the
disk path (NbtIo.write through DataOutputStream) writes the canonical
NaN. Horizon canonicalized on both. The writer now canonicalizes only for
write_named, and the old test that asserted a canonical network NaN
checks both paths against vanilla 26.3's bytes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 07:38:32 -05:00
masa-masa-0314andGLM 5.3 9a1dc67c0d Localization: the server connection's phases follow the language
The connect flow drew its phase texts as literals on the net thread:
"Connecting to the server..." (also the Remote's initial status), "Joining
world..." and "Finding your friend's world..." stayed English in every
language while the middle phases ("Logging in...", "Encrypting...") were
already vanilla keys. The three now go through `connect.connecting`,
`connect.joining` and Horizon's new `horizon.multiplayer.finding_friend`
(all six bundles), and the local world-open progress line says
`horizon.loading.opening_world` / `horizon.loading.cancelling_world`
instead of hardcoded "Opening world" / "Cancelling world".

`NOT_SIGNED_IN` stays literal on purpose: `reconnect::allowed` matches
disconnect reasons with English keywords ("online mode", "microsoft
account"), so a translated message would slip past the never-retry list.
Matching on keys instead of texts is its own fix.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 20:36:51 +08:00
socksproxandDevin (SWE-2 High) 46329b3cb6 Keep the scaled seat of a scaled non-avatar vehicle
`remote_mob_attributes` puts `minecraft:scale` on any living entity, but
`awake_dims_in` grows only avatars' boxes: a scaled strider's `height()`
stays unscaled, so un-divide the default attachment only for them.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 13:31:39 +01:00
levzzz 8306da9cc3 Merge #4590 into launcher/native-mod-crates
Builds keep #4590's profiles, plan and timing and gain #3964's crate
check and post-build steps; post-build programs build with the chosen
profile. The strings #3855 retired stay out of the translations.
2026-10-31 17:31:02 +05:00
levzzz 9b6aa1342a Merge #3891 into launcher/native-fast-profile
#3855 (in #3891) moved Native mods from Settings to the Mods page: the
build's time, how it ended and the Release build switch move with it, to
the instance's Native build row. The install notice #3855 removed stays
removed; the build record keeps #3891's per-mod sha256.
2026-10-31 17:30:28 +05:00
oskal675-designandCodex GPT-6.1 c6c043e8b5 Resolve enchantment-dependent component item models
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-10-31 17:28:33 +05:00
MSedgeMC 43a17d0af9 Draw a display's shadow, interpolated as vanilla draws it
A display entity carries shadow_radius and shadow_strength, and vanilla
interpolates both across its interpolation window and casts a shadow when
the radius is above zero, through the same gates a mob's shadow uses.
Horizon parsed and blended neither: the two fields sat unread and the
shadow pass collected only mobs, dropped items and experience orbs.

The blend now carries them, the gate arithmetic lives in one function that
both casters call, and text, block and item displays join the pass.
2026-10-31 17:56:23 +05:30
MSedgeMC 67979afe8d Read the projectile power the server sends with a projectile's motion
ClientPacketListener.handleProjectilePowerPacket finds the entity by id and,
when it is a hurting projectile, takes the power it accelerates by. Horizon
decoded the packet and dropped it, so a deflected fireball - whose power
halves - kept the spawn default on the client between tracker updates. Our
own server already sends it bundled with the motion.
2026-10-31 17:53:57 +05:30
ElyTheCatandCodex c37ca8bbc3 Preserve guest interaction ranges
Co-Authored-By: Codex <[email protected]>
2026-10-31 13:23:57 +01:00
SylviromiandClaude Opus 5.5 3b77664bdb Native mods: recipe helpers work on servers, which have no player
native_recipes_of_type, native_recipe_ingredient and native_recipe_result
treated a Game with no player as outside a world. A server's Game (the
server thread, a dedicated server, a LAN host) never has one, so a machine
ticking there got no recipes, though their docs say they are the server's
table. Outside a world now means a client with no player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 15:18:28 +03:00
chi4aandDeepSeek Harness 45cad03deb Fix vehicle container close events and the tick close
Use the vehicle's own position for CONTAINER_OPEN and CONTAINER_CLOSE: vanilla
Entity.gameEvent calls level().gameEvent(source, event, this.position) and
AbstractChestBoat/MinecartChest.stopOpen use position(), not y + 0.5.

Close a vehicle menu through one helper: store the menu's slots back, remove the
shared placeholder, and fire CONTAINER_CLOSE for a chest boat or chest minecart
(no-op for a hopper minecart). The ServerPlayer.tick stillValid close
(out of reach or vehicle gone) now uses it too, so it no longer leaves the
placeholder behind or skips the event, matching closeContainer -> removed ->
stopOpen.

Co-Authored-By: DeepSeek Harness (deepseek-flash) <[email protected]>
2026-10-31 14:12:38 +02:00
oskal675-designandCodex GPT-6.1 cf8ed9d8c3 Render each remote hand with its own item stack
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-10-31 17:01:21 +05:00
AurelienBelloirandClaude Opus 5.5 6dd82d9d30 Merge remote-tracking branch 'origin/main' into parity/data-pack-dialogs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:56:10 +01:00
oskal675-designandCodex GPT-6.1 db171db98f Preserve LAN authentication fallback during server login
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-10-31 16:44:39 +05:00
masa-masa-0314andGLM 5.3 5876fa5ff8 Localization: the merchant screen follows the language, and Horizon's own strings come in Chinese
Three fixes for text the language screen could not translate:

- The merchant screen drew its title, villager profession, trade level
  ("Novice".."Master"), the Trades caption, the inventory caption and the
  out-of-stock hint through `Enchantments::translate` — a pinned en_us map
  loaded once at startup — so they stayed English in every other language.
  They go through `lang::get_or` now, like the rest of the menus.
- The reconfiguring screen's button used the key `gui.disconnect`, which
  no language file has (vanilla's button is `CommonComponents.GUI_DISCONNECT`
  = `menu.disconnect`), so the raw key showed on screen. It is
  `menu.disconnect` now.
- Horizon's own strings had no zh_cn bundle: the settings, graphics, HDR,
  controller, extras, shader packs, mods and AI agent pages, and the
  multiplayer invite dialog, were English for a Chinese player. A complete
  zh_cn bundle ships now (the five other bundles already do; the language
  is picked up by `Language::load`'s source scan like the rest).

Checked the whole game for the first two classes with a key audit: every
key a `lang` call passes now resolves in vanilla's en_us or Horizon's
bundle, vanilla-namespace keys all have zh_cn values, and no production
text draw keeps a hardcoded English literal. `test_instance_block.invalid_test`
looks like a missing key but is vanilla's own (`TestInstanceBlockEntity`
translates it without the key existing in any language file), so it stays.
Horizon-only command feedback (/raid, /profiler, the agent's messages)
still speaks literal English by house style; localizing those would need
new keys across all six bundles and is left alone here.

Co-Authored-By: GLM 5.3 (ZCode) <[email protected]>
2026-10-31 19:39:53 +08:00
Ludovic MarieandClaude Opus 5.5 42f51485f4 lan_e2e: reopen_player, the owner keeps position and items over Save and Quit
The integrated server's owner is saved in the world's own player file;
#4866 stopped writing it on the server thread and nothing caught it
(#5065, #5075). The check moves both players onto glass blocks with
items, saves and quits, reopens and compares (the guest's own file too).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:39:30 +01:00
ElyTheCatandCodex d692215361 Fix LAN packet phase lifecycle
Co-Authored-By: Codex <[email protected]>
2026-10-31 15:08:46 +03:30
apeirsandClaude Opus 5.5 184a48b55b Recipe book: Space and Enter place the last clicked recipe again
RecipeBookComponent.mouseClicked keeps the recipe a click placed as
lastRecipe (slot clicks keep it, unlike lastPlacedRecipe), and
RecipeBookComponent.keyPressed places it again on Enter, Space or numpad
Enter (isSelection), key repeats included, with the key's Shift. Holding
Space and shift-clicking the result crafts an inventory quickly. Horizon
had neither the field nor the key path. horizon-ctl's key command takes the
same path, as for the merchant's offers.

lan_e2e: recipe-reselect crafts wool three times from one book click (Shift
held Space, then Enter), on the host and on a guest.

Fixes #5099

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:32:06 +01:00
hauserdew-uiandBig Pickle da185c6128 Audio: list output devices only when the ALSA cards change
cpal 0.16 lists ALSA devices by opening a hardcoded set of names
("default", "pipewire", "pulse", "jack", "oss") for playback and
capture. Every missing plugin prints two ALSA errors, so the per-second
device check in Audio::should_change_device printed six "Unknown PCM"
lines and spent 5-9 ms (spiking to ~70 ms) enumerating a list that only
changes when the ALSA cards do.

Read /proc/asound/cards (a few microseconds) and enumerate only when it
changes; without a fingerprint, keep checking as before. Open the
default output first and enumerate only when it cannot open.

Co-Authored-By: Big Pickle (opencode) <[email protected]>
2026-10-31 13:28:05 +02:00
Kacpherek 80106eb50d Light a vex, allay and wither at 15 block light
Only Blaze and Magma Cube (and burning mobs) were lit fully, and with the sky
light forced to 15 as well. AllayRenderer, VexRenderer and WitherBossRenderer
override getBlockLightLevel to 15 like BlazeRenderer, and EntityRenderer's
default gives a burning entity 15; getPackedLightCoords keeps the sky light
the level's. These mobs were dark in caves.
2026-10-31 12:26:37 +01:00
Kacpherek 7c0c1f6be3 Read, save and send a phantom's size
A phantom's size was never read: /summon phantom {size:N} and a server's
phantoms stayed size 0, so they were never drawn larger (PhantomRenderer.scale
uses 1 + 0.15 * size). Phantom.setPhantomSize (Phantom.java) stores 0 to 64 in
ID_SIZE and updatePhantomSizeInfo sets the attack damage base to 6 + size and
refreshes the box, scaled by 1 + 0.15 * size in getDefaultDimensions. The size
is now loaded and saved as "size", sent to LAN guests as data 16 and read from
a server's.
2026-10-31 12:26:37 +01:00
Kacpherek 66e3585fa8 Draw the special model of a block display's chest, head or banner
A block display of a chest, banner, skull, shulker box, bell, conduit,
decorated pot, enchanting table or the end portal and gateway showed only the
empty block model. BuiltInBlockModels gives these blocks a special model, and
CompositeBlockModel.update adds it to the block model's quads. The display now
draws it through the block-entity renderer, as a chest minecart does its
chest.
2026-10-31 12:26:37 +01:00
Kacpherek 361c9f9fff Draw the item a dolphin carries at its snout
A dolphin holding an item showed nothing: its model has no arm bones for
ItemInHandLayer's hand lookup. DolphinCarryingItemLayer.submit
(DolphinCarryingItemLayer.java) draws the main hand's item in the GROUND
context at (0, 1, -1), then moves it with |pitch| / 60: y by -0.5 and z by
+0.5 for a negative pitch, y by +0.8 and z by +0.2 otherwise.
2026-10-31 12:26:37 +01:00
Kacpherek 0877a0b76a Show break cracks on a conduit and an enchanting table's book
Mining a conduit or an enchanting table drew no crack stage on their
block-entity models. ConduitRenderer.submit and EnchantTableRenderer.submit
call submitCrumblingOverlay after the shell (inactive) or cage (active) and
after the book; the wind and eye are not overlaid. render_breaking now
covers both.
2026-10-31 12:26:37 +01:00
Kacpherek 37d6e42013 Hold a copper golem's item as CopperGolemModel does
A copper golem's item sat at the humanoid hand offset. CopperGolemModel.translateToHand
(CopperGolemModel.java) turns the item 90 degrees about the arm and moves it
0.125 forward while the golem is idle, and otherwise scales it to 0.55 and
moves it (-0.125, 0.3125, -0.1875). Both run before ItemInHandLayer's own
offset.
2026-10-31 12:26:37 +01:00
Kacpherek 5896db7b9c Jitter an angry enderman sideways each frame
An enderman with a target (isCreepy) stood still. EndermanRenderer.getRenderOffset
(EndermanRenderer.java) adds nextGaussian() * 0.02 * scale to the render
position's x and z every frame. The offset is added to the entity's render
origin, seeded per mob and frame so the body and carried block agree.
2026-10-31 12:26:37 +01:00
Kacpherek 770dd407d5 Describe the grown frog's finalization without a vanilla citation
Word the finalize doc without claiming a port.
2026-10-31 12:26:28 +01:00
Kacpherek 13b200f8c8 Wear down the helmet of an undead mob burning in daylight
A zombie or skeleton in sunlight with a helmet was simply spared, so the
helmet never lost durability and never broke. Mob.burnUndead hurts the
item in sunProtectionSlot (head; body for a zombie horse or nautilus) by
random.nextInt(2) when it is damageable and only ignites the mob when the
slot is empty. A broken item leaves the slot and
LivingEntity.onEquippedItemBroken broadcasts the break event (49 head,
65 body) for the sound and crumbs.
2026-10-31 12:26:28 +01:00
Kacpherek ecdcb91c27 Keep a tadpole's name and flags when it grows into a frog
A grown tadpole was replaced by a fresh frog that kept only yaw, so a
name tag, silence and the like were lost. Tadpole.ageUp calls
Mob.convertTo with ConversionParams.single(this, false, false), which
copies those through ConversionType.convertCommon, then finalizes the
frog for the conversion and makes it persistent.
2026-10-31 12:26:28 +01:00
Kacpherek 47da9560bc Convert piglins and hoglins in place instead of respawning
A piglin, piglin brute or hoglin that zombified outside the Nether was
replaced by a fresh mob that kept only baby, yaw and persistence, so the
name tag, armour, held items, effects and flags were lost. Vanilla runs
Mob.convertTo with ConversionParams.single (piglins keep equipment and the
pick-up-loot flag, hoglins keep equipment only) and then adds nausea for
10 seconds (AbstractPiglin.finishConversion, Hoglin.finishConversion).

ConversionType.convertCommon calls setBaby(true) on the new mob and only
copies the exact age between two AgeableMobs, so a baby hoglin becomes a
non-ageable baby zoglin instead of keeping its growing age.
2026-10-31 12:26:28 +01:00
Kacpherek c225acc128 Run the dragon phases' client ticks on a client
EnderDragon.aiStep calls currentPhase.doClientTick() on a client and
Horizon never did. DragonLandingPhase spawns 8 dragon-breath puffs from the
head each tick, DragonSittingFlamingPhase 8 x 6 on every second of the first
10 ticks, DragonDeathPhase an explosion emitter every 10 ticks, and
DragonSittingAttackingPhase replays the growl on every tick instead of once.
Vanilla throws away the results of look.yRot, so the puffs keep one direction.
Run them from tick_remote_dragon and drop the one growl the server played.
2026-10-31 12:26:28 +01:00
Kacpherek a5280537ef Draw a breeze's block dust and jump trail on a client
Breeze.tick (Breeze.java) calls emitGroundParticles while it stands, shoots
or inhales, 20 of them while it slides, and emitJumpTrailParticles for the
first five ticks of a long jump. tick_breeze_effects drew them, but only
tick_creatures called it, so a client of a server (single player included)
saw none. Call it from the remote entity tick too.
2026-10-31 12:26:28 +01:00
Kacpherek d20172681d Show a witch's magic puffs on the client
The server drew Witch.aiStep's 7.5E-4 roll but never broadcast entity
event 15, and nothing handled it, so no witch ever puffed. Witch.handleEntityEvent
(Witch.java) spawns nextInt(35) + 10 witch particles around 0.5 above its
box, redrawing the count on every loop pass, with gaussian offsets of 0.13.
2026-10-31 12:26:27 +01:00
Kacpherek 15e9f8caeb Play the zoglin's angry grunt when it starts a fight
A zoglin only grunted angrily on its ambient timer. Zoglin.updateActivity
(Zoglin.java) plays entity.zoglin.angry as soon as the brain switches from
idle to the fight activity.
2026-10-31 12:26:27 +01:00
Kacpherek aee4f219e4 Kick up dust under a walking iron golem
Only players ever drew sprint dust. Vanilla IronGolem.canSpawnSprintParticle
(IronGolem.java) replaces the sprinting test: when its horizontal motion
exceeds 2.5000003E-7 and a one in five roll hits, Entity.spawnSprintParticle
streams the block under its feet back from it, scaled to its 1.4 width.
2026-10-31 12:26:27 +01:00
Kacpherek 3f0aa12580 Pass the rabbit's target position into its server step
The killer bunny hop needs only where its target stands, so server_ai_step takes that instead of the whole MobWorld, and the existing tests keep calling it without a world.
2026-10-31 12:26:27 +01:00
Kacpherek 575f48fe5f Make killer bunnies armoured, hostile and named
Variant 99 (from /summon or a save) only changed the texture. Vanilla
Rabbit.setVariant(EVIL) (Rabbit.java) sets armor to 8, adds the bite goal
(MeleeAttackGoal 1.4), the hurt-by and player/wolf target goals, a plus 5
attack modifier and the Killer Bunny name, and customServerAiStep makes it
face and hop at a target within 4 blocks.
2026-10-31 12:26:27 +01:00
Kacpherek 9e11a4d7c8 Show the pattern and colours on a tropical fish bucket tooltip
A bucket of tropical fish had no pattern or colour lines. Vanilla
TropicalFish.Pattern.addToTooltip (TropicalFish.java) names one of the 22
common variants, or shows the pattern name and then the base colour (with
the pattern colour when it differs), in italic gray, just before the
instrument line.
2026-10-31 12:26:27 +01:00
Kacpherek 562cbac5fa Hang a camel's lead at its body anchor
Every camel drew its lead at the default offset (eye height, 0.4 of the
width ahead). Vanilla Camel.getLeashOffset (Camel.java) uses the front
body anchor, which sinks while the camel sits, less 0.2 times the age
scale, and 0.56 of the width ahead.
2026-10-31 12:26:27 +01:00
Kacpherek d467786e5b Drop goat horns with their own instrument and upward push
A horn knocked off by a ramming goat was a plain goat horn (always the
ponder tune) thrown with the generic item motion. Vanilla Goat.createHorn
(Goat.java) picks a random instrument from #regular_goat_horns, or
#screaming_goat_horns for a screaming goat, with a random seeded from the
goat's UUID hash, and Goat.dropHorn throws it with deltaX/Z in -0.2..0.2
and deltaY in 0.3..0.7 drawn from the goat's random.
2026-10-31 12:26:27 +01:00
Kacpherek 1d69ba5efa Make tadpoles swim at half speed and follow frog food
Tadpoles swam around at speed 1.0 and ignored players holding food.
Vanilla TadpoleAi.initIdleActivity (TadpoleAi.java) strolls with
RandomStroll.swim(0.5F) and runs FollowTemptation at 1.25 for items in
the frog_food tag.
2026-10-31 12:26:27 +01:00
oskal675-designandCodex GPT-6.1 90d02c5fff Use the SRV target address in login handshakes
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-10-31 16:16:36 +05:00
MSedgeMC 854c85b235 Let a mob pick up and equip what it finds, as vanilla's looting pass does
Mob.aiStep walks the items within one block of a mob with CanPickUpLoot and
mob_griefing on, and pickUpItem equips what beats what it holds: armour by
armour and toughness, a weapon by attack damage, an equal item by
enchantments, damage and name, with the preferred-weapon tags skeleton and
drowned mobs carry. The replaced piece is dropped unless the drop chance
saves it, and the mob becomes persistent.

Horizon rolled the flag at spawn and nothing read it: a zombie stood beside
a diamond sword and left it there. The comparison rules live in a lib
module so they can be tested without a game, and the pass reuses the panda
and piglin pickup plumbing rather than adding a third copy.
2026-10-31 16:27:20 +05:30
AurelienBelloirandClaude Opus 5.5 c5bf7a35cd Key the reloaded sync data by the pack dialogs it keeps
After a /reload that disabled a world's packs, the cached SyncData was
marked as read without packs while still holding the kept registries with
the packs' dialogs, so the next world opened without packs would have been
served them. The cache key now also counts kept pack entries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:52:49 +01:00
DetimesandClaude Sonnet 5.5 5b25371912 horizon-server: Velocity modern forwarding behind a proxy (#4963)
A forwarding.secret file in the server folder turns it on for an offline
server: the login asks the proxy on velocity:player_info, checks the
HMAC-SHA256 signature and joins the player with the forwarded UUID, name,
properties and address. A login that doesn't answer, or is signed with
another secret, is refused; an empty secret file stops the start.

The format was worked out by recording a real Velocity 4.3.1 with a
stand-in backend (tools/velocity_forwarding_probe.py), see
docs/velocity-forwarding.md.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 10:52:37 +00:00
AurelienBelloirandClaude Opus 5.5 456bbccd1d Keep the synchronized registries on /reload
Vanilla's MinecraftServer.reloadResources builds the new
ReloadableServerResources against the server's existing
LayeredRegistryAccess (TagLoader.loadTagsForExistingRegistries); the
synchronized registries RegistryDataLoader read at server start never
reload. Players keep the registries of their configuration phase, and
later joiners get the same ones.

Horizon's /reload read the SyncData again with the current packs, so a
dialog file added or removed shifted the dialog indices (vanilla's too,
as pack ids sort among them): /dialog show sent the wrong index, the
re-broadcast dialog tags pointed at wrong entries and new joiners got
another registry.

Now /reload under a hub reloads the SyncData (recipes, advancements,
command tree) but keeps the hub's synchronized registries and their
names, and reads the tags against them (SyncData::keep_registries,
lan::reload_sync_data); the commands' dialog ids are not touched. A
pack's new dialog shows after a restart, as in vanilla. Without a hub,
/reload forgets the cached data as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:48:17 +01:00
MSedgeMC 2d519f8808 Land a spectator who becomes creative while standing on the floor
changeGameModeForPlayer clears flight on top of the mode abilities when the
new mode is not spectator and the player is in range of the ground:
no collider joins the body, and the block below the feet is within one
block. Horizon copied GameType.updatePlayerAbilities in both the local and
the guest funnel and stopped there, so a spectator switched to creative on
the floor kept hovering.

One probe, in the world crate where the collision scan lives, used by both
funnels.
2026-10-31 16:13:40 +05:30
MSedgeMC a6d66cd98e Let a spawn egg carry entity data, as vanilla's does
EntityType.updateCustomEntityTag loads the egg's entity_data component into
the mob it just spawned, after the components, guarded by the type matching
and by the op rule for the three op-only types. Horizon applied the egg's
components and not this one, so a NoAI or Silent egg spawned an ordinary
mob.

The loader is the one /data merge already uses, and the op-only set is now
shared with the tooltip warning that already listed it.
2026-10-31 16:10:52 +05:30
AurelienBelloirandClaude Opus 5.5 cf2c6dd7cb Order the dialog registry by file id and drop a pack world's sync data
ResourceManagerRegistryLoadTask.load registers the entries sorted by the
keys of FileToIdConverter.listMatchingResources, the file Identifiers
(dialog/<path>.json), path first, then namespace (Identifier.compareTo).
The ".json" counts: x-y comes before x since '-' sorts before '.'. Every
synchronized registry's entries, the game's own included, now sort by
"<path>.json", then namespace, instead of the bare id.

The cached SyncData remembers whether it was read with data packs. A world
without packs opened after one with packs read it again instead of keeping
the first world's dialogs, recipes and advancements; a world without packs
after another, or after the world-screen preload, still reuses it, and
SyncPreload.adopt replaces only what was read with packs.

The commands' dialog ids are set from the SyncData lan_host and /reload
send, not in finish_open_world: there it loaded SyncData on the server
thread before SyncPreload.adopt, wasting the preload.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:38:08 +01:00
AurelienBelloirandClaude Opus 5.5 7553d7356c Keep a failed use on a block from firing any_block_use
DebugStickItem.useOn returns FAIL when handleInteraction does not
succeed (no game-master permission, a block without properties), and
handleUseItemOn fires ANY_BLOCK_USE only when the result consumes the
action. use_hand now sets use_failed on the stick's failure, and on a
click a native mod canceled, which uses nothing.

ChiseledBookShelfBlock.useWithoutItem returns CONSUME on an empty slot
without a book, so ServerPlayerGameMode.useItemOn fires DEFAULT_BLOCK_USE
there too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:36:44 +01:00
ElyTheCatandCodex 9dba54f8fa Keep clamped occlusion sky conservative
Co-Authored-By: Codex <[email protected]>
2026-10-31 14:06:17 +03:30
MSedgeMC fe0c9d5588 Let the spectators_generate_chunks rule decide who loads terrain
ChunkMap.skipPlayer is a spectator with the rule off, and a skipped player
holds no distance-manager ticket: the world does not load or generate
around them, though they still receive the chunks other players load.
Horizon declared and saved the rule and never read it, so a spectator was a
chunk source like any other player in all three places that matter.

One helper now answers it, and the ticket sources, the LAN streaming areas
and the dedicated server's centre all ask it.
2026-10-31 16:06:09 +05:30
AurelienBelloirandClaude Opus 5.5 6dba457703 Merge remote-tracking branch 'origin/main' into parity/default-and-any-block-use
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:34:18 +01:00
AurelienBelloirandClaude Opus 5.5 32a35d1225 Merge remote-tracking branch 'origin/main' into parity/data-pack-dialogs
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/lan/data.rs

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:33:16 +01:00
k3yhandClaude Opus 5.5 a921e7b79e Load a region chunk whatever follows its root (#5064)
RegionFileStorage.read parses the root straight off the decompressing
stream (NbtIo.read on the chunk's DataInputStream) and closes it, so
bytes after the root and a damaged stream past it are never read.
Horizon decompressed the whole record first and then refused trailing
bytes, so such a chunk failed to load, read_record took it as missing,
and the game generated it again and saved over what had been built.

ChunkRecord::nbt now parses the root from whatever decompressed before
an error (Compression::decode_into keeps it; an LZ4 block is kept only
once checked, as lz4-java hands it out) and ignores what follows. A root
cut short still fails with the decompression error.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:30:16 -05:00
DoodlyyousufandGrok 4.6 714e6e52bd Center widget labels with vanilla integer half-widths
AbstractWidget.renderScrollingString / TextAlignment.CENTER uses
x + w/2 - width/2. Flooring (w - width) / 2 sat odd-width labels one
pixel left of vanilla on even-width buttons.

Co-Authored-By: Grok 4.6 <[email protected]>
2026-10-31 10:12:56 +00:00
MSedgeMC 0d476d32e7 Keep a template's carried block when there is no registry
mob_to_nbt dropped an enderman's carriedBlockState whenever the mob carried
none, but a caller that passes no registry never read the key either, so a
structure template's compound lost a field it had. Reported in review: the
key is only removed when a registry could have written it.
2026-10-31 15:41:59 +05:30
MSedgeMC 830820b2cf Write the em dash back as an em dash
A comment in mobs/allay.rs was saved with a doubled encoding, so the dash
in the travelFlying note read as mojibake. Reported in review.
2026-10-31 15:41:53 +05:30
reservedbytes c48886001b fix(mobs): a primed sulfur cube is still knocked about 2026-10-31 09:08:15 -01:00
MSedgeMC ea306f29eb Give a tamed pet its owner's scoreboard team 2026-10-31 15:28:44 +05:30
MSedgeMC 7b9a18331d End a wind charge's fall context when the player lands in water 2026-10-31 15:28:38 +05:30
reservedbytes 135c4a8872 fix(mods): property setters return false when nothing changed 2026-10-31 08:49:12 -01:00
reservedbytes 67188b668c fix(worldgen): a template with entities and no blocks places them 2026-10-31 08:45:10 -01:00
reservedbytes 9b63c4c735 fix(combat): a player hurt by a creeper's blast takes a player_explosion 2026-10-31 08:45:07 -01:00
MSedgeMC ef638cf256 Start a restored nausea at full strength, as vanilla does
ClientPacketListener.handleUpdateMobEffect calls skipBlending when the packet
carries no blend flag, and BlendState.setImmediate then sets the factor from
hasEffect rather than fading up. That is the path the effect list takes on
joining, respawning and changing dimension, so vanilla shows the warp at
once. Horizon had this for darkness and not for nausea, whose warp ramped
from zero over a hundred and fifty ticks.

One branch beside the darkness one, in the same place, with the blend-out
guard now shared.
2026-10-31 15:10:49 +05:30
mularealandAntigravity 24831525c0 entities: push armor stands with flowing water and lava
Co-Authored-By: Antigravity <[email protected]>
2026-10-31 10:24:21 +01:00
apeirsandClaude Opus 5.5 83189a8ae7 Generated columns: read a whole section in one pass
VanillaTerrain::column read every block of a generated section with
ProtoChunk::block (a section lookup, coordinates and a palette read per
block, ~98k calls a chunk) before repacking it. Light views for the ring
past the view distance are built on the server thread, so flying kept a
steady stream of them there. A whole generated section is now unpacked
word by word and mapped through its palette (TerrainChunk::section_states,
BlockSection::fill_states, PackedStorage::unpack_into); a partly generated
one still goes block by block. Same values, same packing.

Fixes #5038

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 10:19:19 +01:00
apeirs 9cccb97de1 TNT minecart: a primed cart's blast spares rails and the blocks under them
MinecartTNT.getBlockExplosionResistance and shouldBlockExplode: while the
cart is primed (fuse > -1), a rail or a block with a rail above has no
resistance to its blast and is never blown up. Horizon broke both, so a
cart primed on an activator rail tore up its own track and the block
holding it. An unprimed cart (a wall hit, a fall, fire) blasts rails
like any explosion, as before.

Harness: command_exec_corpus `tnt minecart rails`.

Fixes #4992
2026-10-31 09:57:57 +01:00
Neferr e085a4edf5 Native mods: say when the player tick hook runs and when it doesn't
Players still tick under /tick freeze, and a dead player, or one killed
earlier in the same tick, isn't called. Notes from reservedbytes on #4665.
2026-10-31 09:56:29 +01:00
Aaron Wang 3d9e3dbe6e Keep the chunk_io tests off the queue the save tests also fill
chunk_io's PENDING is process-wide, and save tests that fail a chunk load
or write report into it while the chunk_io tests take and compare it, so
those tests failed now and then (#4854). The newest-chunk rules move into
Pending::load_failed and save_failed, which the tests call on a local
value; warn_on_low_disk_space returns what it found. Only the
low-disk-space packet test still reads PENDING, and it checks a flag
nothing else in the lib clears.

Fixes #4854

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 04:42:37 -04:00
Aaron Wang 05a059bde4 Blindness stops a sprint and keeps one from starting
LocalPlayer.isSprintingPossible is false while Player.isMobilityRestricted
(blindness). sprint::Conditions already honoured mobility_restricted, but
nothing set it. PlayerMotion now carries the flag into the sprint phase,
and sync_player_body sets it from the effects every tick, on the server
thread's client as well.

agent_control_e2e's consume scenario now holds the sprint key while the
server gives and clears blindness.

Fixes #4831

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 04:30:51 -04:00
VaspyyyandClaude Opus 5.5 9194f37386 Shader API: rebuild Horizon's own pack after a resource reload
The reload replaces the atlas and the renderer, so the pack's sprite tags
named stale layers and the pack stopped drawing until its files changed.
Suggested by rdbtCVS's review on #4186.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QS9x9HatX1QRXMqxeb
2026-10-31 08:15:30 +00:00
Sticky-BugandAntigravity 06f4fe173c feat: place flowers and plants in flower pots (#4844)
Right-clicking an empty flower pot with a flower, sapling, mushroom, or other potted plant item now places it in the pot matching vanilla FlowerPotBlock.useItemOn behavior:
- Construct potted_by_content map on Behaviours caching all 39 potted blocks
- Support empty pot plant insertion returning InteractionResult::Success
- Consume 1 held plant item, increment pot_flower stat, and award item_used_on_block
- Clicking a potted pot with a plant returns InteractionResult::Consume
- Add unit test coverage for potting flowers, saplings, and mushrooms
- Update port map entries in game2-013.tsv

Fixes #4844

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 13:36:41 +05:30
huiandClaude Opus 5.5 d4b98b96b0 Native mods: on_equip for /item's main hand, recipes in subfolders, no sends while reconfiguring
/item weapon.mainhand now goes through onEquipItem, as vanilla's setItemSlot
does; hotbar.N stays the inventory's. A content mod's recipes in a subfolder
of its recipe folder load, as a data pack's do. native_net_send_to_server
refuses while the server reconfigures the player. The hook's doc says what
scatters at death isn't item_dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 08:59:26 +01:00
apeirs 5943233ee5 Elytra: glide with Mth's sine table and float drag, as vanilla does
updateFallFlyingMovement takes the look direction from getLookAngle
(calculateViewVector: Mth.sin/Mth.cos, 0.017453292f), the pull-up's
-sin(lean) from Mth.sin, the lift from Math.cos of the widened lean, and
drags by multiply(0.99F, 0.98F, 0.99F). Horizon used exact f32 sin/cos,
f32::to_radians and 0.99/0.98, so a long glide landed up to ~0.11
blocks from vanilla's spot.

Fixes #4618
2026-10-31 08:51:11 +01:00
TiggerTakkyTTT 49abcffb11 fix(client): stop particles during tick freeze 2026-10-31 07:50:33 +00:00
Dufru 34f24116bf Fix Brazilian Portuguese locale and add missing translations 2026-10-31 04:39:57 -03:00
Kacpherek d4326f77c8 Tick experience orbs on the client between server updates
A server's experience orb was only eased towards each position update and
did not move on its own in between, so orbs jumped every update (#4488).

Entity keeps a NO_OP interpolation handler for an orb, so the update puts it
there at once, and ExperienceOrb.tick runs on a client too: gravity,
buoyancy, collisions, friction and followNearbyPlayer towards the nearest
living player within 8 blocks (ExperienceOrb.java). The orb now takes the
update's position, ground flag and motion, and runs the existing
ExperienceOrb::tick each tick. Merging stays on the server.
2026-10-31 08:28:56 +01:00
Kacpherek 45c36d8721 Keep a boat passenger's eyes dry in single player
The local player skipped the fluid update while riding, so its eye flags
froze at whatever they were on boarding and the air bar stayed up inside a
floating boat.

Entity.getFluidInteractionBox asks the vehicle:
AbstractBoat.modifyPassengerFluidInteractionBox gives no box when the boat
is not underwater and reaches the passenger's head, and otherwise only the
part above the boat's top. The rider's tick now samples fluids in that box
(Entity.baseTick) through the existing interaction update.
2026-10-31 08:28:03 +01:00
Kacpherek f671f9a758 Tie a lead to a fence for players of a server
A client of a server (single player included) clicks the fence block, never
the knot, so the guest's use only reached the block and item turns. The
port of LeadItem.useOn and LeashFenceKnotEntity.interact, Game::interact_knot,
ran only from the host's own interact_entity, so no lead could be tied to a
fence, taken back off a knot or snipped with shears on the server thread.

Vanilla: LeadItem.useOn binds the player's leashed mobs to the clicked fence
(LeadItem.bindPlayerMobs), and LeashFenceKnotEntity.interact handles a click
on an existing knot (LeashFenceKnotEntity.java). A guest's block use now
offers the click to interact_knot first, as the host's frame does.
2026-10-31 08:27:54 +01:00
Kacpherek 12a0e29610 Tell a LAN guest when its respawn block is missing
A guest respawning at a bed or anchor that was gone had the
"block.minecraft.spawn.not_valid" message put into the host's chat, and
never saw it itself. PlayerList.respawn sends the player
ClientboundGameEventPacket.NO_RESPAWN_BLOCK_AVAILABLE, and
ClientPacketListener.handleGameEvent turns it into that system message.

Send the event from both guest respawn paths when the point cannot be
resolved, and show the message when the remote client receives it.
2026-10-31 08:27:39 +01:00
Kacpherek fbf38dfc89 Update the health, food, armor and xp objectives of LAN guests
Game::tick_scoreboard recorded these criteria only for the in-frame
player, so a guest's (and a single-player owner's, who is a placed guest)
health, food, armor, experience and level scores never moved: a
`health` objective shown below the name or in the tab list stayed at 0.

ServerPlayer.doTick calls updateScoreForCriteria for every player when
the value differs from lastRecorded*. Move that part into
record_player_scores, used by tick_scoreboard and by each guest's tick
with a per-guest record in GuestPlay (reset with a new body).
2026-10-31 08:27:29 +01:00
Kacpherek 932cecc24e Use nothing on a server while the hands paddle a boat
Minecraft.startUseItem returns when LocalPlayer.isHandsBusy (rowing with
the movement keys held). The in-frame path checks it in use_with_hand;
remote_use did not, so on a server a right click while rowing still ate,
placed or threw. remote_use now returns the same way.
2026-10-31 08:27:18 +01:00
Kacpherek a72ce25b23 Predict the armour swap of a right click on a server client
Item.use runs Equippable.swapWithEquipmentSlot on the client too
(MultiPlayerGameMode.useItem), so the worn piece and the hand change at
once and the server's slot packets only confirm. remote_use_item sent the
packet and waited, so on a slow server the swap showed a ping late.

It now calls equip_from_hand like the in-frame path. A client spawns no
dropped item (the server does) and takes its attribute modifiers from the
server's update packet, so equip_from_hand skips both when remote.
2026-10-31 08:27:18 +01:00
My Name b8b6196026 Melee damage reads the attack_damage attribute 2026-10-31 10:14:17 +03:00
levzzzandClaude Opus 5.5 d6452eb44b Merge hr/main: hook points API 41 for data files; horizon-mixins 4342a0b
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:06:12 +05:00
Muhammad Asif 8dadd9e02a Give zombies and skeletons pumpkin heads on Halloween
Zombie.finalizeSpawn and AbstractSkeleton.finalizeSpawn put a carved pumpkin (or jack o'lantern) on an empty head 25% of the time on 31 October. Structure spawn already did this; natural, spawner, summon and raid paths never ran it.
2026-10-31 06:55:05 +00:00
abit-foggy 6ec32c5ec7 perf(client): index fresh chunks in ChunkMap instead of scanning all columns 2026-10-30 20:51:24 -10:00
abit-foggy a368ded7f5 perf(client): restrict tick_blocks candidate lookup to random-ticking bounds 2026-10-30 20:50:32 -10:00
abit-foggy 4dca2732a3 perf(client): avoid O(N) SipHash scan in TickChunkMembership::synchronize 2026-10-30 20:50:32 -10:00
repositorykreml1nandClaude Opus 5.5 aae2414706 Build the test's camera with struct update syntax
clippy::field_reassign_with_default denies the assignment after default().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 09:38:15 +03:00
ElyTheCatandCodex c3246b78e4 Make sprint knockback LAN coverage tick-tight
Batch movement, teleport and attack commands so the check proves that a hit occurred instead of overshooting the target between control calls. Assert the first post-hit sample is below sprint speed.

Co-Authored-By: Codex <[email protected]>
2026-10-31 10:05:43 +03:30
rdbtCVSandClaude Opus 5.5 368eea9f74 Pack upgrade: refuse entries by their stored name
zip 9's enclosed_name turns ../x into x instead of refusing it, so the
escape test main added passed the entry through. Check the stored name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:28:40 +00:00
MSedgeMC 95a4e8effa Keep an iron golem from fighting the creepers it refuses
IronGolem.canAttack answers no for a creeper as well as for a player, and
that answer is asked by the retaliation goal, by target continuation and by
the nearest-target search. Horizon mirrored only the player half, so a
creeper that hit a golem became its target and the golem swung back.

The rule now covers any target and is asked by the retaliation goal and by
target continuation, the two places that had no answer.
2026-10-31 11:58:02 +05:30
repositorykreml1nandClaude Opus 5.5 cbe755778c The hands stop shaking while a ridden boat turns
The hand pass draws with the camera's view, whose rotation spreads a ridden
boat's turn over the frames of a tick (LocalPlayer.getViewYRot). hand_geometry
posed the hands with the end-of-tick yaw instead, so they swung by that turn
20 times a second. HandSway::to_world takes the camera now.

Fixes #4583.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 09:26:49 +03:00
MSedgeMC 1e14c90711 Let the universal_anger rule reach a wolf's target goal
NeutralMob.isAngryAtAllPlayers is the gamerule universal_anger plus a mob
that is still angry with no anger target left, and isAngryAt asks it before
the named target. Horizon registered the rule and stored it, but the wolf's
player target goal only checked the named target, so the rule did nothing.

The wolf's predicate now asks the rule, and the world it reads carries the
gamerule the same way mob_griefing and mob_drops do.
2026-10-31 11:56:28 +05:30
ElyTheCatandCodex 98a1726379 Restore vanilla remote knockback ordering
Use the entity kind for the RemotePlayer client-side attack path so profile bookkeeping cannot suppress the vanilla attacker slowdown. Restore the client-side mob warning and leave sprint synchronization to the normal end-of-tick movement send.

Co-Authored-By: Codex <[email protected]>
2026-10-31 09:48:54 +03:30
rdbtCVSandClaude Opus 5.5 0510ebdfbf rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:12:57 +00:00
rdbtCVS eeede72cf8 Merge remote-tracking branch 'origin/main' into fix/update-zip-security 2026-10-31 06:12:20 +00:00
rdbtCVSandClaude Opus 5.5 0faccb5be8 Pack overlay test: break the zip where zip 9 still refuses to open it
zip 2 refused an archive whose entry claims method 99 without an AES
field; zip 9 opens it and only the entry fails, which mirror now skips.
A name that runs past the archive still fails the open, and the build.
Also rustfmt for upgrade/mod.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:12:19 +00:00
rdbtCVSandClaude Opus 5.5 d114abe94b Launcher pt-BR: the strings added since the table was written, rustfmt
Fills in the 135 strings the table lacked (Mods browser, native mods from
GitHub, AI agents, data collection), drops the unused "Sign in", names pt-br
in the language setting's hint, and formats parse().

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:11:40 +00:00
rdbtCVS f88dcf6379 Merge remote-tracking branch 'origin/main' into pr-3468 2026-10-31 06:04:29 +00:00
rdbtCVSandClaude Opus 5.5 976de52032 Merge main; give the world-wide plan test real assertions
world_wide_plans_stay_small_and_cover_every_level only printed node counts.
It now checks that the roots reach MAX_LEVEL, that each plan stays under
16 384 nodes (10 655 at most today, at a corner of the world), that reduced
has no more nodes than full, and that every level from the finest one to
the roots has tiles. The mesh test's corner check bound as (A && B) || C;
it now needs the far corner. Main's mesher materials take a position: the
heightmap's fluid test passes the tile origin, as fill_buried_gaps does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:04:23 +00:00
ElyTheCatandAntigravity ede6a087d0 Renderer: clamp vertical occlusion search grid height to non-air sections
- Dynamically clamp the occlusion graph search box height to the highest non-air section across columns in the search radius, skipping empty air section exploration.
- Fall back to column reachability for open air queries above the clamped search box.
- Add unit test vertical_clamping_bounds_height_and_reaches_open_sky.

Fixes #4985

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 09:24:48 +03:30
rdbtCVSandClaude Opus 5.5 18a3169447 Format the respawn immunity test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:39:55 +00:00
MSedgeMC e35e3e3ca0 Keep the touch pickup a NoAI allay still has
Mob.aiStep's looting loop runs for every server mob, AI or not, and an
allay's pickup gates (canPickUpLoot, the held item, mob griefing, the
inventory room) never ask for AI. So a NoAI allay still takes a matching
drop that lands in its getPickupReach, which Allay overrides to (1, 1, 1)
— it only never walks to one (the sensor is the brain's) and never throws
one (the delivery goal is too).

The blanket skip becomes that split: the reach is one helper, the search
uses it when AI is off, and delivery stays behind the AI gate.
2026-10-31 11:09:47 +05:30
Muhammad Asif b70cb22e9d Swap F in a chest with the off hand
AbstractContainerMenu SWAP button 40 is the off hand even when the
open menu has no off-hand slot. Horizon only mapped 40 on the
inventory menu, so F in a chest predicted a move the server ignored
and left a ghost item.

Fixes #4887
2026-10-31 05:35:25 +00:00
rdbtCVSandClaude Opus 5.5 fae347ca67 Merge main into fix/mod-registry-kind-and-retry
Keeps both sides: main's owner updates, the "listed by its repository"
refusal and the label route next to the native turn and the retry. A mod
turning native is never an owner update (its kind changes, which the
owner-update workflow doesn't review), and the test checks for it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:29:28 +00:00
rdbtCVS 28b8be4410 Merge remote-tracking branch 'origin/main' into pr-4834 2026-10-31 05:28:33 +00:00
rdbtCVS 17e6f6e8fa Merge remote-tracking branch 'origin/main' into fix/update-zip-security 2026-10-31 05:15:35 +00:00
rdbtCVSandClaude Opus 5.5 a0f42c81e0 One rule for zip entry names that can't be decoded
A reader that returns a Result fails with an error; the best-effort port-pack scans, which already skip entries they can't read, skip it. Also ports the readers main added since (resourcepacks.rs, upgrade/mod.rs) to the 9.x API.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:15:33 +00:00
MSedgeMC 026d3fa47a Save a pufferfish's puff state and an enderman's carried block
Pufferfish.addAdditionalSaveData writes PuffState and reads it back clamped
to two; Enderman writes carriedBlockState with the block state codec, only
while it carries one, and an air state reads back as no block. Horizon
saved neither, so a puffed fish came back small and an enderman forgot its
block across a reload.

The block state codec moves from the save path into entity_nbt so both
sides share it, and the registry it needs is threaded to the two save
functions.
2026-10-31 10:41:41 +05:30
Dufru 99efd0ccb4 i18n(launcher): add pt-br translation and wire language support 2026-10-31 02:04:38 -03:00
rdbtCVS e41d76620e Merge remote-tracking branch 'origin/main' into pr-4631
# Conflicts:
#	crates/horizon-client/src/text_display.rs
2026-10-31 04:59:39 +00:00
rdbtCVS de6436fd88 Merge remote-tracking branch 'origin/main' into pr-4341
# Conflicts:
#	crates/horizon-client/src/lan/guest.rs
#	crates/horizon-client/src/lan/hub.rs
#	crates/horizon-client/tests/lan_bot.rs
2026-10-31 04:59:11 +00:00
rdbtCVSandClaude Opus 5.5 476cfa77ae Fix cartography test after merging main: Decoration has name and key now
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:58:56 +00:00
rdbtCVS b804e8a290 Merge remote-tracking branch 'origin/main' into fix/update-zip-security 2026-10-31 04:53:40 +00:00
rdbtCVS ed89678b47 Merge remote-tracking branch 'origin/main' into pr4923
# Conflicts:
#	docs/graphics.md
2026-10-31 04:45:24 +00:00
rdbtCVS 070046d358 Merge remote-tracking branch 'origin/main' into pr-3945 2026-10-31 04:44:24 +00:00
rdbtCVSandClaude Opus 5.5 3a88476964 Take MapFrame by value in to_nbt (clippy wrong_self_convention)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:44:24 +00:00
rdbtCVSandClaude Opus 5.5 48beef082a Merge main; drop the melee killer note that #4927 already adds
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:44:14 +00:00
7ee89b0e4c Make the launcher warning prominent in game releases
Game release notes open and close with the warning to get the launcher and not run the files; the closing one sits above the disclaimer. Rebuilt on main's release notes.

Co-Authored-By: Codex (GPT-6.1) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:42:49 +00:00
reservedbytes 45094a9007 fix(combat): firework rockets hurt as fireworks 2026-10-31 03:38:30 -01:00
levzzzandClaude Sonnet 5.5 44b9c67290 Merge #3870 into native-fast-profile: one compile step; the build log keeps what follows a progress redraw
The release switch picks Release; Fast still falls back to release for a
revision without [profile.fast]. The log filter now strips only the progress
pieces of a line, so an error after a redraw reaches build.log.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:33:45 +05:00
levzzz bf27153a24 Merge main into native-fast-profile 2026-10-31 09:31:25 +05:00
socksproxandDevin (SWE-2 High) 0feb6f945e Unscale the default rider attachment before applying model scale
passenger_attachment multiplies the base attachment by the entity scale; the bounding-box fallback now reads height() already scaled after getDimensions learned minecraft:scale, which double-applied it for players and mannequins.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 05:03:41 +01:00
levzzzandClaude Opus 5.5 bcbbdea7a8 Merge launcher/native-builds-stay-put (#3870): its review fixes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 08:59:06 +05:00
socksproxandDevin (SWE-2 High) f6e255ef2b Merge main: keep both the scaled-box and sulfur cube fuse tests
Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 04:48:34 +01:00
socksproxandDevin (SWE-2 High) ed17feee57 fmt: wrap the interaction_range signature
Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 04:41:48 +01:00
ElyTheCatandAntigravity 65e5c14a7a Reset sprint and apply 0.6 attacker speed cut on sprint-knockback hits
When landing a melee attack while sprinting with attack strength scale > 0.9,
vanilla Minecraft 26.3 executes a sprint-knockback hit:
- Plays minecraft:entity.player.attack.knockback
- Multiplies attacker horizontal velocity by 0.6 (ATTACKER_SLOWDOWN)
- Cancels sprint state and syncs ServerboundPlayerCommandPacket(STOP_SPRINTING)
- Excludes spears from sprint-knockback attacks

Fixes #3736

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 07:04:23 +03:30
socksproxandDevin (SWE-2 High) 8b10fe0f2a Launcher: keep each account's secrets in one macOS keychain item
Secrets were split into ~1 KB pieces on every OS for Windows Credential
Manager's 2560-byte item limit. Each piece is its own keychain item and
asks consent for a launcher signature it doesn't know, so macOS players
got a prompt per piece after every ad hoc self-update (#4912). On macOS a
secret is now one item; split pieces an older launcher left fold into it
on first read, and a merged item reads fine as "piece 0" to launchers
that still split. has_owner also checks the selected account first, so
the owner gate touches one item when the player owns the game.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 04:25:41 +01:00
ErikKalikasandClaude Opus 5.5 89f4d8e635 Native mods: send mod block entities to clients
Each content block gets a minecraft:block_entity_type id after vanilla's,
named after the block. The chunk encoder sends a mod block's block entity
with that type and the tag native_block_entity_update_tag gives
(getUpdateTag), and leaves out a mod block that has none; BlockEntityData
on a block change uses the same hook. The client makes its copy through
native_block_entity and merges the tag into its nbt. NATIVE_HOOKS_API 23.

Refs #4615

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:25:15 +02:00
levzzzandClaude Opus 5.5 00ca93e19d Native mods: address review findings, restore the bundle docs
- post_builds: only a missing native-post-build.json means no steps; any other read error, or
  a mod id that isn't one, stops the build (arismoko 3, dimaniojk 1, small point).
- native_data::built and native_built_data return None when the game's path is unknown,
  never a folder relative to the working directory (arismoko 5, dimaniojk 2).
- .hnmod: data is only a bundle's data/ or a crate's top-level data/ (arismoko 4); README*,
  LICENSE*, COPYING*, NOTICE* and .gitignore are accepted in the bundle or a crate folder
  (reservedbytes 3).
- Post-build steps get HORIZON_NATIVE_BUNDLE_DATA_DIR, the bundle's staged data/ (reservedbytes 4).
- "Building horizon-mixins (once)" shows only when it is built; the crate check runs only when
  a mod has a crate; building a post-build step has its own label (reservedbytes 5).
- launcher/Cargo.toml: the libc block above the keychain comment (DUzzL 2).
- docs/native-mods.md: the "A bundle" section (crates, data files, post-build steps), lost in
  an earlier merge, is back, with the .hnmod limits, run-time data and why Horizon's own
  crates are refused for now (#3897; arismoko 1, dimaniojk 3).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 08:21:01 +05:00
levzzz bc310a28c8 Merge remote-tracking branch 'hr/main' into launcher/native-mod-crates 2026-10-31 08:16:50 +05:00
levzzzandClaude Opus 5.5 7cc5e640f9 Merge main into launcher/native-mod-crates: data files are API 23
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 08:14:35 +05:00
k3yhandClaude Opus 5.5 0e076e0559 Send custom_model_data and item_model to the client (#4916)
stack_to_network writes a fixed list of components, and single player's
window gets its stacks through it from the integrated server, as LAN
guests do. custom_model_data and item_model were not on it, so the
client's item model never saw them: custom_model_data tints stayed on
their default, selects fell back, and an item_model override was ignored.
Write both: item_model as its id, custom_model_data from its saved form
(CustomModelData.CODEC, colours as ints or float triples) into its stream
codec.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:10:36 -05:00
Dufru 387d6be2a8 i18n(launcher): fix spanish text to brazilian portuguese 2026-10-30 23:44:59 -03:00
SenjinandClaude Opus 5.5 d993634c18 HDR: calibration, full screen, one test picture at a time
Graphics: HDR gets a calibration for Black Level, Peak Brightness and Paper White:
each step a test picture drawn by the HDR pass straight in nits, with a slider
and one line of help. Horizon's logo disappears on black, then into a patch of 10%
of the screen; Paper White is a white card, by eye. Save writes the three, Cancel
keeps them, a step left untouched keeps Auto. Paper White goes down to 10 for
dark rooms.

Where the system reports the display's values the button reads Calibrate Anyway...
with "Already set up for your display", and Use Automatic Setup puts changed
values back; where nothing is reported it asks the player to calibrate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:41:48 +01:00
rdbtCVS d0076a5119 Merge main 2026-10-31 02:14:19 +00:00
Federico TaglianiandClaude Sonnet 5.5 ffb0e280b3 Exploration maps count the references a chunk saved for its starts
StructureCheck.tryLoadFromStorage and onStructureLoad take the references of
a chunk's saved starts as known, so a world saved by vanilla keeps the
starts its maps already pointed at. SaveDir::start_references reads them;
the sidecar file still holds the ones maps add here.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:00:25 +01:00
socksproxandDevin (SWE-2 High) 3d5aed2b20 Scale fake player hitboxes with minecraft:scale; pick !removed entities
A lobby's giant fake player (minecraft:player or mannequin with a
minecraft:scale attribute) rendered huge but kept a 0.6x1.8 hitbox at its
feet, so right-clicking it did nothing while vanilla's scaled box answers
the click. mob_hit also skipped non-alive mobs although CAN_BE_PICKED is
isPickable = !isRemoved, hiding health-0 NPCs from clicks and the entities
command, and entity reach was hardcoded instead of the
entity_interaction_range/block_interaction_range attributes.

Co-Authored-By: Devin (SWE-2 High) <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-31 01:59:28 +01:00
Federico Tagliani 0161c4d30b Merge remote-tracking branch 'origin/main' into fix/explorer-map-references 2026-10-31 01:43:21 +01:00
chi4aandDeepSeek Harness a089727181 Merge upstream/main into fix/entity-chest-open
Keep both sides in boats.rs: upstream/main's AbstractBoat.remove lead drop
(boat_removed) and this branch's chest-content spill (unconditional) plus the
entity_drops-gated boat item drop.

Co-Authored-By: DeepSeek Harness (deepseek-flash) <[email protected]>
2026-10-31 01:55:04 +02:00
Federico TaglianiandClaude Sonnet 5.5 e40a53d871 Map references: write the file beside it and rename over it
A crash mid-write left truncated JSON, which loads as no references and
made maps repeat structures.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:52:27 +01:00
Federico Tagliani d06b7d7fe3 Merge origin/main into fix/explorer-map-references 2026-10-31 00:52:06 +01:00
ElyTheCatandAntigravity b355b3540c Worldgen: zero scratch density buffer on reuse and preserve heightmap bounds
- Pre-zero reused thread-local scratch density buffer in fill_noise before density sampling.
- Track heightmap bounds dynamically with re-queried first_available across downward scans and non-opaque blocks.
- Add regression unit tests for poisoned scratch density buffer reuse and top-down surface heightmaps.

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 03:13:34 +03:30
e115843756 Fix ghost sand when placing falling blocks next to each other.
Drain hub player packets before scheduled block ticks on the server thread so a second sand place in the same tick as the first's fall schedule still sees the support, matching vanilla's packet-before-block order.

Co-Authored-By: Composer <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-10-31 00:40:37 +01:00
Łukasz KopańskiandCursor 38eb9d612e Drop unrelated recipe_book ghost edits from the cape fix.
Those private-field clears broke verify; keep only auth/skins/profile cape merge.

Co-Authored-By: Cursor <[email protected]>
2026-10-31 00:28:32 +01:00
Dufru 551be35cc3 i18n(launcher): add Brazilian Portuguese (pt-BR) translation 2026-10-30 20:16:27 -03:00
Łukasz KopańskiandCursor e2de9a0b28 Fetch the signed-in player's active cape from Minecraft profile.
Single-player often had a skin but no cape because the client only filled missing skins from the services, not missing capes. Ask GET /minecraft/profile once after join and merge ACTIVE skin and cape into local textures.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 23:45:15 +01:00
martipops e8c951cb3a Merge remote-tracking branch 'upstream/main' into split/02-audio 2026-10-30 18:37:10 -04:00
martipopsandClaude Opus 5.5 eff016f55d Splitscreen audio: say what the duplicate rule can merge
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:12:25 -04:00
GIGABAIT93 ec731c699a Merge branch 'feat/launch-api-prepare-complete' into feat/launch-api-prepare-inputs 2026-10-30 23:51:53 +02:00
GIGABAIT93 2643889010 Merge branch 'feat/launch-api-prepare' into feat/launch-api-prepare-complete 2026-10-30 23:51:49 +02:00
GIGABAIT93andClaude Opus 5.5 e5cbdb422e horizon-prepare: one lock when --output is .local; absolute Java paths
An --output naming root/.local itself took that folder's lock a second
time through another handle, and waited for its own process forever.
Java found through a relative JAVA_HOME, --java-home or PATH entry is now
absolute, since the exporters run in .local.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:51:40 +02:00
calclb 4d0630488d name player killers in death message
CombatTracker only tracked mob attackers, now we track players so we dont
print '%1$s was slain by %2$s'. pr covers melee hits, sweep attacks,
arrows, thorns.
2026-10-30 16:38:16 -05:00
VaspyyyandClaude Opus 5.5 713c613140 Shader API: one switch case per sprite layer when names share it
Identical stills share a layer under several names, so sprite_tags gave
the generated switch duplicate cases (naga: ConflictingSwitchCase) and the
pack failed to load. Their tags are now merged per layer. Reported with
the fix by DUzzL's agent on #4186.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QS9x9HatX1QRXMqxeb
2026-10-30 20:16:07 +00:00
chi4a 56c8af8bd8 Fix opening container vehicles (#4370)
Right-clicking a chest boat, a chest raft, a chest minecart or a hopper
minecart did nothing: the server-side container path (Game::lan_open_container,
shared by LAN guests and single-player players) only knew block containers and
silently dropped the vehicle's placeholder key.

- give a container vehicle its own menu and title: ChestMenu.threeRows (9x3,
  27 slots) for chest boats, rafts and chest minecarts, HopperMenu (5 slots)
  for hopper minecarts, titled with Entity.getDisplayName's name key, as
  AbstractChestBoat.createMenu / MinecartChest.createMenu /
  MinecartHopper.createMenu / MenuProvider.getDisplayName do;
- fire CONTAINER_OPEN with the player and PiglinAi.angerNearbyPiglins on a
  chest boat and a chest minecart (MinecartChest overrides interact; a hopper
  minecart does not), and CONTAINER_CLOSE on their close (MinecartChest
  overrides stopOpen; a hopper minecart inherits the no-op);
- send OPEN_INVENTORY while riding a chest boat (HasCustomInventoryScreen ->
  ServerGamePacketListenerImpl.handlePlayerCommand), so E opens the chest
  instead of the player's inventory;
- spill a chest boat's contents on every destroying removal, as
  AbstractChestBoat.remove does (a creative hit discards, DISCARDED
  shouldDestroy is true, and Containers.dropContents has no entity_drops
  gate; the boat item stays on the entity_drops death path);
- drop the chest minecart's open/close chest sounds: vanilla plays none.

Tests: tools/test_vehicle_container_parity.py (new, 13/13 menu cases against
the real 26.3 jars), live before/after on the server thread, and two-client LAN
item-loss/duplication runs.

Refs #4370
Co-Authored-By: DeepSeek Harness (deepseek-flash) <[email protected]>
2026-10-30 21:57:07 +02:00
levzzzandClaude Sonnet 5.5 496d04368d Merge main into launcher/native-mod-crates: data files are API 15
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:58:32 +05:00
levzzzandClaude Sonnet 5.5 01b88f1524 Launcher: native builds use the fast profile by default, with a build status
fast (incremental, no LTO) is the default Cargo profile of native builds; a
setting, native-build --profile and 'settings native-profile' pick release.
The profile is in the key and has its own target folder. CARGO_INCREMENTAL is
removed from the build's environment. The build says up to date / incremental
(what changed) / first build before it starts, shows crates done of the total,
the profile and the time while it compiles, and 'Built in 42 s (fast,
incremental)' after.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:56:19 +05:00
DUzzL 4f76341bdd Launcher: read ATLauncher instances from observed instance.json files, not its source 2026-10-30 19:18:19 +01:00
AurelienBelloirandClaude Opus 5.5 3033b8133f Name the extended transformation codec in display_data
Display.readAdditionalSaveData reads "transformation" with
Transformation.EXTENDED_CODEC (the four-part form or an ExtraCodecs.MATRIX4F
decomposed by MatrixUtil.svdDecompose) and falls back to
Transformation.IDENTITY. The comment above the read still described only
the four-part Transformation.CODEC; it now names the codec actually followed.

The MatrixUtil.svdDecompose test pins its expected bit patterns to u32 so the
0x8000_0000-style literals never fall back to i32.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:14:06 +01:00
AurelienBelloirandClaude Opus 5.5 e040b000b0 Decompose matrix display transformations like MatrixUtil
Display.readAdditionalSaveData reads "transformation" with
Transformation.EXTENDED_CODEC: the translation/left_rotation/scale/
right_rotation record, or else ExtraCodecs.MATRIX4F (sixteen floats,
row-major), which new Transformation(matrix) splits on demand with
MatrixUtil.svdDecompose (eigenvalueJacobi on a^T*a for the right
rotation, then QR Givens rotations about z, y and x for the left rotation
and scale). Horizon left a matrix-form display at the identity.

- horizon-assets math: svd_decompose, following MatrixUtil.svdDecompose,
  stepJacobi, approxGivensQuat, qrGivensQuat and GivensParameters with
  JOML 1.10.9's Matrix3f.mul/rotate and Quaternionf.mul arithmetic; the
  test checks the bits vanilla's own decomposition returns.
- text_display: the matrix alternative feeds DATA_TRANSLATION_ID to
  DATA_RIGHT_ROTATION_ID. Axis-angle rotations now follow
  new Quaternionf(AxisAngle4f) (the angle wrapped to [0, 2pi), the axis
  not normalized) and component rotations Quaternionf.normalize, through
  the existing horizon-assets helpers, instead of an approximation.
- entity_data: a test pins VECTOR3 and QUATERNION to
  FriendlyByteBuf.writeVector3f / writeQuaternion's big-endian floats.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:05:49 +01:00
AurelienBelloirandClaude Opus 5.5 12a100719a Send the world's data pack dialogs in the dialog registry
RegistryDataLoader builds the synchronized registries from every enabled
pack, so a pack's data/<ns>/dialog/ files join minecraft:dialog in
Identifier order (path first, then namespace) and /dialog show can open
them. The data pack loader now reads dialog/ files, SyncData merges them
over the game's own and always sends their data (no client knows the
pack), and the commands' dialog ids follow SyncData so a parsed id sends
the index the clients hold.

Opening a world with data packs now drops the preloaded SyncData, which
was read without the world's packs and kept their recipes, advancements
and dialogs from the clients.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:38:26 +01:00
AurelienBelloirandClaude Opus 5.5 6a528894ad Fire the default_block_use and any_block_use advancement triggers
ServerPlayerGameMode.useItemOn fires DEFAULT_BLOCK_USE when the block's
useWithoutItem consumes the click, and handleUseItemOn fires ANY_BLOCK_USE
when the whole useItemOn does, with the hand's stack as the use left it.
Behaviours::use_item_on_branch says which of useItemOn and useWithoutItem
answered; the generic path now also fires ITEM_USED_ON_BLOCK for a
consuming useItemOn (a candle snuffed by hand). The jukebox eject, the
decorated pot wobble and the chiseled bookshelf removal fire
DEFAULT_BLOCK_USE on their useWithoutItem branch.

Fixes #4499

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:32:09 +01:00
MSedgeMC 87231ee654 Let a brown mooshroom take a flower's stew effects
MushroomCow.getEffectsFromItemStack reads the item's block, so a brown
adult takes a flower, stores its suspicious stew entry and hands it back in
the next bowl of stew as a suspicious stew carrying that effect. Horizon
had the component, the brown variant and the bowl branch, but no flower
branch and no stored effects, so a bowl always gave plain stew.

The effect belongs to the block, not the item, so the table lives beside
the other block facts: seventeen entries, cross-checked against the jar's
own suspicious_stew_from_* recipes. The two eyeblossoms and the wither rose
are holders too, which a count of FlowerBlock constructor sites misses.
2026-10-30 22:23:21 +05:30
ElyTheCatandAntigravity ffc2c2e4b2 Worldgen: optimize terrain fill, aquifer cell caching, and 48-bit positional seeding
- Precompute the 48-bit masked seed base in LegacyPositionalRandom to eliminate full multiplicative seeding on position and string queries.
- Reuse thread-local scratch density buffers in fill_noise to eliminate 393 KB heap allocations per chunk pass.
- Hoist column density slices in fill_noise to eliminate 98,304 redundant volume.index() lookups.
- Implement monotonic top-down heightmap termination in fill_noise, bypassing all subterranean heightmap checks once settled.
- Cache horizontal squared distances and cell coordinates in NoiseBasedAquifer across vertical slabs, reducing inner distance loop work to a single subtraction and addition.
- Early-return in apply_carving_mask when the carving mask is empty to avoid redundant MaterialConditions setup and graph compilation.

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 18:30:42 +03:30
Ludovic MarieandClaude Opus 5.5 1dde732325 Mod registry: a retry proves the branch is its own with a signed tag
Review (rdbtCVS): the "Submitted by @<login> through the form." line is
plain text anyone who writes to the registry repository can copy, and a
commit's author can be set by anyone too. The form's commit now carries
a "Submission: <tag>" trailer, an HMAC of the branch and the login with
the service's key; a stale branch is started again only when it holds no
commit of its own or the one commit with this account's tag. The test's
someone-else branch now copies alice's exact words with a forged tag and
is refused without a reset.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:51:15 +01:00
Ludovic MarieandClaude Opus 5.5 756e03fb81 Mod registry: a mod may turn native once; a retry takes up its own branch
publish() refused any kind change ("blockscope is a mod"), but the
registry's own review (horizon_mods::index::registry::review, run by
horizon-mods' CI) and docs/native-mods.md let a WebAssembly mod's entry
turn native-mod once, never back. The form now allows exactly that, by
an owner, and names the rule when it refuses any other kind change. The
pull request says the project turns native.

A submission GitHub broke off after making its submit/<id>-<version>
branch was refused as "already submitted" (409) on retry, with no pull
request. When the branch exists, the form now looks for an open pull
request (still 409, with its address) and at the branch's own commits:
if they are all this account's form commits (or there are none), it
resets the branch to main and submits again; anyone else's is 409.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:25:01 +01:00
GIGABAIT93 5700a61de3 Merge branch 'feat/launch-api-prepare-complete' into feat/launch-api-prepare-inputs 2026-10-30 15:48:58 +02:00
GIGABAIT93 e81001ce80 Merge branch 'feat/launch-api-prepare' into feat/launch-api-prepare-complete 2026-10-30 15:48:52 +02:00
GIGABAIT93andClaude Opus 5.5 277b7ee5da Merge main; refill_own_store asks missing_extras too
main added refill_own_store, which called the launcher's lacks_languages,
gone since missing_extras became the one check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:48:46 +02:00
GIGABAIT93 c83cf8cbed Merge remote-tracking branch 'upstream/main' into feat/launch-api-prepare 2026-10-30 15:44:05 +02:00
levzzzandClaude Opus 5.5 5355284d69 Merge hr/main into launcher/native-mod-crates: data files become hook API 8
Main took API 3 (world lifecycle) and 4-7 (arismoko's server tick, block reads,
neighbour and block-change observations). The data-file helpers (native_data!,
native_built_data) are API 8. MIXINS_REV moves to horizon-mixins 5140cdf
(#5, #7, #8 and #9: RETURN hooks on functions with closures and references).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:36:59 +05:00
GIGABAIT93andClaude Opus 5.5 78db89ba16 horizon-prepare: --java-home, log the jar read, document named inputs (#3545)
- Merges #3574 (and with it #3567 and #3551) for docs/api/launch.md.
- --java-home names a Java the launcher manages (Options::java_home was
  there for horizon-launcher only).
- The import logs the client jar it read, which --client-jar may have
  named outside the Minecraft folder.
- named_asset_store_serves_the_asset_commands: with an empty Minecraft
  folder, an asset command fails without --assets-dir and copies from
  the store it names.
- docs/api/launch.md and native-client.md: the named inputs, that an
  earlier verified import is reused instead, the server jar rule (Horizon
  never downloads it; a launcher only for an owner), --java-home.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:31:26 +02:00
GIGABAIT93andClaude Opus 5.5 8311857928 docs: put --complete and --progress json back in native-client.md
The merge of #3567 into this branch dropped them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:29:19 +02:00
GIGABAIT93andClaude Opus 5.5 a91acdd348 Launch API: document horizon-prepare's output and --progress json (#3545)
- docs/api/launch.md gets a Preparing section: --output, --check (what it
  tests and what it doesn't), --complete, and --progress json's rules:
  one JSON object a line, the three keys, readers ignore what they don't
  know, a new key is a patch of the Launch API.
- The changelog lists the preparer's options under 0.1.0, still
  unreleased. The overview, docs/api/README.md and stability.md say the
  Launch API covers the preparer now.
- progress_json_lines_keep_their_shape pins message, error and fraction
  (a quote, a newline, three decimals); fraction_line is the one place
  that formats a fraction.
- steps_open_every_stage reads lib.rs with either line end, so it passes
  on a Windows checkout too.

This branch now holds #3551 as well, for docs/api/launch.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:28:56 +02:00
GIGABAIT93 8dbde815ba Merge branch 'feat/launch-api-client' into feat/launch-api-prepare-complete 2026-10-30 15:26:30 +02:00
GIGABAIT93 83fadad7bf Merge branch 'feat/launch-api-prepare' into feat/launch-api-prepare-complete
# Conflicts:
#	crates/horizon-prepare/src/main.rs
#	docs/native-client.md
2026-10-30 15:26:25 +02:00
GIGABAIT93andClaude Opus 5.5 6f0f282cf1 horizon-prepare: lock a folder while preparing, unstamp before writing (#3545)
- A preparation removes the stamp before it writes anything and stamps
  again at the end, so --check calls a folder being prepared, or whose
  preparation stopped, not current.
- Each preparation holds .horizon-prepare.lock in the resources and in
  root's .local (add_assets in the resources); a second one says it is
  waiting and waits. tools/prepare_output.py leaves the lock out of the
  digest.
- missing_extras is the one completeness check: --check and the
  launcher's COMPLETIONS both use it.
- The Java exporters and the worker run in root's .local, so vanilla's
  logs/ stays in the scratch, not where the preparer was started.
- --check's help and docs/native-client.md say what it doesn't test
  (contents) and that exit 1 is either gaps or "can't check".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:26:08 +02:00
levzzzandClaude Opus 5.5 8cca9cd942 Native mods: horizon-mixins with Isloka's two fixes; post-build programs copied out
MIXINS_REV moves to horizon-mixins d9c83d7 (#7 and #8 by reservedbytes):
- a crate dependency with default features (hashbrown = "0.15") builds: cargo adds the
  lock links between crates Horizon already locks, any new crate is still refused;
- a post-build bin named like another mod's step bin or like horizon-client is refused
  (E_POST_BUILD).

The launcher also copies the game and each step's program out of the shared target folder as
soon as it is built, so a bundle staged by an older horizon-mixins can't run another mod's
program or replace the game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:39:04 +05:00
Federico TaglianiandClaude Sonnet 5.5 bc92841fe6 Exploration maps skip the structures a map already pointed at
ChunkGenerator.findNearestMapStructure with createReference: a map with
skip_existing_chunks (the default, and every cartographer trade) points at
the nearest start no map pointed at yet and counts it as pointed at
(StructureStart.addReference, getMaxReferences 1), so two maps made in a
row no longer lead to the same structure. The starts are kept per
dimension in data/horizon_structure_references_<dim>.json.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 13:27:46 +01:00
iLostName e759c905f1 resource packs: a pack's core/text shader draws the GUI's text, so server HUDs show
Servers place HUDs with their pack's text.vsh (glyphs far right of a boss bar title, moved to the screen's corners) and colour effects in text.fsh. horizon-shaders translates the pack's GLSL (#include or #moj_import) to naga; the HUD draws GUI text runs with it.
2026-10-30 12:44:42 +01:00
MSedgeMC 015fbc82c1 Make the client-loaded immunity term real
ServerPlayer.isInvulnerableTo has three terms, and the third is
!connection.hasClientLoaded: from a death until the client reports the
world loaded, nothing hurts the player. That is what spares a respawned
player the void in the tick it is still standing where it died.

Horizon implemented all three terms, but the only production
implementation of the trait returned Ok(true) for this one, so it was
dead code. A guest now carries the flag: cleared by a respawn, set by
ServerboundPlayerLoadedPacket, copied onto the body each turn, and read by
the damage environment (including the starvation path, which builds one
too). A level change does not clear it, because vanilla resets the loaded
state on death and respawn only.
2026-10-30 16:29:13 +05:30
Kacpherek ffd04438e3 Merge remote-tracking branch 'origin/main' into fix/split-maps-villager-trades 2026-10-30 06:32:12 +01:00
Kacpherek 237b58d952 Move the villager trade display out of this PR
The villager changes (crossed arms item, held offers, equipment sync) now
live in their own branch fix/split-villager-trades; this one keeps the map
banner and frame markers.
2026-10-30 06:32:12 +01:00
Kacpherek c38f433d59 Merge main into fix/split-maps-villager-trades
Keep main's held-stack API (set_main_hand, main_hand_stack) and bone meal
prediction; the villager's shown item now lives in the main hand stack, and
the equipment sync sends that stack.
2026-10-30 06:30:20 +01:00
VaspyyyandClaude Opus 5.5 df86a8dd4f Shader API: sprite tags and the weather at the camera for the material stage
Packs declare [[tags]] of block sprites (glass, say) and read them as
MaterialInput.tags; the frame gains precipitation, temperature, downfall
and wetness (rain smoothed as the shaders/ format's).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QS9x9HatX1QRXMqxeb
2026-10-30 05:02:18 +00:00
martipopsandClaude Opus 5.5 10cc390c21 Audio: the mixer callback neither allocates nor copies world names
Review on #3777: worlds are small ids now (Mixer::world_id), so the callback compares and
copies integers; its camera list is a buffer the mixer keeps; a sound only looks for another
player's copy (and takes the lock for it) while another player shares the output.

Measured with 32 playing sounds and one player, 512-frame buffers (release, M-series Mac):
upstream main 21.9-22.5 us per buffer, this part before 21.3-21.8 us, after 21.3-21.6 us:
the same within noise; the time goes to mixing the samples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:42:35 -04:00
martipops d2ad22fe4c Merge remote-tracking branch 'upstream/main' into split/02-audio 2026-10-29 23:07:35 -04:00
martipopsandClaude Opus 5.5 63325c9928 Audio: a shared output keeps the device name upstream added
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:39:33 -04:00
martipops fbb1d24b53 Merge remote-tracking branch 'upstream/main' into split/02-audio 2026-10-29 21:38:21 -04:00
levzzz 4c08d3e128 Merge remote-tracking branch 'hr/main' into launcher/native-mod-crates 2026-10-30 03:08:41 +05:00
levzzz 4e6c5fc676 Merge remote-tracking branch 'hr/main' into launcher/native-mod-crates
# Conflicts:
#	launcher/src/native_mods.rs
2026-10-30 03:00:27 +05:00
levzzzandClaude Opus 5.5 166ce92969 Launcher: Polish for the native build steps
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:54:41 +05:00
levzzz 87ff3f24b1 Merge remote-tracking branch 'hr/main' into launcher/native-mod-crates 2026-10-30 02:53:20 +05:00
levzzzandClaude Opus 5.5 d6653e2ee5 Launcher: horizon-mixins at its merged 0.6.0 (horizon-mixins#5)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:53:20 +05:00
levzzzandClaude Opus 5.5 263ca3d80a Docs: a native mod crate's features apply to the game too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:32:05 +05:00
levzzzandClaude Opus 5.5 80c9404cf4 Launcher: native mods' own crates, Horizon's lock as their allow list, post-build steps
A bundle's [[crate]] (staged by horizon-mixins 0.6.0 as native-crates/<mod>/<crate>/)
may use only the registry crates Horizon's Cargo.lock has: the build checks the staged
lock's downloaded packages against Horizon's and fetches with --locked, saying plainly
when a feature would add a crate. After the game compiles, each [[post_build]] bin is
built with the same profile and run in the new version folder with
HORIZON_NATIVE_{MOD,VERSION_DIR,DATA_DIR,CACHE_DIR}; a failure fails the build.
Progress steps are translated. MIXINS_REV moves to horizon-mixins#5's head.

A test bundle (launcher/tests/fixtures/native-crate-demo) uses all of it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:24:24 +05:00
Kacpherek 65967c4324 Make the maps-villager-trades fixes build on current main 2026-10-29 22:18:23 +01:00
DUzzL 9a8455d017 Launcher: copy ATLauncher instances into Java instances 2026-10-29 22:16:46 +01:00
levzzzandClaude Opus 5.5 6f9a766556 Native mods: native_data! for bundles' data files, hook points API 3
native_data!("<mod>", "file") compiles a file of a bundle's data/ folder
(staged as native-data/<mod>/ by horizon-mixins) into the game;
native_built_data is the folder a mod's post-build step wrote into. A
.hnmod may carry a crate's own data/ folder too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:08:34 +05:00
Kacpherek 06e43b14df List a map's id, lock and scale in its tooltip
A filled map's tooltip showed none of its lines. MapId.addToTooltip gives Unknown Map when the client has no data for it, else the id line (not for a named map or one a cartography table is locking or scaling), Locked, and with advanced tooltips the scale and level, a scaling copy counting one more up to 4 (MapId.java:27). The lines sit between the instrument and bees lines as in ItemStack.addDetailsToTooltipComponents.
2026-10-29 22:06:48 +01:00
Kacpherek 12a8cc20b4 Clear a map's frame marker when its item frame loses the map
Taking a map out of an item frame, breaking the frame, a blast or /kill left the frame's marker and saved entry on the map. ItemFrame.removeFramedMap calls MapItemSavedData.removedFromFrame(pos, entityId) on every path that empties the frame (dropItem unless the drops are off and something caused it, onKilled), which removes the marker and the frame entry (MapItemSavedData.java:421, ItemFrame.java:247).
2026-10-29 22:06:48 +01:00
Kacpherek 811545ed8d Mark a framed map's item frame on the map
A map hanging in an item frame never showed the frame's marker, and a player who saw it framed without carrying it kept a marker. ServerEntity.sendChanges ticks every 10 ticks tickCarriedBy(player, stack, frame) for each player: one carrying no copy loses their marker, and with position tracking the frame's marker is added under its entity and the frame saved with its block, rotation (2D direction times 90) and entity, replacing an older entity's marker at the same block (MapItemSavedData.java:159).
2026-10-29 22:06:48 +01:00
Kacpherek 079b4bea68 Hide players in map invisibility gear from others' maps
A player wearing a carved pumpkin was still drawn on other players' maps. MapItemSavedData.tickCarriedBy removes the marker of every other holder for whom hasMapInvisibilityItemEquipped finds a piece of #map_invisibility_equipment in an armor slot (MapItemSavedData.java:225). Each holder keeps what it wore on its own tick.
2026-10-29 22:06:48 +01:00
Kacpherek 4941dd40b2 Drop a map's banner marker when its banner is gone
A banner marker stayed on the map after its banner was broken or changed. MapItem.update calls MapItemSavedData.checkBanners for every column it reads: a marker in that column whose banner is no longer the saved one (colour, name or the banner itself) loses its decoration and its entry (MapItemSavedData.java:401).
2026-10-29 22:06:48 +01:00
Kacpherek 910d5479eb Mark a banner on the map when the map is used on it
A map used on a placed banner did nothing. MapItem.useOn toggles that banner's marker through toggleBanner: SUCCESS swings and counts as a use, FAIL (the map refuses) ends the use quietly, and the item is never placed or used otherwise (MapItem.java:336).
2026-10-29 22:06:48 +01:00
Kacpherek 955214ae1b Toggle a banner's marker on a map
A map could not add or drop a banner marker on request. MapItemSavedData.toggleBanner works only for a banner inside the map: one whose marker stands as it is loses it, otherwise the marker is added (renewed when the banner changed) unless the map already tracks more than 256 markers (MapItemSavedData.java:371, MapDecorationType.trackCount).
2026-10-29 22:06:48 +01:00
Kacpherek e108d1d57f Read a placed banner as a map marker source
A map had no way to ask what banner stands at a position. MapBanner.fromWorld returns the banner block entity's base colour (the block's dye colour) and custom name, or nothing when no banner is there (MapBanner.java:25).
2026-10-29 22:06:48 +01:00
Kacpherek a5b45dead5 Load and save map banner and frame markers as vanilla does
A map kept its banners and frames lists as raw tags, so the markers never showed. MapItemSavedData's constructor adds a banner marker in the banner's dye colour (MapBanner.getDecoration, the 16 banner decoration types) and a frame marker for each saved entry, without a level, and the lists are written back (MapBanner.CODEC, MapFrame.CODEC, MapItemSavedData.java:100). A locked copy keeps the banners and the decorations (MapItemSavedData.locked).
2026-10-29 22:06:48 +01:00
Kacpherek 5430fa0f6a Route map markers through addDecoration with vanilla keys
A map kept one marker per player and rebuilt the item ones, with no place for a banner or frame marker. MapItemSavedData.addDecoration files each MapDecoration under a key (player, banner, frame), keeps its place in the drawing order, clamps it to the map, swaps a player's arrow for the off-map ones and drops what falls outside (MapItemSavedData.java:253). Markers now carry their name and key, and the update packet sends the name.
2026-10-29 22:06:48 +01:00
Kacpherek e743e4e418 Cycle the items a villager holds up every 40 ticks
With two or more matching offers a villager swapped nothing: the held item never changed. Vanilla counts 40 ticks, then holds up the next display item and wraps to the first (ShowTradesToPlayer.displayCyclingItems); start resets the counter and index.
2026-10-29 22:06:47 +01:00
Kacpherek 837d409e31 Hold up the offer a villager sells for the item you hold
A villager looking at a player who holds a trade's cost now collects the offers in stock that take that item, holds up the first and keeps looking for 900 ticks instead of 40 (ShowTradesToPlayer.findItemsToDisplay, updateDisplayItems, displayFirstItem). The held stack keeps its count and enchantments and never drops (displayAsHeldItem). Offers not made yet are rolled by the level first.
2026-10-29 22:06:47 +01:00
Kacpherek 4ce5d55664 Empty a villager's hand when it stops showing trades
ShowTradesToPlayer clears the main hand every tick it has nothing to show and again when it stops, restoring the default 0.085 drop chance (ShowTradesToPlayer.clearHeldItem). Horizon left whatever the villager held in place.
2026-10-29 22:06:47 +01:00
Kacpherek c8a6cf4364 Let a villager brain ask the level to roll its offers
A brain tick holds only the mob, but AbstractVillager.getOffers makes the trades the first time they are asked for and that needs the level's trade data and random sequences. VillagerOffers has the game run ensure_offers for the villager, as the trading screen does.
2026-10-29 22:06:47 +01:00
Kacpherek 834cc83e85 Sync a mob's main hand enchantments to its viewers
SetEquipment carried only the main hand's item and count, so an enchanted item a mob holds (the offer a villager shows) never glinted on a client. Vanilla sends the whole stack (ClientboundSetEquipmentPacket); the server now adds the held enchantments from the gear table and the client keeps them there.
2026-10-29 22:06:47 +01:00
Kacpherek f8eebefe0a Draw the item a villager holds on its crossed arms
A villager's held item was never drawn: its model has no hand arms, so ItemInHandLayer found no hand. Vanilla's VillagerRenderer adds CrossedArmsItemLayer, which puts the main hand's item on the arms and shows it in the GROUND display context (CrossedArmsItemLayer.java), as the witch already does.
2026-10-29 22:06:43 +01:00
levzzz 24c84d13d5 Merge #3880 (arismoko: data files in native mod bundles) 2026-10-30 01:58:19 +05:00
levzzz b88b7976f6 Merge #3871 (arismoko: catalog versioned native hook points) 2026-10-30 01:58:19 +05:00
rdbtCVSandOpenAI Codex 2b46e411f5 Update zip to 9.1.3
Co-Authored-By: OpenAI Codex (gpt-6.1-terra) <[email protected]>
2026-10-29 18:21:18 +00:00
martipopsandCodex 296603342f Allow sound engines to share one dimension-aware audio output
Select the nearest listener in each world, suppress duplicate world sounds, and clean up shared listeners and channels. Inaudible sources continue advancing. Part 2 of the splitscreen series; game integration follows separately.

Co-Authored-By: Codex <[email protected]>
2026-10-29 09:34:42 -04:00
GIGABAIT93andClaude Opus 5.5 7f8c190b29 horizon-prepare: name the game files one by one (#3545)
--version-json, --client-jar, --libraries-dir and --assets-dir name
the files a launcher keeps in a layout of its own (instance folders,
shared libraries and assets), each replacing only what --minecraft-dir
would give; every file is still checked against research-lock.json's
pins. GameFiles gains the libraries and asset store folders, and
Options the Inputs (Default in the launcher, so it is unchanged).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:47:51 +02:00
GIGABAIT93andClaude Opus 5.5 82cf8916e2 horizon-prepare: --complete and --progress json (#3545)
--complete prepares, copies the sounds, then fails unless --check would
pass: one command for resources with nothing missing (without the sounds
step the game is silent, which is easy to miss).

--progress json prints one JSON object a line: {"message": ...} for each
log line, {"fraction": F} as each of the preparation's 18 stages begins
and at the end, {"error": ...} on failure. prepare_with_progress takes
the fraction; prepare keeps its signature, and a test keeps STAGES equal
to the stages steps() opens.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:39:47 +02:00
GIGABAIT93andClaude Opus 5.5 8dfd37428a horizon-prepare: name check's extras type for clippy
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:37:10 +02:00
GIGABAIT93andClaude Opus 5.5 da7aa1f73b horizon-prepare: --output and --check (#3545)
--output DIR puts the prepared resources elsewhere than
<root>/.local/horizon-client/<version>, for preparing and for the asset
commands (sounds, panorama, fonts, packs, languages, now one add_assets
behind them); the exporters' scratch files stay in <root>/.local.

--check exits 0 when the resources are complete and current for this
preparer and 1 otherwise, printing each gap with the command that fills
it: the manifest's schema and Minecraft version, the OUTPUT_VERSION that
made them, and the extras the launcher completes (packs, panorama,
Unifont, languages, sounds). A preparation now stamps its resources with
its OUTPUT_VERSION (horizon-prepare.json), which the output digest
leaves out, so the stamp needs no new OUTPUT_VERSION.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 10:28:44 +02:00
AHHhHHHHH-HandClaude Sonnet 5.5 32f99ed5f7 LOD Distance: a slider up to the whole world, with heightmap far tiles
The LOD Distance button becomes a slider from 64 chunks to 4194304 (the whole world). Far terrain plans up to 22 levels with larger cells past 1024 chunks, meshes tiles from level 6 up as heightmaps, makes a very wide face one scaled quad instead of thousands, and bounds the clip subdivision of coarse tiles around full-detail chunks.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-28 21:50:15 -03:00
i114rkandI114rk's AI agent 358b211e97 Boot loading screen: the window paints black with the wordmark, tagline and live console lines before the renderer's first frame
Co-Authored-By: I114rk's AI agent (OpenCode) <[email protected]>
2026-10-28 17:04:55 +03:00
408 changed files with 25348 additions and 2515 deletions

No files matched your search

+1
View File
@@ -21,6 +21,7 @@
.DS_Store
# What the client writes to its working directory when run from the repo root.
/options.txt
/debug-profile.json
/horizon.txt
/horizon-graphics.txt
/horizon-controller.txt
+2 -2
View File
@@ -2,7 +2,7 @@
Thanks for helping. Horizon is Minecraft: Java Edition 26.3 rewritten in Rust, and the one rule that shapes
everything is that **it plays exactly like vanilla**. This page is the short version of how we work; questions are
welcome on [Discord](https://discord.gg/HcTeMZxe5E).
welcome on [Discord](https://discord.gg/playhorizon).
This repository holds the game and the launcher, plus the tools, tests and docs that build, check and release them.
The website (playhorizon.dev) is built in a separate repository from this one's README, `docs/api/` and roadmap. Anything else (videos, Discord and other community material) lives elsewhere.
@@ -136,5 +136,5 @@ new pushes, we add it again.
## Talk to us
[Discord](https://discord.gg/HcTeMZxe5E) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
[Discord](https://discord.gg/playhorizon) for questions and ideas, [issues](https://github.com/horizon-rust/horizon/issues)
for bugs. To take something on, `/claim` its issue ([above](#claim-before-you-start)).
Generated
+14 -26
View File
@@ -118,15 +118,6 @@ dependencies = [
"libc",
]
[[package]]
name = "arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arboard"
version = "3.6.1"
@@ -605,17 +596,6 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f"
[[package]]
name = "derive_arbitrary"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b034bd7d5f032402a2479444dcc6f74e36a03f31854d41680fb240ef682a1ac"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -914,8 +894,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi 6.0.0",
"wasm-bindgen",
]
[[package]]
@@ -3475,6 +3457,12 @@ version = "2.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86a801b3cea342a06d468c8710662aa29e5e05e4f5c0d62f00bbb7f2ad7941c2"
[[package]]
name = "typed-path"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce898823d8cd0f028662d2f349694b1af0a5cc9be83f1d213a00d3147445e1e"
[[package]]
name = "typenum"
version = "1.20.1"
@@ -4672,18 +4660,18 @@ dependencies = [
[[package]]
name = "zip"
version = "2.4.2"
version = "9.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
checksum = "1156b39d142a34b999c2310a50455e0cb3760bf7efad0122f796d813bee26ccb"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"getrandom 0.4.3",
"indexmap",
"memchr",
"thiserror 2.0.21",
"nix",
"typed-path",
"windows-sys 0.61.2",
"zopfli",
]
+1 -1
View File
@@ -111,7 +111,7 @@ What is in progress, what comes next and where to help: [ROADMAP.md](ROADMAP.md)
## Contributing
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests). Questions: [Discord](https://discord.gg/HcTeMZxe5E). Before a pull request, run the self-check and paste its output:
Issues and pull requests are welcome: read [CONTRIBUTING.md](CONTRIBUTING.md) (vanilla first, small pull requests). Questions: [Discord](https://discord.gg/playhorizon). Before a pull request, run the self-check and paste its output:
```sh
git config core.hooksPath .githooks
+99
View File
@@ -0,0 +1,99 @@
//! Combat: attacks vanilla's client can't send (docs/anticheat.md).
//!
//! Vanilla's client sends ServerboundAttackPacket from one place only, `Minecraft.startAttack`, run from
//! `handleKeybinds` once per client tick. It attacks the entity of the tick's `hitResult`, which
//! `Minecraft.tick` picks once before the keys are handled, so every attack of one client tick is on the
//! same entity. And each attack swings the arm (ServerboundPunchPacket) in the same call, so a client
//! tick never has more attacks than swings: a tick that does comes from a kill aura hiding its swing.
//! The swing is counted before or after the attack, as older clients behind a version proxy swing
//! first; a swing without an attack (a miss, a block) is fine.
/// What breaks vanilla's way of attacking.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Foul {
/// A client tick with more attacks than swings: how many more.
NoSwing(u32),
/// A second entity attacked in the same client tick: the first one and this one.
TwoTargets(i32, i32),
}
/// The attacks of the client tick being heard.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Attacks {
/// The entity this tick attacked first.
target: Option<i32>,
attacks: u32,
swings: u32,
}
impl Attacks {
/// ServerboundAttackPacket on entity `target`.
pub fn attack(&mut self, target: i32) -> Option<Foul> {
self.attacks = self.attacks.saturating_add(1);
let first = *self.target.get_or_insert(target);
(first != target).then_some(Foul::TwoTargets(first, target))
}
/// ServerboundPunchPacket: the arm swung.
pub fn swing(&mut self) {
self.swings = self.swings.saturating_add(1);
}
/// ServerboundClientTickEndPacket: the tick's attacks are counted, and the next tick picks its
/// target again.
pub fn tick_end(&mut self) -> Option<Foul> {
let unswung = self.attacks.saturating_sub(self.swings);
*self = Self::default();
(unswung > 0).then_some(Foul::NoSwing(unswung))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_vanilla_tick_is_clean() {
let mut a = Attacks::default();
// Two clicks in one tick on the same entity, each swung for.
for _ in 0..2 {
assert_eq!(a.attack(7), None);
a.swing();
}
assert_eq!(a.tick_end(), None);
// The next tick may attack another entity, swinging first (an older client).
a.swing();
assert_eq!(a.attack(8), None);
assert_eq!(a.tick_end(), None);
// A swing on its own (a miss, a block) is fine.
a.swing();
assert_eq!(a.tick_end(), None);
}
#[test]
fn an_attack_not_swung_for_is_caught() {
let mut a = Attacks::default();
a.attack(7);
assert_eq!(a.tick_end(), Some(Foul::NoSwing(1)));
// A swing in the tick before doesn't count for this one.
a.swing();
a.tick_end();
a.attack(7);
a.attack(7);
a.swing();
assert_eq!(a.tick_end(), Some(Foul::NoSwing(1)));
}
#[test]
fn two_entities_in_one_tick_are_caught() {
let mut a = Attacks::default();
a.attack(7);
a.swing();
assert_eq!(a.attack(8), Some(Foul::TwoTargets(7, 8)));
a.swing();
// Still against the tick's first target.
assert_eq!(a.attack(9), Some(Foul::TwoTargets(7, 9)));
a.swing();
assert_eq!(a.tick_end(), None);
}
}
+37 -17
View File
@@ -2,17 +2,21 @@
//! `server.toml`, each player's violation levels, and the rules of the checks. The server's hub
//! (`horizon_client::lan`) feeds it what players send and carries out what it decides.
//!
//! It is off unless the owner turns it on. A check that fails *flags* the player: its violation level
//! (VL) rises by the flag's weight and falls back by `decay` a second. From `setback` on, the move or
//! action is cancelled (the player is sent back where the server last had them); from `alert` on,
//! online operators and the console are told; from `kick` on, the player is disconnected. Nothing bans.
//! Any threshold at 0 turns that action off.
//! It is on by default, and so is every check, but only the checks a vanilla client can't trip act on
//! their own (`combat`); the others log only until they have shown no false positive with real
//! players (their `setback`, `alert` and `kick` are 0). `enabled = false` turns the anticheat, or one
//! check, off. A check that fails *flags* the player: its violation level (VL) rises by the flag's
//! weight and falls back by `decay` a second. From `setback` on, the move or action is cancelled (the
//! player is sent back where the server last had them); from `alert` on, online operators and the
//! console are told; from `kick` on, the player is disconnected. Nothing bans. Any threshold at 0 turns
//! that action off.
//!
//! The checks are written from vanilla 26.3's own rules and from what cheats are publicly described to
//! do, never from another anticheat's code.
use serde::{Deserialize, Serialize};
pub mod combat;
pub mod prediction;
pub mod timer;
@@ -72,7 +76,7 @@ macro_rules! checks {
}
)*
/// `server.toml`'s `[anticheat]`: off by default.
/// `server.toml`'s `[anticheat]`: on by default.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
@@ -89,7 +93,7 @@ macro_rules! checks {
impl Default for Config {
fn default() -> Self {
Self {
enabled: false,
enabled: true,
alerts: true,
alert_level: 2,
log: "logs/anticheat.log".into(),
@@ -120,7 +124,8 @@ checks! {
/// Timer: more client ticks than the server's clock allows (a sped-up game). Vanilla's client ends
/// every tick with ServerboundClientTickEndPacket, at the server's tick rate; each one spends a tick's
/// time from a balance that wall-clock time fills, capped so a pause can't be saved up for a burst.
timer: Timer => TimerConfig [2.0, 5.0, 20.0, 0.02] {
/// It logs only by default; once trusted: `setback = 2`, `alert = 5`, `kick = 20`.
timer: Timer => TimerConfig [0.0, 0.0, 0.0, 0.02] {
/// How far ahead of the clock, in milliseconds, a client may get before it is flagged.
tolerance_ms: f64 = 300.0,
/// The most time, in milliseconds, a quiet connection banks for the ticks that follow (a lag
@@ -129,9 +134,10 @@ checks! {
}
/// Ground: a move that says it ends on the ground with nothing there to stand on: no-fall (claiming
/// the ground to skip fall damage) and walking on water. The claim is always dropped, so vanilla's
/// fall damage follows; from `setback` on the player is also sent back.
ground: Ground => GroundConfig [5.0, 15.0, 60.0, 2.0] {}
/// the ground to skip fall damage) and walking on water. The claim is dropped, so vanilla's
/// fall damage follows; from `setback` on the player is also sent back. Logging only (`setback = 0`,
/// the default), the claim stands. Once trusted: `setback = 5`, `alert = 15`, `kick = 60`.
ground: Ground => GroundConfig [0.0, 0.0, 0.0, 2.0] {}
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
@@ -141,11 +147,18 @@ checks! {
/// setback puts the player back where its moves last agreed. A tick near what the replay can't
/// play yet (another entity, a block change or abilities the client hadn't seen, a moving piston,
/// a chunk the client doesn't have, a riptide launch) is excused, and so are the ticks after it
/// until the moves agree again.
prediction: Prediction => PredictionConfig [5.0, 15.0, 60.0, 2.0] {
/// until the moves agree again. It logs only by default; once trusted: `setback = 5`, `alert = 15`,
/// `kick = 60`.
prediction: Prediction => PredictionConfig [0.0, 0.0, 0.0, 2.0] {
/// How far, in blocks, a move may land from where the replay puts it.
tolerance: f64 = 0.03,
}
/// Combat: attacks vanilla's client can't send (`combat`): one with no swing after it (a kill aura
/// that hides its swing), or a second entity attacked in the same client tick (vanilla attacks the
/// one entity under the crosshair, picked once a tick). Each flags once; from `setback` on the
/// player's attacks are dropped. A vanilla client never trips it, so it acts by default.
combat: Combat => CombatConfig [2.0, 5.0, 20.0, 0.5] {}
}
/// How many checks there are.
@@ -219,6 +232,8 @@ pub struct Player {
pub ground: Option<(u64, f64)>,
/// The client ticks the prediction plays again (only while it runs).
pub record: Option<Box<prediction::Record>>,
/// The attacks of the client tick being heard.
pub attacks: combat::Attacks,
}
impl Player {
@@ -262,12 +277,17 @@ mod tests {
}
#[test]
fn off_by_default_and_without_a_section() {
assert!(!Config::default().enabled);
fn on_by_default_but_only_combat_acts() {
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
assert_eq!(c, Config::default());
assert!(!c.runs(Check::Timer));
assert!(!c.runs(Check::Ground));
for &check in Check::ALL {
assert!(c.runs(check), "{}", check.name());
let a = c.actions(check);
let acts = [a.setback, a.alert, a.kick] != [0.0; 3];
assert_eq!(acts, check == Check::Combat, "{}", check.name());
}
let off = Config::from_server_toml("[anticheat]\nenabled = false\n").unwrap();
assert!(Check::ALL.iter().all(|&check| !off.runs(check)));
}
#[test]
+10 -10
View File
@@ -16,6 +16,7 @@ use std::{
sync::{Arc, Mutex},
};
mod component_predicate;
mod special;
pub use special::{SpecialKind, SpecialModel};
@@ -628,7 +629,7 @@ pub enum ConditionProperty {
ExtendedView,
KeybindDown(String),
ViewEntity,
/// A `component` predicate, evaluated by the host.
/// A single `component` predicate; external predicate types use the host.
Component(Value),
}
impl ConditionProperty {
@@ -651,13 +652,7 @@ impl ConditionProperty {
"minecraft:extended_view" => Self::ExtendedView,
"minecraft:keybind_down" => Self::KeybindDown(string(required(m, "keybind")?)?),
"minecraft:view_entity" => Self::ViewEntity,
"minecraft:component" => {
let predicate = required(m, "predicate")?;
if object(predicate)?.len() != 1 {
return Err(err("a single component predicate is expected"));
}
Self::Component(predicate.clone())
}
"minecraft:component" => Self::Component(component_predicate::decode(m)?),
_ => return Err(err("unknown condition property")),
})
}
@@ -964,10 +959,14 @@ pub trait ItemEnv {
fn bundle_fullness(&self, _stack: &StackView, _components: &Components) -> f32 {
0.0
}
/// `ItemStack.getMaxStackSize` when the prototype has no component.
/// Additional component predicate types supplied by the host.
fn component_matches(&self, _stack: &StackView, _predicate: &Value) -> bool {
false
}
/// Membership in the level's enchantment registry tags.
fn enchantment_in_tag(&self, _enchantment: &str, _tag: &str) -> bool {
false
}
fn is_keybind_down(&self, _keybind: &str) -> bool {
false
}
@@ -1335,7 +1334,8 @@ impl Resolve<'_> {
ConditionProperty::KeybindDown(key) => self.env.is_keybind_down(key),
ConditionProperty::ViewEntity => owner.is_some_and(|o| o.is_view_entity),
ConditionProperty::Component(predicate) => {
self.env.component_matches(self.stack, predicate)
component_predicate::matches(predicate, self.components, self.env)
.unwrap_or_else(|| self.env.component_matches(self.stack, predicate))
}
}
}
@@ -0,0 +1,177 @@
//! Component conditions used by enchantment-dependent item models.
use super::*;
/// `DataComponentPredicate.singleCodec`: a single predicate's type discriminator
/// (`predicate`) and its wrapped codec's separate `value` field.
pub(super) fn decode(m: &Map<String, Value>) -> Result<Value> {
let id = component_type(required(m, "predicate")?)?;
let mut value = required(m, "value")?.clone();
if matches!(
id.as_str(),
"minecraft:enchantments" | "minecraft:stored_enchantments"
) {
let mut entries = Vec::new();
for entry in list(&value)? {
let mut entry = object(entry)?.clone();
if let Some(selector) = get(&entry, "enchantments") {
let selector = match selector {
Value::Array(ids) => Value::Array(
ids.iter()
.map(ident)
.map(|r| r.map(Value::String))
.collect::<Result<_>>()?,
),
Value::String(name) if name.starts_with('#') => {
Value::String(format!("#{}", identifier(&name[1..])?))
}
_ => Value::String(ident(selector)?),
};
entry.insert("enchantments".into(), selector);
}
if let Some(levels) = get(&entry, "levels") {
let levels = if levels.is_object() {
let mut bounds = Map::new();
for key in ["min", "max"] {
if let Some(n) = get(object(levels)?, key) {
bounds.insert(key.into(), Value::from(int(n)?));
}
}
// MinMaxBounds.Bounds.validateSwappedBoundsInCodec.
if bounds
.get("min")
.and_then(Value::as_i64)
.zip(bounds.get("max").and_then(Value::as_i64))
.is_some_and(|(min, max)| min > max)
{
return Err(err("minimum exceeds maximum"));
}
Value::Object(bounds)
} else {
Value::from(int(levels)?)
};
entry.insert("levels".into(), levels);
}
entries.push(Value::Object(entry));
}
value = Value::Array(entries);
}
Ok(Value::Object(Map::from_iter([(id, value)])))
}
/// `EnchantmentsPredicate.matches` and `EnchantmentPredicate.containedIn`:
/// all entries must match; each entry needs one selected, present enchantment
/// with a matching level. An empty predicate list still needs the component.
pub(super) fn matches(
predicate: &Value,
components: &Components,
env: &dyn ItemEnv,
) -> Option<bool> {
let (id, expected) = predicate.as_object()?.iter().next()?;
if !matches!(
id.as_str(),
"minecraft:enchantments" | "minecraft:stored_enchantments"
) {
return None;
}
let Some(have) = components.get(id).and_then(Value::as_object) else {
return Some(false);
};
Some(expected.as_array()?.iter().all(|entry| {
let Some(entry) = entry.as_object() else {
return false;
};
have.iter().any(|(id, level)| {
let level = level.as_i64().unwrap_or(0);
let selector = get(entry, "enchantments");
let selected = match selector {
None => true,
Some(Value::String(s)) => s
.strip_prefix('#')
.map_or(s == id, |tag| env.enchantment_in_tag(id, tag)),
Some(Value::Array(ids)) => ids.iter().any(|s| s.as_str() == Some(id)),
_ => false,
};
selected
&& if selector.is_some() {
matches_enchantment(level, get(entry, "levels"))
} else {
level_matches(level, get(entry, "levels"))
}
})
}))
}
/// `EnchantmentPredicate.matchesEnchantment`: a named enchantment needs a
/// nonzero level even when its bounds accept zero.
fn matches_enchantment(level: i64, bounds: Option<&Value>) -> bool {
level != 0 && level_matches(level, bounds)
}
/// `MinMaxBounds.Ints.matches`: inclusive bounds, or an exact integer.
fn level_matches(level: i64, bounds: Option<&Value>) -> bool {
match bounds {
None => true,
Some(Value::Number(n)) => n.as_i64() == Some(level),
Some(Value::Object(bounds)) => {
get(bounds, "min")
.and_then(Value::as_i64)
.is_none_or(|min| level >= min)
&& get(bounds, "max")
.and_then(Value::as_i64)
.is_none_or(|max| level <= max)
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
struct Tags;
impl ItemEnv for Tags {
fn enchantment_in_tag(&self, enchantment: &str, tag: &str) -> bool {
enchantment == "minecraft:density" && tag == "horizon:mace"
}
}
#[test]
fn unnamed_predicates_inspect_entries_but_named_ones_require_a_nonzero_level() {
let components = Components::from_iter([(
"minecraft:enchantments".into(),
json!({"minecraft:density":0}),
)]);
assert_eq!(
matches(&json!({"minecraft:enchantments":[{}]}), &components, &NoEnv),
Some(true)
);
assert_eq!(
matches(
&json!({"minecraft:enchantments":[{"enchantments":"minecraft:density"}]}),
&components,
&NoEnv
),
Some(false)
);
}
#[test]
fn enchantment_selectors_use_the_hosts_registry_tags() {
let condition = json!({"predicate":"enchantments", "value":[
{"enchantments":"#horizon:mace", "levels":{"min":3}}
]});
let predicate = decode(condition.as_object().unwrap()).unwrap();
let mut components = Components::from_iter([(
"minecraft:enchantments".into(),
json!({"minecraft:density":3}),
)]);
assert_eq!(matches(&predicate, &components, &Tags), Some(true));
assert_eq!(matches(&predicate, &components, &NoEnv), Some(false));
components.insert(
"minecraft:enchantments".into(),
json!({"minecraft:density":2}),
);
assert_eq!(matches(&predicate, &components, &Tags), Some(false));
}
}
+216
View File
@@ -335,6 +335,177 @@ pub fn quaternion_from_axis_angle(angle: f32, axis: [f32; 3]) -> [f32; 4] {
[axis[0] * sin, axis[1] * sin, axis[2] * sin, cos]
}
/// A JOML `Matrix3f`: `m[column][row]`.
type Mat3 = [[f32; 3]; 3];
/// `Matrix3f.mul`: `a * b`.
fn mul3(a: &Mat3, b: &Mat3) -> Mat3 {
std::array::from_fn(|c| {
std::array::from_fn(|r| fma(a[0][r], b[c][0], fma(a[1][r], b[c][1], a[2][r] * b[c][2])))
})
}
/// `Matrix3f.transpose`.
fn transpose3(m: &Mat3) -> Mat3 {
std::array::from_fn(|c| std::array::from_fn(|r| m[r][c]))
}
/// `Matrix3f.rotate(Quaternionfc)`: `m * R(q)`, without fma.
fn rotate3(m: &Mat3, q: [f32; 4]) -> Mat3 {
let [x, y, z, w] = q;
let (w2, x2, y2, z2) = (w * w, x * x, y * y, z * z);
let (zw, xy, xz, yw, yz, xw) = (z * w, x * y, x * z, y * w, y * z, x * w);
let (zw, xy, xz, yw, yz, xw) = (zw + zw, xy + xy, xz + xz, yw + yw, yz + yz, xw + xw);
let rotation = [
[w2 + x2 - z2 - y2, xy + zw, xz - yw],
[xy - zw, y2 - z2 + w2 - x2, yz + xw],
[yw + xz, yz - xw, z2 - y2 - x2 + w2],
];
std::array::from_fn(|c| {
let rc = rotation[c];
std::array::from_fn(|r| m[0][r] * rc[0] + m[1][r] * rc[1] + m[2][r] * rc[2])
})
}
/// `Quaternionf.mul`: `a * b`.
fn quaternion_mul(a: [f32; 4], b: [f32; 4]) -> [f32; 4] {
let ([x, y, z, w], [qx, qy, qz, qw]) = (a, b);
[
fma(w, qx, fma(x, qw, fma(y, qz, -z * qy))),
fma(w, qy, fma(-x, qz, fma(y, qw, z * qx))),
fma(w, qz, fma(x, qy, fma(-y, qx, z * qw))),
fma(w, qw, fma(-x, qx, fma(-y, qy, -z * qz))),
]
}
/// `GivensParameters`: the half angle's sine and cosine.
#[derive(Clone, Copy)]
struct Givens(f32, f32);
impl Givens {
/// `GivensParameters.fromUnnormalized`.
fn unnormalized(sin_half: f32, cos_half: f32) -> Self {
let inv = 1.0 / (sin_half * sin_half + cos_half * cos_half).sqrt();
Self(inv * sin_half, inv * cos_half)
}
/// `MatrixUtil.PI_4`: `fromPositiveAngle(π / 4)`.
fn quarter_pi() -> Self {
let half = std::f32::consts::FRAC_PI_4 / 2.0;
let sin = joml_sin(half);
Self(sin, cos_from_sin(sin, half))
}
fn inverse(self) -> Self {
Self(-self.0, self.1)
}
fn cos(self) -> f32 {
self.1 * self.1 - self.0 * self.0
}
fn sin(self) -> f32 {
2.0 * self.0 * self.1
}
/// `aroundX/Y/Z(Quaternionf)` (axis 0, 1, 2).
fn quaternion(self, axis: usize) -> [f32; 4] {
let mut q = [0.0, 0.0, 0.0, self.1];
q[axis] = self.0;
q
}
/// `aroundX/Y/Z(Matrix3f)` (axis 0, 1, 2).
fn matrix(self, axis: usize) -> Mat3 {
let (cos, sin) = (self.cos(), self.sin());
let (a, b) = ((axis + 1) % 3, (axis + 2) % 3);
let mut m = [[0.0; 3]; 3];
m[axis][axis] = 1.0;
m[a][a] = cos;
m[b][b] = cos;
m[a][b] = sin;
m[b][a] = -sin;
m
}
}
/// `MatrixUtil.approxGivensQuat`.
fn approx_givens(a11: f32, a12: f32, a22: f32) -> Givens {
let g = 3.0 + 2.0 * 2.0f32.sqrt();
let (cos_half, sin_half) = (2.0 * (a11 - a22), a12);
if g * sin_half * sin_half < cos_half * cos_half {
Givens::unnormalized(sin_half, cos_half)
} else {
Givens::quarter_pi()
}
}
/// `MatrixUtil.qrGivensQuat`.
fn qr_givens(a1: f32, a2: f32) -> Givens {
let p = f64::from(a1).hypot(f64::from(a2)) as f32;
let mut sin_half = if p > 1.0e-6 { a2 } else { 0.0 };
let mut cos_half = a1.abs() + if p > 1.0e-6 { p } else { 1.0e-6 };
if a1 < 0.0 {
std::mem::swap(&mut sin_half, &mut cos_half);
}
Givens::unnormalized(sin_half, cos_half)
}
/// `MatrixUtil.stepJacobi`: the xy, xz then yz pairs, each by
/// `similarityTransform` (`R^T * m * R`).
fn step_jacobi(m: &mut Mat3, q: &mut [f32; 4]) {
let mut rotate = |m: &mut Mat3, g: Givens, axis: usize| {
*q = quaternion_mul(*q, g.quaternion(axis));
let r = g.matrix(axis);
*m = mul3(&transpose3(&r), &mul3(m, &r));
};
if m[0][1] * m[0][1] + m[1][0] * m[1][0] > 1.0e-6 {
let g = approx_givens(m[0][0], 0.5 * (m[0][1] + m[1][0]), m[1][1]);
rotate(m, g, 2);
}
if m[0][2] * m[0][2] + m[2][0] * m[2][0] > 1.0e-6 {
let g = approx_givens(m[0][0], 0.5 * (m[0][2] + m[2][0]), m[2][2]).inverse();
rotate(m, g, 1);
}
if m[1][2] * m[1][2] + m[2][1] * m[2][1] > 1.0e-6 {
let g = approx_givens(m[1][1], 0.5 * (m[1][2] + m[2][1]), m[2][2]);
rotate(m, g, 0);
}
}
/// `MatrixUtil.svdDecompose(Matrix4fc, ...)`, how `new Transformation(matrix)`
/// splits a matrix: translation, left rotation, scale, right rotation. The
/// right rotation diagonalizes `a^T * a` (`eigenvalueJacobi`, 5 steps), then
/// QR Givens rotations about z, y and x bring `a * R(right)` to the scale.
pub fn svd_decompose(m: &Mat4) -> ([f32; 3], [f32; 4], [f32; 3], [f32; 4]) {
let inv = 1.0 / m.0[3][3];
let a: Mat3 = std::array::from_fn(|c| std::array::from_fn(|r| m.0[c][r] * inv));
let mut b = mul3(&transpose3(&a), &a);
let mut right = [0.0, 0.0, 0.0, 1.0];
for _ in 0..5 {
step_jacobi(&mut b, &mut right);
}
right = normalize_quaternion(right);
let (x_small, y_small) = (f64::from(b[0][0]) < 1.0e-6, f64::from(b[1][1]) < 1.0e-6);
let mut left = [0.0, 0.0, 0.0, 1.0];
let mut r = rotate3(&a, right);
let mut qr = |r: &mut Mat3, g: Givens, axis: usize| {
left = quaternion_mul(left, g.quaternion(axis));
*r = mul3(&transpose3(&g.matrix(axis)), r);
};
let g = if x_small {
qr_givens(r[1][1], -r[1][0])
} else {
qr_givens(r[0][0], r[0][1])
};
qr(&mut r, g, 2);
let g = if x_small {
qr_givens(r[2][2], -r[2][0])
} else {
qr_givens(r[0][0], r[0][2])
};
qr(&mut r, g.inverse(), 1);
let g = if y_small {
qr_givens(r[2][2], -r[2][1])
} else {
qr_givens(r[1][1], r[1][2])
};
qr(&mut r, g, 0);
let scale = [r[0][0], r[1][1], r[2][2]];
let translation = std::array::from_fn(|i| m.0[3][i] * inv);
let [x, y, z, w] = right;
(translation, left, scale, [-x, -y, -z, w])
}
#[cfg(test)]
mod tests {
use super::*;
@@ -355,6 +526,51 @@ mod tests {
assert!((m.0[0][1] - 1.0).abs() < 1e-6 && m.0[0][0].abs() < 1e-6);
}
#[test]
fn svd_decomposes_like_matrix_util() {
let bits = |v: &[f32]| v.iter().map(|f| f.to_bits()).collect::<Vec<_>>();
let parts = |m: [f32; 16]| {
let (t, l, s, r) = svd_decompose(&Mat4::from_row_major(m));
[bits(&t), bits(&l), bits(&s), bits(&r)]
};
// Values from MatrixUtil.svdDecompose on the 26.3 jar.
let scaled = [
2.0, 0.0, 0.0, 1.0, 0.0, 3.0, 0.0, -2.0, 0.0, 0.0, 0.5, 0.25, 0.0, 0.0, 0.0, 1.0,
];
assert_eq!(
parts(scaled),
[
vec![0x3f80_0000u32, 0xc000_0000, 0x3e80_0000],
vec![0u32, 0, 0, 0x3f80_0000],
vec![0x4000_0000u32, 0x4040_0000, 0x3f00_0000],
vec![0x8000_0000u32, 0x8000_0000, 0x8000_0000, 0x3f80_0000],
]
);
let quarter_turn = [
0.0, -1.0, 0.0, 0.5, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
];
assert_eq!(
parts(quarter_turn),
[
vec![0x3f00_0000u32, 0, 0],
vec![0u32, 0, 0x3f35_04f3, 0x3f35_04f3],
vec![0x3f7f_ffffu32, 0x3f7f_ffff, 0x3f80_0000],
vec![0x8000_0000u32, 0x8000_0000, 0x8000_0000, 0x3f80_0000],
]
);
let sheared = [
1.0, 0.5, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.25, 0.0, 2.0, 3.0, 0.0, 0.0, 0.0, 2.0,
];
assert_eq!(
parts(sheared),
[
vec![0u32, 0, 0x3fc0_0000],
vec![0x3c03_7010u32, 0x3d38_d722, 0x3ea8_ef95, 0x3f71_60d9],
vec![0x3f23_2630u32, 0x3ec6_bdde, 0x3f81_5b1f],
vec![0xbcc5_7e11u32, 0xbda8_1e36, 0xbee2_916d, 0x3f64_860a],
]
);
}
#[test]
fn half_turn_has_exact_zeros_like_joml() {
let half_turn = 180.0 * (std::f64::consts::PI / 180.0) as f32;
assert_eq!(quaternion_xyz(0.0, half_turn, 0.0), [0.0, 1.0, 0.0, 0.0]);
+32 -14
View File
@@ -63,9 +63,12 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
into a writable book), flood-sign (four 384-character lines on the block
below), flood-recipe (recipe book placements)
--flood N packets a tick for the flood behaviours; default 100
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test);
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
in the same tick (a multi-target kill aura, for the anticheat's tests);
the report gives each bot's last health
--attack-after S start attacking S seconds after joining; default 5
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
ignores the player's moves and actions for its first 3 seconds
--no-read stop reading from the server once spawned (a client that lets the
@@ -142,6 +145,7 @@ struct Options {
/// The player to attack, and how long after joining to start.
attack: Option<String>,
attack_after: f64,
no_swing: bool,
flood: usize,
no_read: bool,
no_loaded: bool,
@@ -174,6 +178,7 @@ fn options() -> Result<Options, String> {
explore_speed: 22.0,
attack: None,
attack_after: 5.0,
no_swing: false,
flood: 100,
no_read: false,
no_loaded: false,
@@ -197,6 +202,10 @@ fn options() -> Result<Options, String> {
o.no_read = true;
continue;
}
if arg == "--no-swing" {
o.no_swing = true;
continue;
}
if arg == "--no-loaded" {
o.no_loaded = true;
continue;
@@ -335,8 +344,8 @@ struct Bot {
counters: OnceLock<Arc<Counters>>,
/// The bot stopped reading (--no-read).
deaf: AtomicBool,
/// `--attack`: the target's UUID and, once AddEntity showed it, its entity id.
target: Mutex<(Option<u128>, Option<i32>)>,
/// `--attack`: each target's UUID and, once AddEntity showed it, its entity id.
target: Mutex<Vec<(u128, Option<i32>)>>,
attacks: AtomicU64,
/// The last SetHealth.
health: Mutex<Option<f32>>,
@@ -701,9 +710,10 @@ fn read(
}
cb::ADD_ENTITY => {
if let Ok(play::Clientbound::AddEntity(p)) = play::Clientbound::decode(id, &body) {
let mut target = lock(&bot.target);
if target.0 == Some(p.uuid) {
target.1 = Some(p.id);
for target in lock(&bot.target).iter_mut() {
if target.0 == p.uuid {
target.1 = Some(p.id);
}
}
}
Ok(())
@@ -802,7 +812,12 @@ fn run_bot(
let (reader, writer) = connection.split();
let _ = bot.counters.set(reader.counters.clone());
let writer = Arc::new(Mutex::new(writer));
lock(&bot.target).0 = o.attack.as_deref().map(offline_uuid);
*lock(&bot.target) = o
.attack
.iter()
.flat_map(|names| names.split(','))
.map(|name| (offline_uuid(name), None))
.collect();
bot.online.store(true, Ordering::SeqCst);
{
let (writer, bot) = (writer.clone(), bot.clone());
@@ -1012,13 +1027,16 @@ fn run_bot(
send(body);
}
}
let target = lock(&bot.target).1;
if let Some(id) = target
&& joined.elapsed().as_secs_f64() >= o.attack_after
&& tick % 10 == 0
{
send(play::Serverbound::Attack(play::Attack { id }).encode());
bot.attacks.fetch_add(1, Ordering::Relaxed);
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
for id in targets {
send(play::Serverbound::Attack(play::Attack { id }).encode());
// Minecraft.startAttack swings right after the attack.
if !o.no_swing {
send(play::Serverbound::Punch(Empty).encode());
}
bot.attacks.fetch_add(1, Ordering::Relaxed);
}
}
tick += 1;
let ok = send(
+13
View File
@@ -57,6 +57,9 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/en_us.json" => {
include_str!("../../horizon-client/assets/horizon/lang/en_us.json")
}
"assets/horizon/lang/zh_cn.json" => {
include_str!("../../horizon-client/assets/horizon/lang/zh_cn.json")
}
"assets/horizon/lang/ru_ru.json" => {
include_str!("../../horizon-client/assets/horizon/lang/ru_ru.json")
}
@@ -288,6 +291,16 @@ mod tests {
}
assert_eq!(en.get("horizon.pages.mods"), Some("Mods"));
assert_eq!(ru.get("horizon.pages.mods"), Some("Моды"));
let zh = Language::load(&[&empty], "zh_cn");
assert_eq!(zh.get("horizon.options.controller"), Some("手柄"));
assert_eq!(zh.get("horizon.pages.mods"), Some("模组"));
assert_eq!(zh.get("Preset"), Some("预设"));
assert_eq!(zh.get("%s chunks"), Some("%s 个区块"));
// Not every bundle is complete: the en_us layer is in every map, so a
// missing key falls back to English (`horizon.mods.not_built` is not
// in ru_ru either).
assert_eq!(zh.get("LOD Distance"), Some("LOD 距离"));
assert_eq!(ru.get("horizon.mods.not_built"), Some(" (Not built)"));
let pl = Language::load(&[&empty], "pl_pl");
assert_eq!(pl.get("horizon.options.controller"), Some("Kontroler"));
assert_eq!(pl.get("horizon.options.vibration"), Some("Wibracje"));
+22 -2
View File
@@ -272,10 +272,15 @@ pub fn potion_color(id: Option<&str>) -> u32 {
let Some(def) = id.and_then(potion) else {
return BASE_POTION_COLOR;
};
effects_color(def.effects.iter().map(|(id, _, amp)| (*id, *amp)))
}
/// `PotionContents.getColorOptional` of some effects, `(id, amplifier)`, all visible: the mix of
/// their colours weighted by amplifier + 1, else the base potion colour.
pub fn effects_color<'a>(effects: impl Iterator<Item = (&'a str, i32)>) -> u32 {
let (mut r, mut g, mut b, mut total) = (0u32, 0u32, 0u32, 0u32);
for (e, _, amp) in def.effects {
for (e, amp) in effects {
let Some(e) = effect(e) else { continue };
let w = *amp as u32 + 1;
let w = amp as u32 + 1;
r += w * (e.color >> 16 & 255);
g += w * (e.color >> 8 & 255);
b += w * (e.color & 255);
@@ -301,6 +306,21 @@ mod tests {
assert_eq!(description_id("ruby:curse/dark"), "effect.ruby.curse.dark");
}
/// `PotionContents.getColorOptional`: each visible effect's colour weighted by its
/// amplifier + 1, the channels averaged by integer division; none gives the base colour.
#[test]
fn effects_mix_their_colours_by_amplifier() {
let c = |id: &str| effect(id).unwrap().color;
assert_eq!(effects_color([("poison", 0)].into_iter()), c("poison"));
let (p, s) = (c("poison"), c("speed"));
let mix = |shift: u32| (((p >> shift & 255) + 3 * (s >> shift & 255)) / 4) << shift;
assert_eq!(
effects_color([("poison", 0), ("speed", 2)].into_iter()),
mix(16) | mix(8) | mix(0)
);
assert_eq!(effects_color(std::iter::empty()), BASE_POTION_COLOR);
}
/// The only test that registers: the registry is set once per process.
#[test]
fn mod_effects_follow_vanilla_once_registered() {
+1 -1
View File
@@ -44,7 +44,7 @@ unicode-bidi = "0.3.18"
ureq = { version = "2", default-features = false, features = ["tls"] }
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
winit = "0.30.13"
zip = { version = "2", default-features = false, features = ["deflate"] }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
[target.'cfg(unix)'.dependencies]
rustix = { version = "1", default-features = false, features = ["std", "time"] }
+561 -54
View File
@@ -39,6 +39,12 @@ struct Channel {
looping: bool,
at: [f32; 3],
relative: bool,
/// Whose sounds these are: 0 the game's, another splitscreen player's (`Audio::share`).
owner: u8,
/// Its world (`Mixer::world_id`; 0 none).
world: u32,
/// The sound file it started and when, to tell another player's copy of it.
started: Option<(String, std::time::Instant)>,
}
#[derive(Default)]
struct Mixer {
@@ -46,6 +52,15 @@ struct Mixer {
listener: [f64; 3],
listener_yaw: f32,
listener_pitch: f32,
/// Splitscreen: the other players' cameras (position, yaw, pitch) by owner. A world sound is
/// heard from the nearest camera, and panned half as wide while several share the speakers.
listeners: HashMap<u8, ([f64; 3], f32, f32)>,
/// Each camera's world by owner, as ids: the callback compares and copies integers.
worlds: HashMap<u8, u32>,
/// World names by id - 1 (`world_id`).
world_names: Vec<String>,
/// The callback's cameras (position, basis, world), kept to be refilled without allocating.
cameras: Vec<([f64; 3], Basis, u32)>,
limiter_peak: f32,
/// The output device the stream was opened on (`Library.currentDeviceName`).
#[cfg(feature = "audio")]
@@ -54,6 +69,21 @@ struct Mixer {
#[cfg(feature = "audio")]
device_lost: bool,
}
/// A camera's forward, up and right vectors.
type Basis = ([f64; 3], [f64; 3], [f64; 3]);
impl Mixer {
/// The id of world `name` (1 and up), given once per name.
fn world_id(&mut self, name: &str) -> u32 {
let i = match self.world_names.iter().position(|w| w == name) {
Some(i) => i,
None => {
self.world_names.push(name.to_owned());
self.world_names.len() - 1
}
};
i as u32 + 1
}
}
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
/// what the subtitle overlay needs.
#[derive(Clone, Debug, PartialEq)]
@@ -75,7 +105,34 @@ pub struct MixerBackend {
decoder: Option<std::sync::mpsc::Sender<Decode>>,
next: ChannelId,
live: bool,
/// Whose channels these are (`Channel::owner`).
owner: u8,
world: u32,
/// How many other players share this output: alone, a sound never looks for its copy.
shares: Arc<std::sync::atomic::AtomicUsize>,
}
/// A splitscreen player's backend leaving: its camera and sounds go with it.
impl Drop for MixerBackend {
fn drop(&mut self) {
if self.owner == 0 {
return;
}
self.shares
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
if let Ok(mut m) = self.mixer.lock() {
m.listeners.remove(&self.owner);
m.worlds.remove(&self.owner);
let owner = self.owner;
m.channels.retain(|_, c| c.owner != owner);
}
}
}
/// Splitscreen: each shared backend's channel ids start at a block of their own, never handed out
/// again, so a player in a seat used before never reuses an id a departed one's decoder may
/// still attach to. The game's own backend counts from 0, as before.
static SHARED_CHANNELS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Two players' copies of one sound: started within this of each other.
const SAME_SOUND: std::time::Duration = std::time::Duration::from_millis(150);
/// Decoded samples and their rate (`None`: the file did not load).
type Samples = Option<(Arc<Vec<f32>>, u32)>;
/// Decoded static sounds (`SoundBufferLibrary`'s buffers), dropped least
@@ -176,6 +233,11 @@ pub struct Audio {
/// `SoundEngine.lastSeenDevices`: the system default output last seen.
#[cfg(feature = "audio")]
default_seen: Option<String>,
/// The ALSA cards (Linux) or Core Audio devices (macOS) last seen: listing
/// the output devices probes every one, so it waits for these to change
/// (`card_fingerprint`).
#[cfg(feature = "audio")]
cards_seen: Option<String>,
/// The other half of `lastSeenDevices`: every output device last seen.
#[cfg(feature = "audio")]
devices_seen: Vec<String>,
@@ -225,7 +287,59 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
}
Some((mono, rate))
}
/// A sound file without its variant number (`mob/creeper/say2.ogg` is `mob/creeper/say`): each
/// player's game picks its own variant of the same event.
// ponytail: variants named by a trailing number, as vanilla's are; the event name if a pack breaks it.
fn variantless(key: &str) -> &str {
key.trim_end_matches(".ogg")
.trim_end_matches(|c: char| c.is_ascii_digit())
}
impl MixerBackend {
/// Splitscreen: another player's game started this same sound here just now (both clients
/// heard it); this copy stays stopped, the first is heard from the nearest camera.
///
/// A copy is recognised only by its file (without the variant), world, place and time: the
/// games pick variants on their own and play many sounds client-side (steps, digging), so
/// nothing marks two copies of one event. Two different events alike in all four (two
/// players' steps on grass, side by side, within 150 ms) are heard once. That is accepted:
/// at that distance and timing they sound like one; a doubled sound is the louder fault.
fn duplicate(&self, channel: ChannelId, (namespace, path): (&str, &str)) -> bool {
// Alone: nobody else's copy, and no lock taken for it.
if self.shares.load(std::sync::atomic::Ordering::Relaxed) == 0 {
return false;
}
let Ok(mut m) = self.mixer.lock() else {
return false;
};
// Alone (no other player's camera), or a sound by the listener (a click, music): its own.
let Some(c) = m.channels.get(&channel).filter(|c| !c.relative) else {
return false;
};
if m.listeners.is_empty() {
return false;
}
let key = format!("{namespace}:{path}");
let key = variantless(&key);
let at = c.at;
let world = c.world;
let twin = m.channels.values().any(|o| {
o.owner != self.owner
&& o.world == world
&& !o.relative
&& o.started
.as_ref()
.is_some_and(|(k, t)| k == key && t.elapsed() < SAME_SOUND)
&& (0..3).map(|i| (o.at[i] - at[i]).powi(2)).sum::<f32>() < 4.0
});
if let Some(c) = m.channels.get_mut(&channel) {
if twin {
c.state = State::Stopped;
} else {
c.started = Some((key.to_owned(), std::time::Instant::now()));
}
}
twin
}
fn with(&self, channel: ChannelId, f: impl FnOnce(&mut Channel)) {
if let Ok(mut m) = self.mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
@@ -250,6 +364,9 @@ impl Backend for MixerBackend {
looping: false,
at: [0.0; 3],
relative: false,
owner: self.owner,
world: self.world,
started: None,
},
);
Some(id)
@@ -287,6 +404,10 @@ impl Backend for MixerBackend {
}
let (namespace, path) = sound.path();
let file = self.root.join("assets").join(namespace).join(&path);
// A looping sound follows its entity in one client's tracking only: each keeps its own.
if !looping && self.duplicate(channel, (namespace, &path)) {
return;
}
if sound.stream {
// A streamed sound loops by restarting its stream.
let mixer = self.mixer.clone();
@@ -395,28 +516,50 @@ fn distance_gain(linear: Option<f32>, distance: f32) -> f32 {
#[cfg_attr(not(feature = "audio"), allow(dead_code))]
fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
data.fill(0.0);
let listener = mixer.listener;
// The camera's forward and up vectors (`Camera.forwardVector`/`upVector`)
// and OpenAL's right = forward x up.
let (yaw, pitch) = (
f64::from(mixer.listener_yaw.to_radians()),
f64::from(mixer.listener_pitch.to_radians()),
let basis = |yaw: f32, pitch: f32| {
let (yaw, pitch) = (f64::from(yaw.to_radians()), f64::from(pitch.to_radians()));
let forward = [
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
];
let up = [
-yaw.sin() * pitch.sin(),
pitch.cos(),
yaw.cos() * pitch.sin(),
];
let right = [
forward[1] * up[2] - forward[2] * up[1],
forward[2] * up[0] - forward[0] * up[2],
forward[0] * up[1] - forward[1] * up[0],
];
(forward, up, right)
};
// Every camera hearing (splitscreen: the other players' too), in the mixer's own buffer.
let mut cameras = std::mem::take(&mut mixer.cameras);
cameras.clear();
cameras.extend(
std::iter::once((
0,
(mixer.listener, mixer.listener_yaw, mixer.listener_pitch),
))
.chain(
mixer
.listeners
.iter()
.map(|(&owner, &camera)| (owner, camera)),
)
.map(|(owner, (at, yaw, pitch))| {
(
at,
basis(yaw, pitch),
mixer.worlds.get(&owner).copied().unwrap_or_default(),
)
}),
);
let forward = [
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
];
let up = [
-yaw.sin() * pitch.sin(),
pitch.cos(),
yaw.cos() * pitch.sin(),
];
let right = [
forward[1] * up[2] - forward[2] * up[1],
forward[2] * up[0] - forward[0] * up[2],
forward[0] * up[1] - forward[1] * up[0],
];
let shared = cameras.len() > 1;
let dot = |a: [f64; 3], b: [f64; 3]| (a[0] * b[0] + a[1] * b[1] + a[2] * b[2]) as f32;
// Gains and pans per source for this buffer.
let mut voices: Vec<(ChannelId, [f32; 3], f64)> = Vec::new();
@@ -427,27 +570,48 @@ fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
if c.state != State::Playing {
continue;
}
let d = if c.relative {
[f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])]
} else {
let at = [f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])];
let away = |listener: [f64; 3]| {
[
f64::from(c.at[0]) - listener[0],
f64::from(c.at[1]) - listener[1],
f64::from(c.at[2]) - listener[2],
at[0] - listener[0],
at[1] - listener[1],
at[2] - listener[2],
]
};
let length = |d: [f64; 3]| d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
// The nearest camera hears it (one camera but in splitscreen).
let camera = cameras
.iter()
.filter(|camera| c.relative || camera.2 == c.world)
.min_by(|a, b| length(away(a.0)).total_cmp(&length(away(b.0))))
.map(|camera| (camera.0, camera.1));
// Unheard sources still advance: returning to a world must not resume stale sounds.
let (listener, (forward, up, right)) = camera.unwrap_or(([0.0; 3], basis(0.0, 0.0)));
let d = if c.relative { at } else { away(listener) };
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
let gain = if camera.is_some() {
c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance)
} else {
0.0
};
// Relative sources are already in OpenAL's listener space (-Z ahead).
let [left, right] = if c.relative {
let [mut left, mut right] = if c.relative {
stereo_gains(d[0] as f32, d[1] as f32, -d[2] as f32)
} else {
stereo_gains(dot(d, right), dot(d, up), dot(d, forward))
};
if shared {
// Several players on the speakers: half the stereo width, as left and right are
// nobody's in particular.
let middle = (left + right) / 2.0;
left = middle + (left - middle) / 2.0;
right = middle + (right - middle) / 2.0;
}
.map(|pan| gain * pan);
let [left, right] = [left, right].map(|pan| gain * pan);
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, [left, right, gain], step));
}
mixer.cameras = cameras;
for (id, [left, right, gain], step) in voices {
let Some(c) = mixer.channels.get_mut(&id) else {
continue;
@@ -538,6 +702,36 @@ fn open_device(
}
Ok((stream, config))
}
/// Try one output device for `open`: skip it when it was already tried, log it
/// when it opens, and keep its error when it does not.
#[cfg(feature = "audio")]
fn try_open_device(
device: &cpal::Device,
mixer: Arc<Mutex<Mixer>>,
tried: &mut Vec<String>,
errors: &mut Vec<String>,
) -> Option<cpal::Stream> {
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
if tried.contains(&name) {
return None;
}
tried.push(name.clone());
match open_device(device, mixer) {
Ok((stream, config)) => {
log::info!(
"Horizon: audio output {name} ({} Hz, {} channels, {})",
config.sample_rate().0,
config.channels(),
config.sample_format()
);
Some(stream)
}
Err(e) => {
errors.push(format!("{name}: {e}"));
None
}
}
}
/// The name of the system's default output device.
#[cfg(feature = "audio")]
fn default_output_name() -> Option<String> {
@@ -545,6 +739,42 @@ fn default_output_name() -> Option<String> {
.default_output_device()
.and_then(|d| d.name().ok())
}
/// A cheap fingerprint of what `output_devices` can list. On Linux, the ALSA
/// cards: the rest are `cpal`'s fixed names. On macOS, every Core Audio
/// device's name, inputs included: telling which of them play opens each one
/// (about 90 ms a second with twenty devices), naming them takes 2 or 3 ms.
/// `None` elsewhere, where the list is always looked at.
#[cfg(feature = "audio")]
fn card_fingerprint() -> Option<String> {
#[cfg(target_os = "linux")]
{
std::fs::read_to_string("/proc/asound/cards").ok()
}
#[cfg(target_os = "macos")]
{
let devices = cpal::default_host().devices().ok()?;
Some(
devices
.filter_map(|d| d.name().ok())
.collect::<Vec<_>>()
.join("\n"),
)
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
None
}
}
/// Whether the output device list should be looked at again: only the ALSA
/// cards can add or remove an entry, and `None` means there is no fingerprint,
/// so the list has to be checked as before.
#[cfg(any(feature = "audio", test))]
fn cards_changed(seen: Option<&str>, cards: Option<&str>) -> bool {
match (seen, cards) {
(Some(seen), Some(cards)) => seen != cards,
_ => true,
}
}
#[cfg(feature = "audio")]
fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
device: &cpal::Device,
@@ -649,6 +879,17 @@ impl Audio {
};
let mut engine = SoundEngine::new(registry);
engine.loaded = live;
#[cfg(feature = "audio")]
let cards = if live { card_fingerprint() } else { None };
// Where the cards gate the list (Linux, macOS), it starts empty: opening the
// output does not need it, and listing it probes every device; the first
// change fills it. Elsewhere it starts with every device listed, as before.
#[cfg(feature = "audio")]
let devices = if live && cards.is_none() {
output_devices()
} else {
Vec::new()
};
Self {
_stream: stream,
device_name: target_device.to_owned(),
@@ -660,6 +901,9 @@ impl Audio {
decoder: None,
next: 0,
live,
owner: 0,
world: 0,
shares: Arc::default(),
},
record_heard: false,
listener: [0.0; 3],
@@ -674,7 +918,9 @@ impl Audio {
#[cfg(feature = "audio")]
default_seen: if live { default_output_name() } else { None },
#[cfg(feature = "audio")]
devices_seen: if live { output_devices() } else { Vec::new() },
cards_seen: cards,
#[cfg(feature = "audio")]
devices_seen: devices,
#[cfg(feature = "audio")]
device_ticks: 0,
}
@@ -741,29 +987,20 @@ impl Audio {
}
let host = cpal::default_host();
let mut errors = Vec::new();
// The default device first, then any other output a host lists:
// a default that cannot open (busy, or a stale ALSA default) should
// not leave the game silent when another device works.
let default = host.default_output_device();
let others = host.output_devices().into_iter().flatten();
// The default device first: it is what plays, and listing the others
// probes every PCM, which is slow and prints ALSA's errors for the
// plugins this system lacks.
let mut tried = Vec::new();
for device in default.into_iter().chain(others) {
let name = device.name().unwrap_or_else(|_| "unnamed device".into());
if tried.contains(&name) {
continue;
}
tried.push(name.clone());
match open_device(&device, mixer.clone()) {
Ok((stream, config)) => {
log::info!(
"Horizon: audio output {name} ({} Hz, {} channels, {})",
config.sample_rate().0,
config.channels(),
config.sample_format()
);
return Ok(stream);
}
Err(e) => errors.push(format!("{name}: {e}")),
if let Some(device) = host.default_output_device()
&& let Some(stream) = try_open_device(&device, mixer.clone(), &mut tried, &mut errors)
{
return Ok(stream);
}
// A default that cannot open (busy, or a stale ALSA default) should not
// leave the game silent when another device works.
for device in host.output_devices().into_iter().flatten() {
if let Some(stream) = try_open_device(&device, mixer.clone(), &mut tried, &mut errors) {
return Ok(stream);
}
}
if errors.is_empty() {
@@ -777,15 +1014,75 @@ impl Audio {
backend: &mut self.backend,
}
}
/// World identity for this player's sources and camera. Existing channels keep their world.
pub fn set_world(&mut self, world: &str) {
if let Ok(mut mixer) = self.backend.mixer.lock() {
let id = mixer.world_id(world);
self.backend.world = id;
mixer.worlds.insert(self.backend.owner, id);
}
}
/// `SoundManager.updateSource`: the camera position and rotation
/// (`Listener.setTransform` takes its forward and up vectors).
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32, pitch: f32) {
self.listener = position;
self.listener_yaw = yaw;
if let Ok(mut m) = self.backend.mixer.lock() {
m.listener = position;
m.listener_yaw = yaw;
m.listener_pitch = pitch;
if self.backend.owner == 0 {
m.listener = position;
m.listener_yaw = yaw;
m.listener_pitch = pitch;
} else {
m.listeners
.insert(self.backend.owner, (position, yaw, pitch));
}
}
}
/// Splitscreen: another player's sounds on this output, heard from its camera too
/// (`owner`: 1 to 255, its own). Dropped, its camera and sounds go.
pub fn share(&self, owner: u8) -> Self {
assert_ne!(owner, 0, "shared audio owners must be nonzero");
self.backend
.shares
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let mut engine = SoundEngine::new(self.engine.registry.clone());
engine.loaded = self.backend.live;
Self {
_stream: None,
device_name: self.device_name.clone(),
engine,
backend: MixerBackend {
mixer: self.backend.mixer.clone(),
root: self.backend.root.clone(),
cache: self.backend.cache.clone(),
decoder: None,
next: (SHARED_CHANNELS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1)
<< 40,
live: self.backend.live,
owner,
world: self.backend.world,
shares: self.backend.shares.clone(),
},
record_heard: false,
listener: [0.0; 3],
listener_yaw: 0.0,
master: self.master,
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
source_override: None,
capture: None,
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
// The device belongs to the first player's output, which looks after it.
#[cfg(feature = "audio")]
default_seen: self.default_seen.clone(),
#[cfg(feature = "audio")]
cards_seen: self.cards_seen.clone(),
#[cfg(feature = "audio")]
devices_seen: self.devices_seen.clone(),
#[cfg(feature = "audio")]
device_ticks: 0,
}
}
/// Sync the volume options, refreshing the categories that changed
@@ -810,6 +1107,11 @@ impl Audio {
/// at once a second.
#[cfg(feature = "audio")]
fn should_change_device(&mut self) -> bool {
// A splitscreen share plays through the first player's output: it leaves the device,
// and the mixer's lost flag, to that output.
if self.backend.owner != 0 {
return false;
}
let (current, lost) = match self.backend.mixer.lock() {
Ok(mut m) => (m.device_name.clone(), std::mem::take(&mut m.device_lost)),
Err(_) => return false,
@@ -817,6 +1119,7 @@ impl Audio {
if lost {
log::info!("Horizon: audio device was lost");
self.default_seen = default_output_name();
self.cards_seen = card_fingerprint();
self.devices_seen = output_devices();
return true;
}
@@ -826,6 +1129,17 @@ impl Audio {
}
self.device_ticks = 0;
let default = default_output_name();
let cards = card_fingerprint();
// Listing the devices probes every one (on Linux every built-in PCM,
// printing ALSA's errors for the plugins this system lacks; on macOS
// every Core Audio device, about 90 ms with twenty of them); only the
// fingerprint's devices can change the list, and it is cheap.
if default == self.default_seen
&& !cards_changed(self.cards_seen.as_deref(), cards.as_deref())
{
return false;
}
self.cards_seen = cards;
let devices = output_devices();
// Only a change in the device lists is looked at, as in vanilla: a
// preferred device that is listed but cannot open is not retried.
@@ -1140,6 +1454,9 @@ mod tests {
looping,
at,
relative: false,
owner: 0,
world: 0,
started: None,
}
}
@@ -1297,6 +1614,185 @@ mod tests {
assert!(close(gains(0.0, 90.0, [0.0, 0.0, 1.0]), [0.5, 0.5]));
}
#[test]
fn splitscreen_sounds_are_heard_from_the_nearest_camera() {
// P1 far away, a second player beside the source: heard at full gain, centred.
let mut m = Mixer {
listener: [100.0, 0.0, 0.0],
..Mixer::default()
};
m.listeners.insert(1, ([0.0, 0.0, 1.0], 0.0, 0.0));
m.channels
.insert(1, channel(vec![1.0; 4], Some(16.0), [0.0, 0.0, 2.0], true));
let mut data = [0.0; 2];
mix(&mut m, &mut data, 2, 48_000);
assert!(
data[0] > 0.5 && (data[0] - data[1]).abs() < 1e-4,
"{data:?}"
);
// Alone, P1 would not hear it: 100 blocks past its 16.
m.listeners.clear();
let mut data = [0.0; 2];
mix(&mut m, &mut data, 2, 48_000);
assert_eq!(data, [0.0; 2]);
}
#[test]
fn a_shared_listener_registers_even_in_the_hosts_world() {
let mut host = Audio::silent(Path::new("/nonexistent/horizon-test-audio"));
host.set_world("minecraft:overworld");
let mut guest = host.share(1);
guest.set_world("minecraft:overworld");
guest.set_listener([1.0, 2.0, 3.0], 0.0, 0.0);
let id = guest.backend.create(false).unwrap();
let mixer = host.backend.mixer.lock().unwrap();
let overworld = mixer
.world_names
.iter()
.position(|w| w == "minecraft:overworld");
let overworld = overworld.map(|i| i as u32 + 1);
assert_eq!(mixer.worlds.get(&1).copied(), overworld);
assert_eq!(Some(mixer.channels[&id].world), overworld);
}
#[test]
fn cameras_in_other_dimensions_cannot_hear_a_source() {
let mut m = Mixer {
listener: [100.0, 0.0, 0.0],
..Default::default()
};
m.listeners.insert(1, ([0.0, 0.0, 1.0], 0.0, 0.0));
let nether = m.world_id("nether");
m.worlds.insert(1, nether);
m.channels
.insert(1, channel(vec![1.0; 4], Some(16.0), [0.0, 0.0, 2.0], true));
let mut data = [0.0; 2];
mix(&mut m, &mut data, 2, 48_000);
assert_eq!(
data, [0.0; 2],
"nearby Nether camera must not hear Overworld source"
);
m.channels.get_mut(&1).unwrap().world = nether;
mix(&mut m, &mut data, 2, 48_000);
assert!(data[0] > 0.5, "same-world camera must hear source");
let end = m.world_id("end");
m.worlds.insert(1, end);
data.fill(0.0);
mix(&mut m, &mut data, 2, 48_000);
assert_eq!(data, [0.0; 2], "no listener remains in source's world");
}
#[test]
fn sounds_in_a_world_without_listeners_finish_while_inaudible() {
let mut mixer = Mixer::default();
let mut source = channel(vec![1.0; 4], None, [0.0; 3], false);
let nether = mixer.world_id("nether");
source.world = nether;
mixer.channels.insert(1, source);
let mut data = [0.0; 4];
mix(&mut mixer, &mut data, 2, 48_000);
assert_eq!(data, [0.0; 4]);
assert!(mixer.channels[&1].position > 0.0);
mix(&mut mixer, &mut data, 2, 48_000);
assert_eq!(mixer.channels[&1].state, State::Stopped);
mixer.worlds.insert(0, nether);
mix(&mut mixer, &mut data, 2, 48_000);
assert_eq!(data, [0.0; 4], "returning must not resume the old one-shot");
}
#[test]
fn variants_of_one_sound_are_the_same_sound() {
assert_eq!(
variantless("minecraft:mob/creeper/say1.ogg"),
"minecraft:mob/creeper/say"
);
assert_eq!(
variantless("minecraft:step/grass6.ogg"),
"minecraft:step/grass"
);
assert_eq!(
variantless("minecraft:random/click.ogg"),
"minecraft:random/click"
);
}
#[test]
fn another_players_copy_of_a_sound_is_dropped() {
let mixer = Arc::new(Mutex::new(Mixer::default()));
// One other player shares the output.
let shares = Arc::new(std::sync::atomic::AtomicUsize::new(1));
let backend = |owner| MixerBackend {
mixer: mixer.clone(),
root: PathBuf::new(),
cache: Arc::default(),
decoder: None,
next: (u64::from(owner)) << 40,
live: true,
owner,
world: 0,
shares: shares.clone(),
};
let (mut p1, mut p2) = (backend(0), backend(1));
// P2 is listening (its camera is in the mixer), so two players share the speakers.
mixer
.lock()
.unwrap()
.listeners
.insert(1, ([0.0; 3], 0.0, 0.0));
let started = |b: &mut MixerBackend, at: [f64; 3]| {
let id = b.create(false).unwrap();
b.set_position(id, at);
!b.duplicate(id, ("minecraft", "mob/creeper/say1.ogg"))
};
assert!(started(&mut p1, [5.0, 64.0, 5.0]));
// P2's client got the same sound at the same place: one copy plays.
assert!(!started(&mut p2, [5.2, 64.0, 5.0]));
// Elsewhere, or P1's own second one, it is a sound of its own.
assert!(started(&mut p2, [40.0, 64.0, 5.0]));
assert!(started(&mut p1, [5.0, 64.0, 5.0]));
// Equal coordinates in another dimension describe a different sound.
p2.world = mixer.lock().unwrap().world_id("nether");
assert!(started(&mut p2, [5.0, 64.0, 5.0]));
// P2 leaving takes its sounds.
drop(p2);
assert!(
mixer
.lock()
.unwrap()
.channels
.values()
.all(|c| c.owner == 0)
);
}
#[test]
fn reusing_a_seat_does_not_reuse_its_channels_or_leave_its_camera() {
let host = Audio::silent(Path::new("/nonexistent/horizon-test-audio"));
let mut guest = host.share(1);
guest.set_world("overworld");
guest.set_listener([1.0, 0.0, 0.0], 0.0, 0.0);
let old = guest.backend.create(false).unwrap();
drop(guest);
{
let mixer = host.backend.mixer.lock().unwrap();
assert!(!mixer.channels.contains_key(&old));
assert!(!mixer.listeners.contains_key(&1));
assert!(!mixer.worlds.contains_key(&1));
}
let mut replacement = host.share(1);
let new = replacement.backend.create(false).unwrap();
assert_ne!(old, new);
assert!(
replacement
.backend
.mixer
.lock()
.unwrap()
.channels
.contains_key(&new)
);
}
#[test]
fn a_mono_output_plays_sources_unpanned() {
let mut m = Mixer::default();
@@ -1306,4 +1802,15 @@ mod tests {
mix(&mut m, &mut data, 1, 48_000);
assert_eq!(data, [0.5; 2]);
}
#[test]
fn output_devices_are_looked_at_again_only_when_the_cards_change() {
// No fingerprint (not Linux, or the cards could not be read): the list
// has to be looked at as before.
assert!(cards_changed(None, None));
assert!(cards_changed(Some("0 [PCH ]"), None));
// The same cards list the same devices; a card added or removed does not.
assert!(!cards_changed(Some("0 [PCH ]"), Some("0 [PCH ]")));
assert!(cards_changed(Some("0 [PCH ]"), Some("0 [PCH ]\n 1 [USB ]")));
}
}
+19
View File
@@ -272,6 +272,25 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
}
}
const MINECRAFT_PROFILE: &str = "https://api.minecraftservices.com/minecraft/profile";
/// The signed-in account's active skin and cape from `GET /minecraft/profile`.
/// Single-player and integrated servers often send no `textures` property, or
/// one without the equipped cape; the client asks the profile endpoint once.
pub fn own_textures(session: &AuthSession) -> Option<crate::skins::Textures> {
let response = agent()
.get(MINECRAFT_PROFILE)
.set("Authorization", &format!("Bearer {}", session.access_token))
.call()
.ok()?;
if response.status() != 200 {
return None;
}
let body = response.into_string().ok()?;
let profile: serde_json::Value = serde_json::from_str(&body).ok()?;
Some(crate::skins::services_profile_textures(&profile))
}
/// What the session service says about a player joining this server
/// (MinecraftServicesSessionService.hasJoinedServer).
#[derive(Clone, Debug, PartialEq)]
@@ -1118,9 +1118,10 @@ impl BlockEntityRenderer {
}
/// `submitCrumblingOverlay` of ChestRenderer.submit, BannerRenderer.submit,
/// SkullBlockRenderer.submitSkull, ShulkerBoxRenderer.submit,
/// BellRenderer.submit, LecternRenderer.submit (the book) and
/// CopperGolemStatueBlockRenderer.submit: the model drawn again under the
/// destroy stage.
/// BellRenderer.submit, LecternRenderer.submit (the book),
/// CopperGolemStatueBlockRenderer.submit, ConduitRenderer.submit (the shell
/// or cage) and EnchantTableRenderer.submit (the book): the model drawn
/// again under the destroy stage.
pub fn render_breaking(
&self,
scene: &Scene,
@@ -1138,6 +1139,8 @@ impl BlockEntityRenderer {
| Kind::Bell
| Kind::Lectern { .. }
| Kind::CopperGolem { .. }
| Kind::Conduit
| Kind::EnchantingTable
) {
return;
}
@@ -1683,6 +1686,10 @@ impl BlockEntityRenderer {
* Mat4::from_quat(Quat::from_axis_angle(axis, degrees * (PI / 180.0)));
draw_part(model, "root", m, &none, paint, out);
}
// The crumbling overlay covers the shell and the cage only.
if crumbling {
return;
}
let wind = if phase == 1 {
"entity/conduit/wind_vertical"
} else {
+8
View File
@@ -210,6 +210,14 @@ impl Tag {
text: Text::translate("chat.tag.not_secure", &[]),
}
}
/// `GuiMessageTag.chatError`: red (`CHAT_ERROR`, 16733525), the tooltip `chat.tag.error`.
pub fn chat_error() -> Self {
Self {
color: 0xFF_5555,
icon: false,
text: Text::translate("chat.tag.error", &[]),
}
}
/// `GuiMessageTag.chatModified`: the tooltip adds the original content in grey.
pub fn modified(original_content: &str) -> Self {
Self {
+260 -3
View File
@@ -1,7 +1,13 @@
//! Resident chunk membership, with a token for consumers that cache its keys.
use crate::{chunk::Chunk, fast_hash::FxHashMap};
use crate::{
chunk::Chunk,
fast_hash::{FxHashMap, FxHashSet},
};
use std::{
collections::hash_map::{Iter, ValuesMut},
collections::{
VecDeque,
hash_map::{Iter, ValuesMut},
},
ops::Deref,
sync::{
Arc,
@@ -16,18 +22,46 @@ fn next_revision() -> u64 {
NEXT_REVISION.fetch_add(1, Ordering::Relaxed)
}
/// The membership changes one map keeps for the consumers that read them
/// every tick (`TickChunkMembership.synchronize`); older ones are dropped.
const MAX_RECENT_DELTAS: usize = 256;
/// One membership change of one `ChunkMap`, stamped with the revision it
/// brought the map to.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChunkMembershipDelta {
Added(Key),
Removed(Key),
Reset,
}
/// Read access behaves like a map. Structural writes are explicit so edits to
/// blocks or light don't invalidate consumers of the loaded chunk key set.
/// Tokens are process-wide: replacing the entire map also invalidates caches.
pub struct ChunkMap {
columns: Columns,
revision: u64,
/// The oldest revision `recent_deltas` still answers for: the revision of
/// the change it last dropped, or the revision that cleared them. A
/// consumer holding an older one rebuilds, it does not replay.
min_valid_revision: u64,
/// This map's recent membership changes, in revision order.
/// Revisions are process-wide: the numbers missing in between belong to
/// the other maps (a server thread's, another dimension's), which say
/// nothing about this one's keys.
recent_deltas: VecDeque<(u64, ChunkMembershipDelta)>,
/// The resident chunks generation has not placed its animals in yet.
fresh: FxHashSet<Key>,
}
impl Default for ChunkMap {
fn default() -> Self {
let revision = next_revision();
Self {
columns: Columns::default(),
revision: next_revision(),
revision,
min_valid_revision: revision,
recent_deltas: VecDeque::new(),
fresh: FxHashSet::default(),
}
}
}
@@ -35,26 +69,107 @@ impl ChunkMap {
pub fn membership_revision(&self) -> u64 {
self.revision
}
fn record_delta(&mut self, delta: ChunkMembershipDelta) {
if self.recent_deltas.len() >= MAX_RECENT_DELTAS
&& let Some((evicted, _)) = self.recent_deltas.pop_front()
{
self.min_valid_revision = evicted;
}
self.recent_deltas.push_back((self.revision, delta));
}
/// This map's membership changes since `last_revision`, one of its own
/// earlier `membership_revision`s, or `None` when they can no longer be
/// answered: the consumer's revision predates what the ring kept, the map
/// was cleared since (a reset rebuilds everything), or the revision is not
/// this map's, which its own revisions give away: every one of them in the
/// answered range is the opening revision or a change the ring still
/// holds, so a revision another map took in between is refused, not
/// replayed.
pub fn deltas_since(
&self,
last_revision: u64,
) -> Option<impl Iterator<Item = (u64, ChunkMembershipDelta)> + '_> {
if last_revision == 0
|| last_revision < self.min_valid_revision
|| last_revision > self.revision
|| self.recent_deltas.is_empty()
{
return None;
}
let pos = self
.recent_deltas
.partition_point(|(rev, _)| *rev <= last_revision);
// A revision this map never took: another map's, drawing on the same
// process-wide counter. Only the opening revision and the ones the
// ring still holds are this map's.
let is_own_revision = last_revision == self.min_valid_revision
|| (pos > 0 && self.recent_deltas[pos - 1].0 == last_revision);
if !is_own_revision {
return None;
}
let slice = self.recent_deltas.range(pos..);
if slice
.clone()
.any(|(_, d)| *d == ChunkMembershipDelta::Reset)
{
return None;
}
Some(slice.copied())
}
pub fn insert(&mut self, key: Key, chunk: Arc<Chunk>) -> Option<Arc<Chunk>> {
if chunk.fresh {
self.fresh.insert(key);
} else {
self.fresh.remove(&key);
}
let previous = self.columns.insert(key, chunk);
if previous.is_none() {
self.revision = next_revision();
self.record_delta(ChunkMembershipDelta::Added(key));
}
previous
}
pub fn remove(&mut self, key: &Key) -> Option<Arc<Chunk>> {
self.fresh.remove(key);
let previous = self.columns.remove(key);
if previous.is_some() {
self.revision = next_revision();
self.record_delta(ChunkMembershipDelta::Removed(*key));
}
previous
}
pub fn clear(&mut self) {
if !self.columns.is_empty() {
self.columns.clear();
self.fresh.clear();
self.revision = next_revision();
self.recent_deltas.clear();
self.recent_deltas
.push_back((self.revision, ChunkMembershipDelta::Reset));
self.min_valid_revision = self.revision;
}
}
pub fn has_fresh(&self) -> bool {
!self.fresh.is_empty()
}
/// The fresh keys in the chunk map's own iteration order, so the spawns
/// they drive run in the order a scan of the columns gave.
pub fn fresh_keys_ordered(&self) -> Vec<Key> {
if self.fresh.is_empty() {
Vec::new()
} else if self.fresh.len() == 1 {
self.fresh.iter().copied().collect()
} else {
self.columns
.keys()
.filter(|k| self.fresh.contains(k))
.copied()
.collect()
}
}
pub fn remove_fresh(&mut self, key: &Key) {
self.fresh.remove(key);
}
pub fn get_mut(&mut self, key: &Key) -> Option<&mut Arc<Chunk>> {
self.columns.get_mut(key)
}
@@ -125,4 +240,146 @@ mod tests {
map = ChunkMap::default();
assert_ne!(cleared, map.membership_revision());
}
/// The deltas' kinds, checking the ring answers in revision order.
fn kinds(deltas: Vec<(u64, ChunkMembershipDelta)>) -> Vec<ChunkMembershipDelta> {
let mut last = 0;
for (rev, _) in &deltas {
assert!(*rev > last, "the ring answers in revision order");
last = *rev;
}
deltas.into_iter().map(|(_, d)| d).collect()
}
/// Revision numbers are process-wide: the ones another map takes in
/// between, and a payload-only rewrite that takes none at all, say nothing
/// about this map's keys.
#[test]
fn deltas_since_ignores_revisions_other_maps_take() {
let mut map = ChunkMap::default();
let mut other = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
map.insert((0, 0), chunk.clone());
let seen = map.membership_revision();
for key in [(1, 0), (2, 0), (3, 0)] {
other.insert(key, chunk.clone());
}
map.insert((0, 0), Arc::new(Chunk::empty(0, 0))); // Payload only.
map.insert((4, 0), chunk.clone());
map.remove(&(0, 0));
assert_eq!(
kinds(map.deltas_since(seen).unwrap().collect()),
[
ChunkMembershipDelta::Added((4, 0)),
ChunkMembershipDelta::Removed((0, 0)),
]
);
// The consumer's own revision answers, with nothing new since.
assert!(
map.deltas_since(map.membership_revision())
.unwrap()
.next()
.is_none()
);
// A revision another map took in between: refused, not replayed.
assert!(map.deltas_since(other.membership_revision()).is_none());
}
/// A map built ahead of the live one, its revisions interleaved with it,
/// answering for a revision it never took: refused, so the consumer
/// rebuilds rather than replay another map's changes.
#[test]
fn deltas_since_none_for_interleaved_revision_from_another_map() {
let mut map = ChunkMap::default();
let mut other = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
map.insert((0, 0), chunk.clone());
let seen = map.membership_revision();
other.insert((1, 0), chunk.clone());
let foreign = other.membership_revision();
map.insert((2, 0), chunk.clone());
assert!(map.deltas_since(foreign).is_none());
assert_eq!(
kinds(map.deltas_since(seen).unwrap().collect()),
[ChunkMembershipDelta::Added((2, 0))]
);
}
/// The ring answers for the changes it kept, and only those.
#[test]
fn deltas_since_none_for_a_revision_the_ring_dropped() {
let mut map = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
let created = map.membership_revision();
let mut revisions = Vec::new();
let changes = MAX_RECENT_DELTAS as i32 + 1;
for i in 0..changes {
map.insert((i, 0), chunk.clone());
revisions.push(map.membership_revision());
}
let (first, kept) = revisions.split_first().unwrap();
// The oldest change fell out with the consumer's revision: rebuild.
assert!(map.deltas_since(created).is_none());
assert_eq!(map.deltas_since(*first).unwrap().count(), kept.len());
}
/// A reset rebuilds whatever the consumer last saw, and the ring answers
/// for what follows it.
#[test]
fn deltas_since_none_across_a_clear() {
let mut map = ChunkMap::default();
let chunk = Arc::new(Chunk::empty(0, 0));
map.insert((0, 0), chunk.clone());
let before = map.membership_revision();
map.clear();
assert!(map.deltas_since(before).is_none());
let cleared = map.membership_revision();
map.insert((1, 0), chunk);
assert_eq!(
kinds(map.deltas_since(cleared).unwrap().collect()),
[ChunkMembershipDelta::Added((1, 0))]
);
}
#[test]
fn fresh_chunks_tracking() {
let mut map = ChunkMap::default();
assert!(!map.has_fresh());
let mut chunk = Chunk::empty(1, 2);
chunk.fresh = true;
map.insert((1, 2), Arc::new(chunk));
assert!(map.has_fresh());
assert_eq!(map.fresh_keys_ordered(), vec![(1, 2)]);
map.remove_fresh(&(1, 2));
assert!(!map.has_fresh());
let mut chunk2 = Chunk::empty(3, 4);
chunk2.fresh = true;
map.insert((3, 4), Arc::new(chunk2));
assert!(map.has_fresh());
map.remove(&(3, 4));
assert!(!map.has_fresh());
}
#[test]
fn fresh_keys_ordered_matches_chunk_map_iteration_order() {
let mut map = ChunkMap::default();
let keys = [(-5, 10), (12, -3), (0, 0), (7, 8), (-1, -1), (4, 4)];
for (i, &key) in keys.iter().enumerate() {
let mut chunk = Chunk::empty(key.0, key.1);
chunk.fresh = i % 2 == 0;
map.insert(key, Arc::new(chunk));
}
let ordered = map.fresh_keys_ordered();
let expected: Vec<Key> = map
.keys()
.copied()
.filter(|k| map.fresh.contains(k))
.collect();
assert_eq!(ordered, expected);
assert_eq!(ordered.len(), 3);
}
}
@@ -82,6 +82,28 @@ pub fn double_chest_name(first: Option<&str>, second: Option<&str>) -> horizon_w
display_name(first.or(second), "container.chestDouble")
}
/// The menu a container vehicle's `createMenu` builds (`ContainerEntity`):
/// `AbstractChestBoat.createMenu` and `MinecartChest.createMenu` are
/// `ChestMenu.threeRows` (a 9x3 menu), `MinecartHopper.createMenu` is a
/// `HopperMenu` (five slots). `None` for any other entity type.
pub fn vehicle_menu(entity_type: &str) -> Option<(&'static str, usize)> {
match entity_type {
"minecraft:chest_minecart" => Some(("minecraft:generic_9x3", 27)),
"minecraft:hopper_minecart" => Some(("minecraft:hopper", HOPPER_SLOTS)),
n if n.ends_with("_chest_boat") || n.ends_with("_chest_raft") => {
Some(("minecraft:generic_9x3", 27))
}
_ => None,
}
}
/// `MenuProvider.getDisplayName` for a container vehicle: `Entity.getDisplayName`
/// is its custom name, else `getName`, the entity type's name key. Horizon's
/// vehicles keep no custom name, so this is the type's name key.
pub fn vehicle_menu_name(entity_type: &str) -> String {
format!("entity.{}", entity_type.replacen(':', ".", 1))
}
impl Campfire {
/// `placeFood`'s slot: the first empty one, given the item's
/// `campfire_cooking` time; it takes one item of `held`.
+13 -3
View File
@@ -453,7 +453,7 @@ pub struct DataStore {
/// `item_modifier` files, for `/item modify`.
pub item_modifiers: BTreeMap<String, serde_json::Value>,
/// The content tables' raw files from the packs after the built-in one:
/// `recipe/`, `loot_table/`, `advancement/` and `tags/…/` files, by
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` and `tags/…/` files, by
/// `<kind-dir>/<ns>:<path>`, each id's files in pack order. The tables
/// merge them over what they read from the resource root.
pub files: BTreeMap<String, Vec<Vec<u8>>>,
@@ -461,14 +461,14 @@ pub struct DataStore {
pub errors: Vec<(String, String)>,
}
/// `<kind-dir>/<ns>:<path>` for a pack file the content tables read:
/// `recipe/`, `loot_table/`, `advancement/` or `tags/<sub>/`, where the id
/// `recipe/`, `loot_table/`, `advancement/`, `dialog/` or `tags/<sub>/`, where the id
/// keeps the sub path so a nested tag kind stays addressable.
fn content_id(file: &str) -> Option<String> {
let rest = file.strip_prefix("data/")?;
let (ns, rest) = rest.split_once('/')?;
let (kind, rest) = rest.split_once('/')?;
let kind = match kind {
"recipe" | "loot_table" | "advancement" => kind,
"recipe" | "loot_table" | "advancement" | "dialog" => kind,
"tags" => {
let (sub, name) = rest.rsplit_once('/')?;
return Some(format!("tags/{sub}/{ns}:{}", name.strip_suffix(".json")?));
@@ -497,6 +497,7 @@ impl DataStore {
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"dialog",
"function",
"item_modifier",
"loot_table",
@@ -865,6 +866,10 @@ mod tests {
"data/mine/predicate/p.json",
"{\"condition\":\"minecraft:random_chance\",\"chance\":1}",
);
write(
"data/mine/dialog/config/points.json",
"{\"type\":\"minecraft:notice\",\"title\":\"Points\"}",
);
let packs = discover(&dir);
assert_eq!(packs.len(), 1);
assert_eq!(packs[0].id, "file/mypack");
@@ -877,6 +882,11 @@ mod tests {
["mine:a", "mine:sub/b"]
);
assert!(store.predicates.contains_key("mine:p"));
// Dialogs reach the synchronized registry through the content files.
assert_eq!(
store.files["dialog/mine:config/points"],
[b"{\"type\":\"minecraft:notice\",\"title\":\"Points\"}".to_vec()]
);
let _ = std::fs::remove_dir_all(&dir);
}
+51 -5
View File
@@ -77,6 +77,7 @@ mod end_sky;
mod flat;
mod frame_gen;
mod hdr;
pub use hdr::Calibration as HdrCalibration;
pub mod lightning;
mod lod;
mod native_material;
@@ -1250,6 +1251,19 @@ impl Renderer {
.map(|tile| u64::from(tile.mesh.solid + tile.mesh.translucent))
.sum()
}
/// Tiles and quads of the far terrain tiles with GPU buffers, by level.
pub fn lod_levels(&self) -> Vec<[u64; 2]> {
let mut levels = Vec::new();
for (key, tile) in &self.lods {
let level = usize::from(key.level);
if levels.len() <= level {
levels.resize(level + 1, [0, 0]);
}
levels[level][0] += 1;
levels[level][1] += u64::from(tile.mesh.solid + tile.mesh.translucent);
}
levels
}
/// Bytes of the section vertex arena's buffers, and of the vertices
/// resident sections hold in them.
pub fn section_vertex_bytes(&self) -> [u64; 2] {
@@ -3550,7 +3564,13 @@ impl Renderer {
) {
surface.configure(device, config);
if let Some(hdr) = hdr {
horizon_hdr::set_metadata(device, surface, hdr.paper_white, hdr.peak, hdr.black);
horizon_hdr::set_metadata(
device,
surface,
hdr.paper_white,
hdr.declared_peak(),
hdr.black,
);
}
}
fn configure_surface(&self) {
@@ -3563,9 +3583,29 @@ impl Renderer {
);
}
}
/// HDR calibration's test picture (`crate::hdr_calibration`) while it runs, else None.
pub fn set_hdr_calibration(&mut self, calibration: Option<HdrCalibration>) {
let Some(hdr) = &mut self.hdr else {
return;
};
if hdr.calibration == calibration {
return;
}
let declared = hdr.declared_peak();
hdr.calibration = calibration;
if hdr.declared_peak() != declared {
self.send_hdr_metadata();
}
}
fn send_hdr_metadata(&self) {
if let (Some(surface), Some(hdr)) = (&self.surface, &self.hdr) {
horizon_hdr::set_metadata(&self.device, surface, hdr.paper_white, hdr.peak, hdr.black);
horizon_hdr::set_metadata(
&self.device,
surface,
hdr.paper_white,
hdr.declared_peak(),
hdr.black,
);
}
}
/// The swapchain's configuration: `config` (what every pipeline draws in), or
@@ -3626,7 +3666,8 @@ impl Renderer {
DisplayWatch::default()
};
let format = self.config.format.remove_srgb_suffix();
self.hdr = output.map(|output| hdr::Hdr::new(&self.device, format, output));
self.hdr =
output.map(|output| hdr::Hdr::new(&self.device, &self.queue, format, output));
self.configure_surface();
match output {
Some(output) => log::info!(
@@ -3747,9 +3788,13 @@ impl Renderer {
/// stages' labels; on an error the pack that ran keeps running.
pub fn set_native_shader_pack(
&mut self,
pack: Option<(horizon_shader_api::Report, Vec<(String, f64)>)>,
pack: Option<(
horizon_shader_api::Report,
Vec<(String, f64)>,
Vec<(u32, u32)>,
)>,
) -> std::result::Result<Vec<String>, String> {
let Some((report, constants)) = pack else {
let Some((report, constants, sprite_tags)) = pack else {
self.native = None;
return Ok(Vec::new());
};
@@ -3758,6 +3803,7 @@ impl Renderer {
flat: self.flat.as_ref().map(|f| &f.draw_layout),
sprites: self.terrain_sprites,
format: self.config.format.remove_srgb_suffix(),
sprite_tags: &sprite_tags,
};
let (pack, labels) =
native_pack::NativePack::new(&self.device, &terrain, report, &constants)?;
+90 -4
View File
@@ -91,9 +91,22 @@ pub const TIERS: [Tier; 4] = [
full: 0.38,
},
];
/// A step of HDR calibration (`crate::hdr_calibration`): its test picture is drawn in nits
/// above the controls instead of the frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Calibration {
/// 1 black level, 2 peak brightness, 3 paper white.
pub step: u32,
/// The slider's value, in nits.
pub nits: f32,
/// Where the controls start, as a share of the height.
pub band: f32,
}
pub struct Hdr {
layout: wgpu::BindGroupLayout,
/// Horizon's logo as a 1-bit mask, for calibration's test pictures.
logo: wgpu::TextureView,
pipeline: wgpu::RenderPipeline,
/// Zeroes the frame's alpha once the world is kept (`keep_world`): what draws after,
/// the hand, HUD and menus, raises it, so HDR output knows where they cover the world.
@@ -120,10 +133,17 @@ pub struct Hdr {
pub tiers: [Tier; 4],
/// The HUD and menus' brightness, as a share of paper white (0.25..=1).
pub ui: f32,
/// Calibration's test picture while it runs.
pub calibration: Option<Calibration>,
}
impl Hdr {
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, output: Output) -> Self {
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
output: Output,
) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HDR"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hdr.wgsl").into()),
@@ -143,6 +163,7 @@ impl Hdr {
entries: &[
texture(0),
texture(1),
texture(3),
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
@@ -232,12 +253,13 @@ impl Hdr {
let params = || {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon HDR parameters"),
size: 80,
size: 96,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
};
Self {
logo: logo_mask(device, queue),
layout,
pipeline: pipeline_for(output.format()),
clear_alpha,
@@ -254,6 +276,7 @@ impl Hdr {
black: 0.0,
tiers: TIERS,
ui: 1.0,
calibration: None,
}
}
@@ -298,6 +321,10 @@ impl Hdr {
binding: 2,
resource: self.params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&self.logo),
},
],
});
self.targets = Some(Targets {
@@ -310,6 +337,16 @@ impl Hdr {
self.targets.as_ref().unwrap().frame.clone()
}
/// The peak the HDR metadata gives: while calibration's peak step runs, PQ's own
/// 10 000 nits, so a compositor that clips at the declared peak (KWin) lets the test
/// patch through as bright as the display goes and the logo under it shows.
pub fn declared_peak(&self) -> f32 {
match self.calibration {
Some(c) if c.step == 2 => 10_000.0,
_ => self.peak,
}
}
/// Keeps the world as drawn so far, before the hand and HUD cover it, then zeroes the
/// frame's alpha: the world's marks live on in the copy, and whatever draws from here
/// on raises the alpha where it covers the world.
@@ -430,6 +467,10 @@ impl Hdr {
binding: 2,
resource: self.capture_params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&self.logo),
},
],
});
queue.write_buffer(
@@ -442,9 +483,10 @@ impl Hdr {
}
/// hdr.wgsl's `Params`.
fn params(&self, world: bool, pq: bool) -> [f32; 20] {
fn params(&self, world: bool, pq: bool) -> [f32; 24] {
let paper = self.paper_white.max(1.0);
let mut params = [0.0; 20];
let c = self.calibration;
let mut params = [0.0; 24];
params[..5].copy_from_slice(&[
paper / 80.0,
(self.peak / paper).max(1.0),
@@ -459,6 +501,9 @@ impl Hdr {
params[12 + i] = t.start;
params[16 + i] = t.full;
}
params[20] = c.map_or(0.0, |c| c.step as f32);
params[21] = c.map_or(0.0, |c| c.nits);
params[22] = c.map_or(0.0, |c| c.band);
params
}
@@ -489,6 +534,47 @@ impl Hdr {
}
}
/// Horizon's icon (horizon-theme) as a 1-bit mask: its white strokes, one colour with hard
/// edges, are the logo; everything else is not.
fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
use wgpu::util::DeviceExt;
let (w, h, rgba) =
crate::hud::decode_bytes(horizon_theme::ICON_PNG).unwrap_or((1, 1, vec![0; 4]));
let mask: Vec<u8> = rgba
.as_chunks::<4>()
.0
.iter()
.map(|p| {
if p[3] > 128 && p[..3].iter().all(|&c| c > 200) {
255
} else {
0
}
})
.collect();
device
.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("Horizon HDR calibration logo"),
size: wgpu::Extent3d {
width: w,
height: h,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::R8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&mask,
)
.create_view(&Default::default())
}
#[cfg(test)]
mod tests {
use super::{Output, pick};
@@ -66,7 +66,7 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent)).color;
translucent, horizon_sprite_tags(in.layer & 0x7fffu))).color;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, false);
@@ -91,6 +91,9 @@ pub struct TerrainShape<'a> {
pub sprites: [f64; 2],
/// The world target's colour format.
pub format: wgpu::TextureFormat,
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
/// (`Manifest::sprite_tags`).
pub sprite_tags: &'a [(u32, u32)],
}
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
@@ -110,17 +113,34 @@ fn vanilla_terrain() -> String {
)
}
/// The terrain shader for the pack's material stage `source`.
fn shader_source(source: &str) -> String {
/// `horizon_sprite_tags(layer)`: the bits of `MaterialInput.tags` of each sprite layer.
fn sprite_tags(tags: &[(u32, u32)]) -> String {
let mut out = String::from("fn horizon_sprite_tags(layer: u32) -> u32 {\n switch layer {\n");
let mut tags = tags.to_vec();
tags.sort_unstable_by_key(|&(layer, bits)| (bits, layer));
for group in tags.chunk_by(|a, b| a.1 == b.1) {
let layers: Vec<String> = group.iter().map(|(layer, _)| format!("{layer}u")).collect();
out += &format!(
" case {}: {{ return {}u; }}\n",
layers.join(", "),
group[0].1
);
}
out + " default: { return 0u; }\n }\n}\n"
}
/// The terrain shader for the pack's material stage `source`, with its sprite `tags`.
fn shader_source(source: &str, tags: &[(u32, u32)]) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{ENTRIES}",
vanilla_terrain()
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{ENTRIES}",
vanilla_terrain(),
sprite_tags(tags)
)
}
/// The material's naga module, its errors in the stage's file.
fn module(stage: &CheckedStage) -> Result<naga::Module, String> {
let text = shader_source(&stage.source);
fn module(stage: &CheckedStage, tags: &[(u32, u32)]) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -134,7 +154,7 @@ impl NativeMaterial {
stage: &CheckedStage,
constants: &[(String, f64)],
) -> Result<Self, String> {
let module = module(stage)?;
let module = module(stage, shape.sprite_tags)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
"horizon_terrain_SPRITE_ARRAYS",
@@ -299,13 +319,15 @@ mod tests {
let lit = input.albedo * input.tint * vec4(input.lightmap, 1.0);\n \
return Surface(apply_fog(sprite(lit)));\n}\n";
let stage = stage(own);
let module = module(&stage).unwrap();
let tags = [(7, 1), (3, 1), (9, 2)];
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source))));
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source, &tags))));
let entries: Vec<&str> = module
.entry_points
.iter()
@@ -38,6 +38,8 @@ pub struct NativeFrame {
pub group: wgpu::BindGroup,
start: std::time::Instant,
frames: u32,
/// `frame.wetness`, and when it was last written.
wetness: (f32, Option<std::time::Instant>),
}
/// The uniform `frame` at binding 0, read by `visibility`.
@@ -80,13 +82,18 @@ impl NativeFrame {
group,
start: std::time::Instant::now(),
frames: 0,
wetness: (0.0, None),
}
}
/// Writes this frame's inputs, for a world of `size`.
pub fn write(&mut self, queue: &wgpu::Queue, frame: &Frame, size: [u32; 2]) {
let seconds = self.start.elapsed().as_secs_f32() % 3600.0;
let inputs = frame_inputs(frame, size, seconds, self.frames);
let now = std::time::Instant::now();
let dt = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32());
self.wetness = (wetness(self.wetness.0, frame.pack.rain, dt), Some(now));
let mut inputs = frame_inputs(frame, size, seconds, self.frames);
inputs.wetness = self.wetness.0;
self.frames = self.frames.wrapping_add(1);
queue.write_buffer(&self.buffer, 0, bytemuck::bytes_of(&inputs));
}
@@ -357,6 +364,13 @@ impl NativePost {
}
}
/// `wetness` after `dt` seconds of `rain`: halfway there in 30 seconds while it rains
/// harder, in 10 while it dries (the half-lives of the `shaders/` format's `wetness`).
fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
let half_life = if rain > wetness { 30.0 } else { 10.0 };
rain + (wetness - rain) * 0.5f32.powf(dt / half_life)
}
/// The frame as packs read it (docs/api/shaders-reference.md), from the renderer's `Frame`.
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
@@ -416,6 +430,10 @@ pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) ->
},
frame_index: index,
hide_gui: u32::from(pack.hide_gui),
precipitation: pack.biome_precipitation.clamp(0, 2) as u32,
temperature: pack.temperature,
downfall: pack.rainfall,
wetness: pack.rain,
}
}
@@ -424,6 +442,13 @@ mod tests {
use super::*;
use horizon_shader_api::{MANIFEST, Pack, check};
#[test]
fn wetness_rises_slower_than_it_dries() {
assert_eq!(wetness(0.0, 1.0, 30.0), 0.5);
assert_eq!(wetness(1.0, 0.0, 10.0), 0.5);
assert_eq!(wetness(0.25, 0.25, 5.0), 0.25);
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
@@ -128,6 +128,9 @@ struct Graph {
/// (`Node.sourceDirections`, as the faces rather than the steps); none for
/// the camera's.
entered: Vec<u8>,
/// Whether any section in the searched top layer was reached. Sections
/// above a clamped box use this conservative sky-band answer.
sky_reached: bool,
/// Per section corner, during a full search: `SEES`, or how far the line
/// from it towards the camera is known to stay in reached sections (the
/// first sample that was not). Sections are only ever added during a
@@ -156,8 +159,22 @@ impl Graph {
)
}
fn contains(&self, key: SectionKey) -> bool {
self.index(key)
.is_some_and(|i| self.directions[i] & REACHED != 0)
if let Some(i) = self.index(key) {
self.directions[i] & REACHED != 0
} else if (0..=self.sections).contains(&key.1)
&& key.1 >= self.y0 + self.height
&& (key.0 - self.from.0).abs() <= self.radius
&& (key.2 - self.from.2).abs() <= self.radius
{
// Everything above the highest non-air section is open sky. A
// full search can reach that layer from a neighbouring column,
// so checking only this column's top cell can hide an upper
// section that is visible through the open layer. Once any top
// cell is reached, conservatively keep the whole sky band visible.
self.sky_reached
} else {
false
}
}
/// The full search from the camera (vanilla's `scheduleFullUpdate`).
fn search(
@@ -174,13 +191,37 @@ impl Graph {
self.min_y = columns.min_y;
self.sections = columns.sections;
self.y0 = -1;
self.height = columns.sections + 2;
if from.1 >= columns.sections {
self.height = columns.sections + 2;
} else {
let mut max_non_air_y = 0;
for dz in -radius..=radius {
for dx in -radius..=radius {
let Some(column) = columns.map.get(&(from.0 + dx, from.2 + dz)) else {
continue;
};
for (y, &v) in column.iter().enumerate().rev() {
let y = y as i32;
if y <= max_non_air_y {
break;
}
if v != ALL_VISIBLE {
max_non_air_y = y;
break;
}
}
}
}
let top_y = max_non_air_y.max(from.1);
self.height = (top_y + 3).max(3).min(columns.sections + 2);
}
let side = (2 * radius + 1) as usize;
let size = side * side * self.height as usize;
self.directions.clear();
self.directions.resize(size, 0);
self.entered.clear();
self.entered.resize(size, 0);
self.sky_reached = false;
self.rays.clear();
self.rays
.resize((side + 1) * (side + 1) * (self.height + 1) as usize, 0);
@@ -194,7 +235,8 @@ impl Graph {
let base = self
.index((from.0 + dx, 0, from.2 + dz))
.expect("column inside the searched box");
vis[base..base + column.len()].copy_from_slice(column);
let len = (column.len() as i32).min(self.height - 1) as usize;
vis[base..base + len].copy_from_slice(&column[..len]);
}
}
queue.clear();
@@ -279,6 +321,9 @@ impl Graph {
while let Some(index) = queue.pop_front() {
let i = index as usize;
let key = self.key(i);
if key.1 == self.y0 + self.height - 1 {
self.sky_reached = true;
}
let directions = self.directions[i];
let entered = self.entered[i];
let exits = if entered == 0 {
@@ -1082,6 +1127,64 @@ mod tests {
);
}
/// A clamped search may conservatively show sections above its searched
/// box, but it must never hide one that the full-height search reaches.
#[test]
fn vertical_clamping_only_adds_overdraw() {
let mut compared = 0;
let mut overdraw = 0;
let mut clamped = 0;
for seed in [0x9e37_79b9, 7, 0xdead_beef, 0x2545_f491] {
for (spread, count, radius) in [(6, 900, 6), (12, 2600, 12)] {
let mut o = Occlusion::new(0, 24);
random_world(&mut o, seed, count, spread);
let camera = at((0, 2, 0)) + DVec3::new(3.3, -5.7, 1.9);
o.settle(camera, radius);
let graph = o.graph.as_ref().expect("searched");
clamped += usize::from(graph.height < o.columns.sections + 2);
let full = reference(&o.columns, camera, radius, true);
let from = o.columns.section_of(camera);
let side = 2 * radius + 1;
let height = o.columns.sections + 2;
let full_contains = |key: SectionKey| {
let (dx, dz, dy) = (key.0 - from.0, key.2 - from.2, key.1 + 1);
if dx.abs() > radius || dz.abs() > radius || !(0..height).contains(&dy) {
return false;
}
full[(((dz + radius) * side + dx + radius) * height + dy) as usize]
};
for x in from.0 - radius..=from.0 + radius {
for z in from.2 - radius..=from.2 + radius {
for y in -1..=o.columns.sections {
let key = (x, y, z);
let expected = full_contains(key);
let actual = o.contains(key);
compared += 1;
assert!(
!expected || actual,
"clamped search hid {key:?} for seed {seed:#x}, spread {spread}, radius {radius}; from={from:?}, y0={}, height={}, expected={expected}, actual={actual}",
graph.y0,
graph.height
);
overdraw += usize::from(actual && !expected);
if actual && !expected {
assert!(
y >= graph.y0 + graph.height,
"overdraw must be above the clamped box: {key:?}, y0={}, height={}",
graph.y0,
graph.height
);
}
}
}
}
}
}
assert!(clamped > 0, "the comparison exercised a clamped search");
assert!(compared > 50_000, "compared {compared} sections");
assert!(overdraw > 0, "the conservative top-cell rule was exercised");
}
/// A line checked again from the sample it failed at, after more
/// sections were reached, ends where checking it from its corner does.
#[test]
@@ -1226,4 +1329,26 @@ mod tests {
assert_eq!(graph.entered, local.entered);
}
}
#[test]
fn vertical_clamping_bounds_height_and_reaches_open_sky() {
let mut o = Occlusion::new(0, 24);
o.set((0, 5, 0), 0); // Wall at section 5
o.settle(at((0, 2, 0)), 4);
let graph = o.graph.as_ref().expect("searched");
assert_eq!(graph.height, 5 + 3);
assert!(o.contains((0, 2, 0)), "camera section is reached");
assert!(o.contains((1, 20, 1)), "open air column is reached");
// An enclosed cave cannot reach the sky
let mut cave = Occlusion::new(0, 24);
for (dx, dy, dz) in STEPS {
cave.set((dx, 1 + dy, dz), 0);
}
cave.settle(at((0, 1, 0)), 4);
assert!(
!cave.contains((0, 10, 0)),
"enclosed cave does not see the sky"
);
}
}
+11 -6
View File
@@ -334,7 +334,7 @@ pub fn lod_capped(info: &wgpu::AdapterInfo) -> bool {
}
/// A LOD distance as far terrain uses it on this adapter.
pub fn lod_distance_capped(distance: u32, info: &wgpu::AdapterInfo) -> u32 {
let distance = distance.min(1024);
let distance = distance.min(crate::lod::MAX_CHUNKS);
if lod_capped(info) {
distance.min(INTEGRATED_LOD_DISTANCE)
} else {
@@ -472,7 +472,9 @@ impl Graphics {
let number = |d: u32| value.parse().unwrap_or(d);
match key.trim() {
"preset" => g.preset = Preset::parse(value),
"lodDistance" => g.lod_distance = number(g.lod_distance).min(1024),
"lodDistance" => {
g.lod_distance = number(g.lod_distance).min(crate::lod::MAX_CHUNKS)
}
"farDetail" => g.far_detail = FarDetail::parse(value),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"entityCulling" => g.entity_culling = flag(g.entity_culling),
@@ -499,7 +501,7 @@ impl Graphics {
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
0 => 0,
n => n.clamp(80, 480),
n => n.clamp(10, 480),
}
}
"hdrPeak" => {
@@ -717,7 +719,7 @@ mod tests {
assert_eq!(Graphics::parse("hdrPeak:0\n").hdr_peak, 0);
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 80);
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
// The old menus' Simple / Advanced choice is ignored, and so is the
// shader pack older versions saved.
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
@@ -774,12 +776,15 @@ mod tests {
#[test]
fn lod_distance_is_opt_in_and_bounded() {
assert_eq!(Graphics::default().lod_distance, 0);
for distance in [0, 64, 128, 256, 512, 1024] {
for distance in [0, 64, 128, 256, 512, 1024, 65_536, crate::lod::MAX_CHUNKS] {
let g = Graphics::parse(&format!("lodDistance:{distance}\n"));
assert_eq!(g.lod_distance, distance);
assert_eq!(Graphics::parse(&g.serialize()), g);
}
assert_eq!(Graphics::parse("lodDistance:10000\n").lod_distance, 1024);
assert_eq!(
Graphics::parse("lodDistance:99999999\n").lod_distance,
crate::lod::MAX_CHUNKS
);
assert_eq!(Graphics::parse("lodDistance:-1\n").lod_distance, 0);
assert_eq!(Graphics::parse("lodDistance:invalid\n").lod_distance, 0);
}
+134 -21
View File
@@ -46,6 +46,8 @@ pub enum H {
HdrBlackLevel,
/// Binds `Graphics::hdr_key`.
HdrKey,
/// Starts HDR calibration (`crate::hdr_calibration`).
HdrCalibrate,
HdrPeak,
HdrPaperWhite,
HdrUiBrightness,
@@ -136,6 +138,11 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.",
),
h(
H::HdrCalibrate,
None,
"Sets the three below full screen, one test picture at a time.",
),
h(
H::HdrBlackLevel,
None,
@@ -339,6 +346,28 @@ pub fn rows(level: Level) -> Vec<Row> {
}
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
/// What the LOD Distance slider stops at, in chunks: off, then each doubling
/// up to the whole world.
const LOD_STEPS: [u32; 18] = [
0,
64,
128,
256,
512,
1024,
2048,
4096,
8192,
16384,
32768,
65536,
131072,
262144,
524288,
1048576,
2097152,
crate::lod::MAX_CHUNKS,
];
/// Horizon's graphics screens supplement the vanilla options on Video Settings.
/// Upscaling is only there while TAA is, which does it.
@@ -362,6 +391,46 @@ fn extra_rows(level: Level, settings: &Settings) -> Vec<Row> {
rows
}
/// Whether the system set HDR up for the display, and whether the player kept it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Setup {
/// The system says nothing: the player calibrates.
Unknown,
/// Peak Brightness and Paper White follow the system.
Automatic,
/// The system said, and the player changed them anyway.
Changed,
}
fn setup(s: &Settings) -> Setup {
setup_of(&s.horizon, crate::graphics::reported().is_some())
}
fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
if !reported {
Setup::Unknown
} else if g.hdr_peak == 0 && g.hdr_paper_white == 0 {
Setup::Automatic
} else {
Setup::Changed
}
}
/// The description beside `item` where it depends on [`setup`]; `text` otherwise.
fn description(item: Item, s: &Settings, text: &'static str) -> &'static str {
match (item, setup(s)) {
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
"Your display didn't say how bright it gets: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
"Already set up for your display. Only calibrate if HDR looks wrong."
}
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
"Puts Peak Brightness and Paper White back to your display's setup."
}
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
"Set up for your display. Leave on Auto unless it looks wrong."
}
_ => text,
}
}
fn on_off(on: bool) -> String {
lang::get(if on { "On" } else { "Off" })
}
@@ -384,13 +453,17 @@ impl H {
.unwrap_or(UPLOAD_STEPS.len() - 1);
Some(i as f32 / (UPLOAD_STEPS.len() - 1) as f32)
}
H::LodDistance => {
let i = LOD_STEPS
.iter()
.position(|&d| d >= g.lod_distance)
.unwrap_or(LOD_STEPS.len() - 1);
Some(i as f32 / (LOD_STEPS.len() - 1) as f32)
}
H::Workers => Some(g.workers as f32 / max_workers() as f32),
H::ResolutionScale => Some((g.resolution_scale as f32 - 50.0) / 150.0),
// Auto, then 80..480 by 10.
H::HdrPaperWhite => Some(match g.hdr_paper_white {
0 => 0.0,
n => (n as f32 - 70.0) / 410.0,
}),
// Auto, then 10..480 by 10.
H::HdrPaperWhite => Some(g.hdr_paper_white as f32 / 480.0),
// Auto, then 200..2000 by 50.
H::HdrPeak => Some(match g.hdr_peak {
0 => 0.0,
@@ -454,6 +527,11 @@ impl H {
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
Setup::Unknown => "Calibrate...",
Setup::Automatic => "Calibrate Anyway...",
Setup::Changed => "Use Automatic Setup",
})),
H::HdrPaperWhite => match g.hdr_paper_white {
0 => labelled(
"Paper White",
@@ -499,14 +577,12 @@ impl H {
g.upload_budget_kib =
UPLOAD_STEPS[(v * (UPLOAD_STEPS.len() - 1) as f32).round() as usize]
}
H::LodDistance => {
g.lod_distance = LOD_STEPS[(v * (LOD_STEPS.len() - 1) as f32).round() as usize]
}
H::Workers => g.workers = (v * max_workers() as f32).round() as u32,
H::ResolutionScale => g.resolution_scale = (50.0 + (v * 30.0).round() * 5.0) as u32,
H::HdrPaperWhite => {
g.hdr_paper_white = match (v * 41.0).round() as u32 {
0 => 0,
step => 70 + step * 10,
}
}
H::HdrPaperWhite => g.hdr_paper_white = (v * 48.0).round() as u32 * 10,
H::HdrPeak => {
g.hdr_peak = match (v * 37.0).round() as u32 {
0 => 0,
@@ -532,9 +608,6 @@ impl H {
}
let g = &mut s.horizon;
match self {
H::LodDistance => {
g.lod_distance = step(&[0, 64, 128, 256, 512, 1024], g.lod_distance, back)
}
H::FarDetail => {
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
}
@@ -604,7 +677,11 @@ impl Item {
Some("Your display or system doesn't offer HDR output.")
}
Item::Horizon(
H::HdrBlackLevel | H::HdrPeak | H::HdrPaperWhite | H::HdrUiBrightness,
H::HdrCalibrate
| H::HdrBlackLevel
| H::HdrPeak
| H::HdrPaperWhite
| H::HdrUiBrightness,
) if !s.horizon.hdr => Some("Turn HDR on to use it."),
_ => None,
}
@@ -713,6 +790,8 @@ pub enum Action {
OpenHdr,
/// Wait for a key or mouse button to bind to this name.
BindKey(&'static str),
/// Start HDR calibration.
Calibrate,
Done,
}
@@ -907,7 +986,7 @@ impl GraphicsUi {
wd.x + wd.w + STYLE.gap,
wd.y + 1.0,
cost,
text,
description(item, s, text),
);
}
Target::Button(action) => {
@@ -1000,6 +1079,14 @@ impl GraphicsUi {
Target::Item(Item::Horizon(H::HdrSettings)) => {
vec![Action::ButtonClick, Action::OpenHdr]
}
Target::Item(Item::Horizon(H::HdrCalibrate)) if setup(s) == Setup::Changed => {
s.horizon.hdr_peak = 0;
s.horizon.hdr_paper_white = 0;
vec![Action::ButtonClick, Action::Changed]
}
Target::Item(Item::Horizon(H::HdrCalibrate)) => {
vec![Action::ButtonClick, Action::Calibrate]
}
Target::Item(Item::Horizon(H::HdrKey)) => {
vec![
Action::ButtonClick,
@@ -1257,9 +1344,19 @@ mod tests {
}
}
#[test]
fn hdr_setup_follows_what_the_system_reported() {
let mut g = crate::graphics::Graphics::default();
assert_eq!(setup_of(&g, false), Setup::Unknown);
assert_eq!(setup_of(&g, true), Setup::Automatic);
g.hdr_paper_white = 80;
assert_eq!(setup_of(&g, true), Setup::Changed);
assert_eq!(setup_of(&g, false), Setup::Unknown);
}
#[test]
fn every_advanced_setting_has_a_description() {
for level in [Level::Simple, Level::Advanced] {
for level in [Level::Simple, Level::Advanced, Level::Hdr] {
for row in rows(level) {
if let Row::Setting(s) = row {
assert!(!s.text.is_empty(), "{:?}", s.item);
@@ -1270,11 +1367,12 @@ mod tests {
}
#[test]
fn lod_distance_cycles_in_both_levels_without_changing_near_distance() {
fn lod_distance_slides_in_both_levels_without_changing_near_distance() {
let mut s = Settings::default();
let item = Item::Horizon(H::LodDistance);
let near = s.render_distance;
assert_eq!(item.label(&s).plain(), "LOD Distance: Off");
assert_eq!(item.slider(&s), Some(0.0));
for level in [Level::Simple, Level::Advanced] {
let items: Vec<_> = rows(level)
.into_iter()
@@ -1289,14 +1387,29 @@ mod tests {
.unwrap();
assert_eq!(items[render + 1], item);
}
for distance in [64, 128, 256, 512, 1024, 0] {
item.cycle(&mut s, false, [854, 480]);
// Every stop is reachable and reads back as the same position.
for (i, &distance) in LOD_STEPS.iter().enumerate() {
let at = i as f32 / (LOD_STEPS.len() - 1) as f32;
item.set_slider(&mut s, at);
assert_eq!(s.horizon.lod_distance, distance);
assert_eq!(item.slider(&s), Some(at));
assert_eq!(s.render_distance, near);
}
item.cycle(&mut s, true, [854, 480]);
// The ends are off and the whole world, which the far terrain plans.
item.set_slider(&mut s, 0.0);
assert_eq!(s.horizon.lod_distance, 0);
item.set_slider(&mut s, 1.0);
assert_eq!(s.horizon.lod_distance, crate::lod::MAX_CHUNKS);
assert_eq!(
item.label(&s).plain(),
format!("LOD Distance: {} chunks", crate::lod::MAX_CHUNKS)
);
item.set_slider(&mut s, 5.0 / 17.0);
assert_eq!(s.horizon.lod_distance, 1024);
assert_eq!(item.label(&s).plain(), "LOD Distance: 1024 chunks");
// A distance between stops (from the file) sits on the next one.
s.horizon.lod_distance = 3000;
assert_eq!(item.slider(&s), Some(7.0 / 17.0));
assert_eq!(
Item::Vanilla(Opt::RenderDistance).label(&s).plain(),
format!("Full Detail Distance: {near} chunks")
+58
View File
@@ -26,8 +26,14 @@ struct Params {
share: vec4<f32>,
start: vec4<f32>,
full: vec4<f32>,
// HDR calibration (hdr_calibration.rs): x its step (0 none, 1 black level,
// 2 peak, 3 paper white), y the slider in nits, z where its controls start,
// a share of the height.
calibration: vec4<f32>,
};
@group(0) @binding(2) var<uniform> params: Params;
// Horizon's logo, 1 inside, 0 outside, for calibration's test pictures.
@group(0) @binding(3) var logo: texture_2d<f32>;
// Light sources are lifted evenly rather than by a highlight curve: vanilla's
// textures were drawn for SDR, so a curve on their brightest pixels blows out
@@ -81,6 +87,48 @@ fn lift_black(e: vec3<f32>) -> vec3<f32> {
return e + pq(vec3(params.black)).x * pow(vec3(1.0) - n, vec3(4.0));
}
// Whether `p` falls on the logo drawn `size` pixels wide around `centre`.
fn on_logo(p: vec2<f32>, centre: vec2<f32>, size: f32) -> bool {
let uv = (p - centre) / size + 0.5;
if any(uv < vec2(0.0)) || any(uv >= vec2(1.0)) {
return false;
}
let texels = vec2<f32>(textureDimensions(logo));
return textureLoad(logo, vec2<i32>(uv * texels), 0).r > 0.5;
}
// A calibration step's test picture at `p`, in nits, the way display calibration
// tools draw them: nothing in it is bright over a large area, so the display
// doesn't dim itself while it is judged.
fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
let top = size.y * params.calibration.z;
let centre = vec2(size.x, top) / 2.0;
let v = params.calibration.y;
switch u32(params.calibration.x) {
// Black level: the logo at the slider on 0. Lowered until it disappears,
// it sits on the darkest the display shows.
case 1u: {
return select(0.0, v, on_logo(p, centre, top * 0.4));
}
// Peak: a tenth of the screen at 10 000 nits, which the display clips at
// its brightest, the logo inside at the slider. Raised until it
// disappears, it sits on that brightest.
case 2u: {
let side = sqrt(0.1 * size.x * size.y);
if all(abs(p - centre) < vec2(side / 2.0)) {
return select(10000.0, v, on_logo(p, centre, side * 0.7));
}
return 0.0;
}
// Paper white: a white card at the slider, on a dim grey, to match a
// sheet of paper held beside the screen.
default: {
let card = vec2(top * 0.9, top * 0.6);
return select(3.0, v, all(abs(p - centre) < card / 2.0));
}
}
}
@vertex fn vs_hdr(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
return vec4(p * 2.0 - 1.0, 0.0, 1.0);
@@ -93,6 +141,16 @@ fn lift_black(e: vec3<f32>) -> vec3<f32> {
@fragment fn fs_hdr(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let at = vec2<i32>(pos.xy);
// Calibration: above its controls the test picture, straight in nits, past
// paper white, the lift and the black level.
let size = vec2<f32>(textureDimensions(frame));
if params.calibration.x > 0.5 && pos.y < size.y * params.calibration.z {
let nits = vec3(calibration_nits(pos.xy, size));
if params.pq > 0.5 {
return vec4(pq(nits), 1.0);
}
return vec4(nits / 80.0, 1.0);
}
let f = textureLoad(frame, at, 0);
let c = f.rgb;
// What covers the world here (Hdr::keep_world): nothing at 0, the hand at
@@ -0,0 +1,311 @@
//! HDR calibration (Graphics: HDR, Calibrate...): full screen, one step at a time, the way
//! display calibration tools do it. HDR output draws each step's test picture straight in
//! nits above a band of controls (`hdr.wgsl`, `Hdr::calibration`), past everything that
//! shapes the game's picture, so what the player sees is the display alone:
//!
//! - Black Level: Horizon's logo at the slider's brightness on 0 nits; lowered until it
//! disappears, it sits on the darkest the display shows.
//! - Peak Brightness: a patch of a tenth of the screen at 10 000 nits (the display clips
//! it at its brightest), the logo inside at the slider; raised until it disappears, it
//! sits on that brightest. Small, so the display doesn't dim a bright full screen; the
//! HDR metadata declares PQ's 10 000 nits meanwhile, so a compositor that clips at the
//! declared peak (KWin) lets the patch through.
//! - Paper White: a white card at the slider, matched to a sheet of white paper held
//! beside the screen in the light the player plays in: white is where the eyes have
//! adapted to, lower in a dark room than by day (as ITU-R BT.2408's reference white).
//!
//! Nothing changes until Save, which writes the three into Horizon's settings; Cancel
//! or Escape leaves them as they were.
use crate::{
hud::{HudAtlas, HudBatch, rgba},
settings::Settings,
text::Text,
ui,
};
use winit::keyboard::KeyCode;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Step {
Black,
Peak,
PaperWhite,
}
const STEPS: [Step; 3] = [Step::Black, Step::Peak, Step::PaperWhite];
/// What the options screen does after an input here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
Nothing,
Click,
Changed,
Save,
Cancel,
}
pub struct Calibration {
step: usize,
/// Nits, in the order of `STEPS`.
values: [f32; 3],
/// Which values were Auto (peak, paper white): saved as Auto again unless the
/// player moved them.
auto: [Option<f32>; 2],
dragging: bool,
}
/// Height of the band of controls at the bottom, in GUI pixels.
const BAND: f32 = 84.0;
impl Step {
/// Range and step of the slider, in nits: the settings' own.
fn range(self) -> (f32, f32, f32) {
match self {
Step::Black => (0.0, 1.0, 0.01),
Step::Peak => (200.0, 2000.0, 50.0),
Step::PaperWhite => (10.0, 480.0, 10.0),
}
}
fn name(self) -> &'static str {
match self {
Step::Black => "Black Level",
Step::Peak => "Peak Brightness",
Step::PaperWhite => "Paper White",
}
}
fn hint(self) -> &'static str {
match self {
Step::Black => "Lower it until the logo just disappears into the black.",
Step::Peak => "Raise it until the logo just disappears into the bright square.",
Step::PaperWhite => {
"Make the card a white that's comfortable to look at for a long time, in the light you play in."
}
}
}
/// `hdr::Calibration::step`'s number.
pub fn number(self) -> u32 {
match self {
Step::Black => 1,
Step::Peak => 2,
Step::PaperWhite => 3,
}
}
}
struct Layout {
top: f32,
slider: (f32, f32, f32),
cancel: (f32, f32, f32),
back: (f32, f32, f32),
next: (f32, f32, f32),
}
fn layout(w: f32, h: f32) -> Layout {
let top = (h - BAND).floor();
let cx = (w / 2.0).floor();
let by = top + 58.0;
Layout {
top,
slider: (cx - 100.0, top + 32.0, 200.0),
cancel: (cx - 152.0, by, 98.0),
back: (cx - 49.0, by, 98.0),
next: (cx + 54.0, by, 98.0),
}
}
fn over(cursor: (f32, f32), (x, y, w): (f32, f32, f32)) -> bool {
ui::hovered(cursor, x, y, w, 20.0)
}
impl Calibration {
pub fn new(s: &Settings) -> Self {
let g = &s.horizon;
Self {
step: 0,
values: [
g.hdr_black_level as f32 / 100.0,
g.peak_nits() as f32,
g.paper_white_nits() as f32,
],
auto: [
(g.hdr_peak == 0).then_some(g.peak_nits() as f32),
(g.hdr_paper_white == 0).then_some(g.paper_white_nits() as f32),
],
dragging: false,
}
}
fn current(&self) -> Step {
STEPS[self.step]
}
fn set(&mut self, v: f32) {
let (min, max, step) = self.current().range();
self.values[self.step] = (min + ((v - min) / step).round() * step).clamp(min, max);
}
fn label(&self) -> Text {
let v = self.values[self.step];
Text::literal(match self.current() {
Step::Black => format!("Black Level: {v:.2} nits"),
step => format!("{}: {v:.0} nits", step.name()),
})
}
/// The step, its nits and where the controls start (a share of the height), for
/// HDR output's test picture.
pub fn picture(&self, h: f32) -> (Step, f32, f32) {
(
self.current(),
self.values[self.step],
layout(0.0, h).top / h.max(1.0),
)
}
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
let (w, h) = (batch.width, batch.height);
let l = layout(w, h);
// Above the band HDR output draws the test picture over whatever is here.
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
let step = self.current();
let title = Text::literal(format!(
"HDR Calibration {}/{}: {}",
self.step + 1,
STEPS.len(),
step.name()
));
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
let hint = Text::literal(step.hint());
ui::centered_text(
batch,
atlas,
&hint,
0.0,
l.top + 18.0,
w,
rgba(160, 160, 160, 255),
);
let (min, max, _) = step.range();
let (x, y, sw) = l.slider;
ui::slider(
batch,
atlas,
x,
y,
sw,
&self.label(),
(self.values[self.step] - min) / (max - min),
over(cursor, l.slider) || self.dragging,
true,
self.dragging,
);
let last = self.step + 1 == STEPS.len();
for (rect, label, active) in [
(l.cancel, "Cancel", true),
(l.back, "Back", self.step > 0),
(l.next, if last { "Save" } else { "Next" }, true),
] {
let hover = over(cursor, rect) && active;
ui::button(
batch,
atlas,
rect.0,
rect.1,
rect.2,
20.0,
&Text::literal(label),
active,
hover,
);
}
}
/// A press, or a drag with `drag`, at `cursor`.
pub fn click(&mut self, cursor: (f32, f32), (w, h): (f32, f32), drag: bool) -> Outcome {
let l = layout(w, h);
let (x, _, sw) = l.slider;
if drag {
if !self.dragging {
return Outcome::Nothing;
}
let (min, max, _) = self.current().range();
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
return Outcome::Changed;
}
if over(cursor, l.slider) {
self.dragging = true;
let (min, max, _) = self.current().range();
self.set(min + ui::slider_value(x, sw, cursor.0) * (max - min));
return Outcome::Changed;
}
if over(cursor, l.cancel) {
return Outcome::Cancel;
}
if over(cursor, l.back) && self.step > 0 {
self.step -= 1;
return Outcome::Click;
}
if over(cursor, l.next) {
return self.next();
}
Outcome::Nothing
}
fn next(&mut self) -> Outcome {
if self.step + 1 == STEPS.len() {
return Outcome::Save;
}
self.step += 1;
Outcome::Click
}
/// The slider let go: true if it was held (it clicks on release, as sliders do).
pub fn release(&mut self) -> bool {
std::mem::take(&mut self.dragging)
}
/// Left and Right step the slider, Enter goes on, Escape cancels.
pub fn key(&mut self, key: KeyCode) -> Outcome {
let (_, _, step) = self.current().range();
match key {
KeyCode::ArrowLeft | KeyCode::ArrowDown => {
self.set(self.values[self.step] - step);
Outcome::Changed
}
KeyCode::ArrowRight | KeyCode::ArrowUp => {
self.set(self.values[self.step] + step);
Outcome::Changed
}
KeyCode::Enter | KeyCode::NumpadEnter => self.next(),
KeyCode::Escape => Outcome::Cancel,
_ => Outcome::Nothing,
}
}
/// Writes what the player landed on into Horizon's settings.
pub fn save(&self, s: &mut Settings) {
let g = &mut s.horizon;
g.hdr_black_level = (self.values[0] * 100.0).round() as u32;
let [peak, paper_white] = [1, 2].map(|i| match self.auto[i - 1] {
Some(auto) if auto == self.values[i] => 0,
_ => self.values[i].round() as u32,
});
g.hdr_peak = peak;
g.hdr_paper_white = paper_white;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn calibration_saves_only_on_save() {
let mut s = Settings::default();
s.horizon.hdr_black_level = 5;
let before = s.horizon.clone();
let mut c = Calibration::new(&s);
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
assert!((c.picture(100.0).1 - 0.03).abs() < 1e-6);
assert_eq!(c.key(KeyCode::Escape), Outcome::Cancel);
assert_eq!(s.horizon, before);
// Black, then Peak (one step up), then Paper White, then Save.
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
assert_eq!(c.key(KeyCode::ArrowRight), Outcome::Changed);
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
assert_eq!(c.picture(100.0).0, Step::PaperWhite);
assert_eq!(c.key(KeyCode::Enter), Outcome::Save);
c.save(&mut s);
assert_eq!(s.horizon.hdr_black_level, 3);
assert_eq!(s.horizon.hdr_peak, before.peak_nits() + 50);
assert_eq!(s.horizon.hdr_paper_white, before.hdr_paper_white);
}
}
+126 -5
View File
@@ -10,6 +10,8 @@ use std::{
};
use wgpu::util::DeviceExt;
mod pack_text;
/// A sprite's atlas region, and its size in GUI pixels: the size of the
/// default resources' texture of that name, whatever the resolution of the
/// pack that replaced it (vanilla's GUI code draws in those units: `blit`
@@ -78,6 +80,9 @@ pub struct HudAtlas {
pub glint: (Vec<u8>, u32, u32),
/// `misc/enchanted_glint_armor`, the armour layers' `GlintSampler`.
pub glint_armor: (Vec<u8>, u32, u32),
/// A resource pack's own `core/text` shader for the GUI's text, translated;
/// none draws the text natively.
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
white: Rect,
}
/// Atlas rows reserved for glyphs rasterized at run time.
@@ -330,7 +335,7 @@ impl HudAtlas {
/// The atlas of a resource root that layers packs over `defaults` (the
/// game's own resources), whose textures give the sprites' GUI sizes.
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
Self::pack(
let mut atlas = Self::pack(
root,
defaults,
&[
@@ -347,7 +352,9 @@ impl HudAtlas {
"map",
],
true,
)
)?;
atlas.text_shader = pack_text::load(root, defaults);
Ok(atlas)
}
/// Empty slots written at run time: map textures (game/maps.rs), the
/// minimap, downloaded skins, world and pack icons and the on-screen keyboard's text.
@@ -408,6 +415,61 @@ impl HudAtlas {
atlas.pixels = Vec::new();
Ok(atlas)
}
/// The boot screen's atlas, before any pack loads: the theme's run-time
/// glyphs (`horizon/ui-text`), a white sprite and the glyph rows over
/// transparent black. Nothing from a pack: the boot screen draws with
/// the theme's bundled faces, so the font here has no sheets to read.
pub fn boot() -> Self {
let (width, height) = (1024, 1024);
let text_rows = 512;
let glyph_top = text_rows + 4;
let mut pixels = vec![0u8; (width * height * 4) as usize];
// The white sprite the fills and the font's underline pass sample.
for row in 0..4 {
let at = ((text_rows + row) * width * 4) as usize;
pixels[at..at + 16].fill(255);
}
let rect = |u0: u32, v0: u32, u1: u32, v1: u32, w: u32, h: u32| Rect {
u0: u0 as f32 / width as f32,
v0: v0 as f32 / height as f32,
u1: u1 as f32 / width as f32,
v1: v1 as f32 / height as f32,
w,
h,
};
let mut sprites = HashMap::new();
sprites.insert(
"horizon/white".into(),
rect(0, text_rows, 4, glyph_top, 4, 4),
);
sprites.insert(
"horizon/ui-text".into(),
rect(0, 0, width, text_rows, width, text_rows),
);
let white = sprites["horizon/white"];
Self {
font: crate::font::Font::load(
Path::new("horizon-boot"),
&sprites,
&pixels,
(width, height),
(glyph_top, height - glyph_top),
white,
),
pixels,
width,
height,
sprites,
translucent: HashSet::new(),
flags: HashMap::new(),
object_skins: HashMap::new(),
sprite_animations: Vec::new(),
glint: (vec![0; 4], 1, 1),
glint_armor: (vec![0; 4], 1, 1),
white,
text_shader: None,
}
}
/// Shelf placements of `images` (tallest first) in the narrowest atlas
/// width from 4096 up that keeps the atlas, glyph rows included, within
/// `max` texels a side; and the first row free for glyphs. High-resolution
@@ -669,6 +731,7 @@ impl HudAtlas {
sprite_animations,
glint,
glint_armor,
text_shader: None,
font,
white,
})
@@ -694,6 +757,9 @@ pub struct HudVertex {
/// The `GLINT_TEXTURING` UV into the repeating glint texture, unused
/// unless `uv.x >= 2`.
pub glint_uv: [f32; 2],
/// 1 for the GUI's text (`RenderPipelines.GUI_TEXT`), which a resource
/// pack's text shader draws when it has one.
pub text: u32,
}
/// Sutherland-Hodgman against one half plane, interpolating every attribute.
fn clip_polygon(poly: &[HudVertex], axis: usize, limit: f32, keep_below: bool) -> Vec<HudVertex> {
@@ -750,6 +816,9 @@ pub struct HudBatch {
pub end_portal: EndPortalFill,
/// `options.glintStrength` for the glint pass (`GlintAlpha`).
pub glint_alpha: f32,
/// A resource pack text shader's `GameTime`: the day's fraction
/// (`gameTime % 24000` and the partial tick, over 24000).
pub game_time: f32,
/// Screen's first vertex: the preceding HUD is part of its blurred background.
pub blur_at: u32,
/// Player object profiles encountered while painting text this frame.
@@ -804,6 +873,7 @@ impl HudBatch {
blends: Vec::new(),
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
game_time: 0.0,
blur_at: 0,
inline_profiles: Vec::new(),
}
@@ -881,6 +951,7 @@ impl HudBatch {
color,
depth: 0.0,
glint_uv: [0.0, 0.0],
text: 0,
});
}
}
@@ -900,6 +971,7 @@ impl HudBatch {
color,
depth: 0.0,
glint_uv: glint_uv[i],
text: 0,
});
}
}
@@ -911,6 +983,7 @@ impl HudBatch {
color,
depth: p[i][2],
glint_uv: [0.0, 0.0],
text: 0,
});
}
}
@@ -1051,6 +1124,10 @@ impl HudBatch {
[uv(cx0, cy0), uv(cx1, cy0), uv(cx1, cy1), uv(cx0, cy1)],
tl.color,
);
let end = self.vertices.len();
for v in &mut self.vertices[end - 6..] {
v.text = tl.text;
}
}
moved.push(self.vertices.len() as u32);
let at = |v: u32| moved[((v as usize).saturating_sub(start) / 6).min(moved.len() - 1)];
@@ -1110,6 +1187,7 @@ impl HudBatch {
color: c[i],
depth: 0.0,
glint_uv: [0.0, 0.0],
text: 0,
});
}
}
@@ -1338,6 +1416,7 @@ impl HudBatch {
}
}
};
let glyph = self.vertices.len();
let start = self.vertices.len();
draw(self, 0.0);
lift(self, start, base);
@@ -1355,6 +1434,11 @@ impl HudBatch {
self.rect_uv(x0, py + 8.0, px + *advance - x0, 1.0, white, col);
}
lift(self, start, base + 0.01);
if !world {
for v in &mut self.vertices[glyph..] {
v.text = 1;
}
}
}
}
cursor - x
@@ -1490,6 +1574,10 @@ pub struct HudRenderer {
uniform: wgpu::Buffer,
buffer: wgpu::Buffer,
capacity: u64,
format: wgpu::TextureFormat,
atlas_view: wgpu::TextureView,
/// The resource pack's text shader, when it has one that works.
pack_text: Option<pack_text::PackText>,
}
impl HudRenderer {
pub fn new(
@@ -1716,8 +1804,21 @@ impl HudRenderer {
uniform,
buffer,
capacity,
format,
atlas_view: view.clone(),
pack_text: None,
})
}
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
pub fn set_text_shader(
&mut self,
device: &wgpu::Device,
shader: Option<&horizon_shaders::CoreProgram>,
) {
self.pack_text = shader.and_then(|shader| {
pack_text::PackText::new(device, self.format, &self.atlas_view, shader)
});
}
pub fn animate_sprites(
&mut self,
uploads: &mut crate::gfx::upload::Uploads,
@@ -1834,6 +1935,9 @@ impl HudRenderer {
batch.end_portal,
)),
);
if let Some(pack_text) = &self.pack_text {
pack_text.prepare(uploads, encoder, batch, size);
}
}
/// Draw the batch `prepare` staged.
pub fn draw(
@@ -1915,15 +2019,31 @@ impl HudRenderer {
pass.set_scissor_rect(x, y, w, h);
}
for (range, blend) in &runs[at..end] {
pass.set_pipeline(match blend {
let pipeline = match blend {
Blend::Alpha => &self.pipeline,
Blend::Invert => &self.invert_pipeline,
Blend::Additive => &self.additive_pipeline,
Blend::Depth(_) => &self.depth_pipeline,
Blend::Vignette => &self.vignette_pipeline,
Blend::EndPortal => &self.end_portal_pipeline,
});
pass.draw(range.clone(), 0..1);
};
match &self.pack_text {
Some(pack_text) if *blend == Blend::Alpha => {
for (part, text) in pack_text::text_runs(&batch.vertices, range.clone()) {
if text {
pack_text.draw(&mut pass, part);
pass.set_bind_group(0, &self.group, &[]);
} else {
pass.set_pipeline(pipeline);
pass.draw(part, 0..1);
}
}
}
_ => {
pass.set_pipeline(pipeline);
pass.draw(range.clone(), 0..1);
}
}
}
at = end;
}
@@ -2649,6 +2769,7 @@ mod tests {
blends: Vec::new(),
end_portal: EndPortalFill::default(),
glint_alpha: 0.75,
game_time: 0.0,
blur_at: 0,
inline_profiles: Vec::new(),
};
@@ -0,0 +1,325 @@
//! A resource pack's `core/text` shader on the GUI's text (`RenderPipelines.GUI_TEXT`, the shader
//! with `IS_GUI`): servers' HUDs that their pack's vertex shader moves to the screen's corners,
//! and text effects in its fragment shader. Without one the text draws natively.
use std::path::Path;
use std::sync::Arc;
use horizon_shaders::{CoreProgram, Glue};
use super::{HudBatch, HudVertex};
/// GUI text's vertex format (`POSITION_TEX_COLOR`) from the HUD's vertices. A glyph is two
/// triangles here, its corners top left, top right, bottom right, top left, bottom right, bottom
/// left; vanilla's quads go top left, bottom left, bottom right, top right, which is what
/// `gl_VertexID % 4` tells a pack's shader. The draw passes its first vertex as its instance.
const GLUE: Glue = Glue {
inputs: &[("Position", "vec3"), ("Color", "vec4"), ("UV0", "vec2")],
prelude: "layout(location = 0) in vec2 hz_pos;\n\
layout(location = 1) in vec2 hz_uv;\n\
layout(location = 2) in vec4 hz_color;\n\
const int hz_corner[6] = int[6](0, 3, 2, 0, 2, 1);\n\
int hz_vertex_id() {\n\
int i = int(gl_VertexIndex) - int(gl_InstanceIndex);\n\
return i / 6 * 4 + hz_corner[i % 6];\n\
}",
setup: "Position = vec3(hz_pos, 0.0); Color = hz_color; UV0 = hz_uv;",
// GL's clip depth runs from -w to w, wgpu's from 0 to w; GUI text has no depth test.
finish: "gl_Position.z = 0.5 * gl_Position.w;",
vertex_id: "hz_vertex_id()",
};
const VERTEX: &str = "assets/minecraft/shaders/core/text.vsh";
const FRAGMENT: &str = "assets/minecraft/shaders/core/text.fsh";
/// The packs' text shader over `defaults` (the game's own resources, which have vanilla's and
/// its includes), when a pack replaces either stage.
pub(super) fn load(root: &Path, defaults: &Path) -> Option<Arc<CoreProgram>> {
if root == defaults {
return None;
}
let read = |base: &Path, path: &str| std::fs::read_to_string(base.join(path)).ok();
let own =
|path: &str| read(root, path).filter(|text| read(defaults, path).as_ref() != Some(text));
let (own_vertex, own_fragment) = (own(VERTEX), own(FRAGMENT));
if own_vertex.is_none() && own_fragment.is_none() {
return None;
}
let vertex = own_vertex.or_else(|| read(defaults, VERTEX))?;
let fragment = own_fragment.or_else(|| read(defaults, FRAGMENT))?;
let include = |id: &str| {
let (namespace, path) = id.split_once(':')?;
let path = format!("assets/{namespace}/{path}");
read(root, &path).or_else(|| read(defaults, &path))
};
match horizon_shaders::translate_core(&vertex, &fragment, &["IS_GUI"], &include, &GLUE) {
Ok(program) => {
log::info!("the resource pack's text shader draws the GUI's text");
Some(Arc::new(program))
}
Err(e) => {
log::warn!("the resource pack's text shader is not used: {e}");
None
}
}
}
/// Each uniform block's offset in the one buffer (`core_shader::BLOCKS` order).
const BLOCK_OFFSETS: [u64; 4] = [0, 256, 512, 768];
const BLOCK_SIZES: [u64; 4] = [48, 64, 160, 48];
/// The pipeline and its resources.
pub(super) struct PackText {
pipeline: wgpu::RenderPipeline,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
}
impl PackText {
pub(super) fn new(
device: &wgpu::Device,
format: wgpu::TextureFormat,
atlas: &wgpu::TextureView,
program: &CoreProgram,
) -> Option<Self> {
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = |label, module: &naga::Module| {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module.clone())),
})
};
let vertex = module("Resource pack text (vertex)", &program.vertex);
let fragment = module("Resource pack text (fragment)", &program.fragment);
let visibility = wgpu::ShaderStages::VERTEX_FRAGMENT;
let mut entries: Vec<wgpu::BindGroupLayoutEntry> = (0..4)
.map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
})
.collect();
for (_, binding) in horizon_shaders::SAMPLERS {
entries.push(wgpu::BindGroupLayoutEntry {
binding,
visibility,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
});
entries.push(wgpu::BindGroupLayoutEntry {
binding: binding + 1,
visibility,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
});
}
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Resource pack text"),
entries: &entries,
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Resource pack text uniforms"),
size: 1024,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// The font textures' nearest filtering; every sampler a pack names reads the atlas.
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Resource pack text"),
..Default::default()
});
let mut resources: Vec<wgpu::BindGroupEntry> = (0..4)
.map(|i| wgpu::BindGroupEntry {
binding: i as u32,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &uniform,
offset: BLOCK_OFFSETS[i],
size: wgpu::BufferSize::new(BLOCK_SIZES[i]),
}),
})
.collect();
for (_, binding) in horizon_shaders::SAMPLERS {
resources.push(wgpu::BindGroupEntry {
binding,
resource: wgpu::BindingResource::TextureView(atlas),
});
resources.push(wgpu::BindGroupEntry {
binding: binding + 1,
resource: wgpu::BindingResource::Sampler(&sampler),
});
}
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Resource pack text"),
layout: &layout,
entries: &resources,
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Resource pack text"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let attributes = wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Resource pack text"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &vertex,
entry_point: Some("main"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<HudVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &fragment,
entry_point: Some("main"),
compilation_options: Default::default(),
// BlendFunction.TRANSLUCENT.
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
if let Some(e) = pollster::block_on(scope.pop()) {
log::warn!("the resource pack's text shader is not used: {e}");
return None;
}
Some(Self {
pipeline,
group,
uniform,
})
}
/// The frame's blocks, as `GuiRenderer` sets them for the GUI.
pub(super) fn prepare(
&self,
uploads: &mut crate::gfx::upload::Uploads,
encoder: &mut wgpu::CommandEncoder,
batch: &HudBatch,
size: [u32; 2],
) {
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size));
}
/// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own
/// pipeline and bind group again after.
pub(super) fn draw(&self, pass: &mut wgpu::RenderPass<'_>, range: std::ops::Range<u32>) {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.group, &[]);
pass.draw(range.clone(), range.start..range.start + 1);
}
}
/// The std140 blocks: `Globals` (`GlobalSettingsUniform.update`: the camera at the origin,
/// `GlintAlpha`, `GameTime`, `ScreenSize`), `Projection` (`GuiRenderer`'s orthographic GUI
/// projection, 1000 to 11000 deep, in GL's depth range), `DynamicTransforms` (the GUI's model view
/// moving 11000 back, colour modulator white) and an empty `Fog`.
fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
let mut data = vec![0f32; 256];
let globals = 0;
data[globals + 3] = batch.glint_alpha;
data[globals + 7] = batch.game_time;
data[globals + 8] = size[0] as f32;
data[globals + 9] = size[1] as f32;
// Matrix4f.setOrtho(0, width, height, 0, near, far) with the GUI's scale, columns.
let (w, h) = (size[0] as f32 / batch.scale, size[1] as f32 / batch.scale);
let (near, far) = (1000.0f32, 11000.0f32);
let projection = 64;
data[projection] = 2.0 / w;
data[projection + 5] = -2.0 / h;
data[projection + 10] = 2.0 / (near - far);
data[projection + 12] = -1.0;
data[projection + 13] = 1.0;
data[projection + 14] = (far + near) / (near - far);
data[projection + 15] = 1.0;
let transforms = 128;
for (matrix, z) in [(transforms, -11000.0), (transforms + 16, 0.0)] {
data[matrix] = 1.0;
data[matrix + 5] = 1.0;
data[matrix + 10] = 1.0;
data[matrix + 14] = z;
data[matrix + 15] = 1.0;
}
data[transforms + 32..transforms + 36].fill(1.0);
bytemuck::cast_slice(&data).to_vec()
}
/// `range` split into runs of GUI text and of the rest, in order.
pub(super) fn text_runs(
vertices: &[HudVertex],
range: std::ops::Range<u32>,
) -> Vec<(std::ops::Range<u32>, bool)> {
let mut runs: Vec<(std::ops::Range<u32>, bool)> = Vec::new();
for i in range {
let text = vertices.get(i as usize).is_some_and(|v| v.text != 0);
match runs.last_mut() {
Some((run, t)) if *t == text => run.end = i + 1,
_ => runs.push((i..i + 1, text)),
}
}
runs
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_gui_projection_maps_gui_pixels_to_the_screen() {
let mut batch = HudBatch::new([1920, 1080]);
batch.scale = 4.0;
let data: Vec<f32> = bytemuck::cast_slice(&uniforms(&batch, [1920, 1080])).to_vec();
let column = |matrix: usize, c: usize| &data[matrix + 4 * c..matrix + 4 * c + 4];
let apply = |matrix: usize, v: [f32; 4]| -> [f32; 4] {
std::array::from_fn(|r| (0..4).map(|c| column(matrix, c)[r] * v[c]).sum())
};
// ProjMat * ModelViewMat * (x, y, 0, 1) for the GUI's corners: 480 x 270 GUI pixels.
for ([x, y], [cx, cy]) in [([0.0, 0.0], [-1.0, 1.0]), ([480.0, 270.0], [1.0, -1.0])] {
let clip = apply(64, apply(128, [x, y, 0.0, 1.0]));
assert!(
(clip[0] - cx).abs() < 1e-5 && (clip[1] - cy).abs() < 1e-5,
"{clip:?}"
);
// At GL's far plane, as vanilla's GUI is.
assert!((clip[2] - 1.0).abs() < 1e-4, "{clip:?}");
}
// A pack finds the GUI scale as ScreenSize.x * ProjMat[0][0] / 2.
assert!((data[8] * data[64] / 2.0 - 4.0).abs() < 1e-4);
}
#[test]
fn text_splits_from_the_rest() {
let vertex = |text| HudVertex {
pos: [0.0; 2],
uv: [0.0; 2],
color: 0,
depth: 0.0,
glint_uv: [0.0; 2],
text,
};
let vertices: Vec<HudVertex> = [0, 0, 1, 1, 1, 0].into_iter().map(vertex).collect();
assert_eq!(
text_runs(&vertices, 1..6),
[(1..2, false), (2..5, true), (5..6, false)]
);
}
}
+1
View File
@@ -35,6 +35,7 @@ pub mod gfx;
pub mod graphics;
pub mod graphics_style;
pub mod graphics_ui;
pub mod hdr_calibration;
pub mod hud;
pub mod hud_layout;
pub mod hud_overlays;
+58 -4
View File
@@ -40,8 +40,17 @@ fn sampling_threads(cores: usize) -> usize {
/// terrain starts one sample job at most this often and no detail: it would
/// otherwise take the cores the near chunks and the frame need.
const HELD_SAMPLE_STEP: std::time::Duration = std::time::Duration::from_millis(500);
/// The coarsest tile level: `Plan` roots cover at most 1024 chunks around.
pub(crate) const MAX_LEVEL: u8 = 10;
/// The coarsest tile level: `Plan` roots cover at most `MAX_CHUNKS` chunks
/// around (a level-22 tile is 67 million blocks wide: the whole world).
pub(crate) const MAX_LEVEL: u8 = 22;
/// Tiles this far from the camera (1024 chunks, as far as far terrain went
/// before it covered the world) are planned with `WORLD_SPLIT`.
const WORLD_START: i32 = 1024 * 16;
/// How many tile widths away from the camera a tile of the world-wide terrain
/// splits (`Plan::visit`: cells then span about 1/48 of the distance).
const WORLD_SPLIT: i32 = 3;
/// The farthest `Plan` reaches, in chunks.
pub const MAX_CHUNKS: u32 = 1 << 22;
/// The coarsest tile level whose chunks are generated in full (trees,
/// structures) rather than sampled: cells of up to 4 blocks, out to about
/// 1024 blocks at Full far detail.
@@ -417,7 +426,7 @@ impl Plan {
elevation: i32,
reduced: bool,
) -> Self {
let range = distance.min(1024) as i32 * 16;
let range = distance.min(MAX_CHUNKS) as i32 * 16;
let mut level = 0;
while (16 << level) < range {
level += 1;
@@ -454,7 +463,16 @@ impl Plan {
// Keep distant cells small on screen; finest tiles retain individual
// blocks. Reduced keeps them twice as large (a quarter of the cells).
let split = if previous.split.contains(&key) { 10 } else { 8 };
let threshold = key.size() * if reduced { split / 2 } else { split };
// Beyond the old 1024-chunk limit the cells are as large as `reduced`'s:
// the whole world is 22 levels, not all of them worth full cells.
let threshold = key.size()
* if key.distance(camera) >= WORLD_START {
WORLD_SPLIT
} else if reduced {
split / 2
} else {
split
};
if key.level > 0
&& (key.distance(camera) < detail * 16 + 16
|| key.distance(camera).max(self.elevation) < threshold)
@@ -1744,6 +1762,42 @@ mod tests {
);
}
#[test]
fn world_wide_plans_stay_small_and_cover_every_level() {
// About 10 000 nodes at a corner of the world, 9 000 at its centre.
const WORLD_PLAN_NODES: usize = 16_384;
for (camera, elevation) in [
([0, 0], 0),
([115, 135], 49_000_000),
([-30_000_000, 29_999_000], 90),
] {
let plan =
Plan::with_previous(camera, 12, MAX_CHUNKS, &Plan::default(), elevation, false);
let reduced =
Plan::with_previous(camera, 12, MAX_CHUNKS, &Plan::default(), elevation, true);
let top = plan.nodes.iter().map(|k| k.level).max().unwrap();
assert_eq!(top, MAX_LEVEL, "{camera:?} y={elevation}: roots stop short");
assert!(
plan.nodes.len() < WORLD_PLAN_NODES,
"{camera:?} y={elevation}: {} nodes",
plan.nodes.len()
);
assert!(
reduced.nodes.len() <= plan.nodes.len(),
"{camera:?} y={elevation}: reduced has {} nodes, full {}",
reduced.nodes.len(),
plan.nodes.len()
);
// Every level from the finest planned one up to the roots has tiles.
let finest = plan.nodes.iter().map(|k| k.level).min().unwrap();
for level in finest..=MAX_LEVEL {
assert!(
plan.nodes.iter().any(|k| k.level == level),
"{camera:?} y={elevation}: no level-{level} tile"
);
}
}
}
#[test]
fn aerial_distance_avoids_unseen_full_detail_outside_the_near_area() {
let ground = Plan::new([0, 0], 12, 1024);
let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024, false);
+153 -4
View File
@@ -304,6 +304,34 @@ fn mesh_columns_with_neighbors(
});
mesh_columns_with_edges(key, columns, covered, covered_bounds, &edges, &[], material)
}
/// From this tile level up a cell is 64 blocks wide or more: the strata
/// inside the ground cannot be told apart, so a column is its surface.
const HEIGHTMAP_LEVEL: u8 = 6;
/// `column` as a heightmap: its top solid span reaching down to the bottom of
/// the world (so the walls toward lower neighbours are one face of the
/// surface's material, merged along the cliff), and the fluid over it.
fn heightmap(column: &Column, mut fluid: impl FnMut(&Span) -> bool) -> Column {
let Some((top, below)) = column.spans.split_last() else {
return column.clone();
};
let ground = |span: &Span| Span {
min: crate::chunk::MIN_Y as i16,
..*span
};
let mut spans = Vec::with_capacity(2);
match below.last() {
Some(under) if fluid(top) => {
spans.push(ground(under));
spans.push(*top);
}
_ if fluid(top) => spans.push(*top),
_ => spans.push(ground(top)),
}
Column {
surface: column.surface,
spans,
}
}
fn mesh_columns_with_edges(
key: TileKey,
columns: &[Column],
@@ -314,6 +342,28 @@ fn mesh_columns_with_edges(
mut material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
) -> SectionMesh {
assert_eq!(columns.len(), CELLS * CELLS);
let heightmap_columns;
let heightmap_neighbors;
let (columns, neighbors) = if key.level >= HEIGHTMAP_LEVEL {
let [ox, _, oz] = key.origin();
let mut fluid = |span: &Span| material(span, Direction::Up, [ox, oz]).fluid != 0;
heightmap_columns = columns
.iter()
.map(|column| heightmap(column, &mut fluid))
.collect::<Vec<_>>();
heightmap_neighbors = neighbors.each_ref().map(|side| {
side.iter()
.map(|edge| EdgeColumn {
start: edge.start,
end: edge.end,
column: heightmap(&edge.column, &mut fluid),
})
.collect::<Vec<_>>()
});
(&heightmap_columns[..], &heightmap_neighbors)
} else {
(columns, neighbors)
};
let mut count = CELLS * CELLS;
let edge_offsets = std::array::from_fn::<_, 4, _>(|side| {
let start = count;
@@ -336,7 +386,10 @@ fn mesh_columns_with_edges(
step = step.min(other.size());
}
}
step
// The cells of the coarsest tiles are thousands of blocks wide: a
// few steps each keep the work bounded, the cut around finer terrain
// coarse for as long as such a tile stands in for its children.
step.max(stride / MAX_STEPS)
};
let filled = fill_buried_gaps(
columns,
@@ -944,20 +997,35 @@ fn merge(rects: &mut Vec<Rect>) {
/// The widest face `emit` writes, in blocks: its UVs, counted from the face's
/// corner, must stay within `TerrainVertex::tiled`'s range.
const MAX_FACE: f32 = 1024.0;
/// A face that would be more than this many `MAX_FACE` pieces is one quad
/// whose UVs are scaled to fit: a cell of the coarsest tiles is thousands to
/// millions of blocks wide, and the texture repeats within it are far below a
/// pixel.
const MAX_PIECES: f32 = 4.0;
/// Steps along a cell of a tile cut around finer terrain, at most.
const MAX_STEPS: i32 = 32;
fn emit(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) {
let pieces = ((rect.u1 - rect.u0) / MAX_FACE).ceil() * ((rect.v1 - rect.v0) / MAX_FACE).ceil();
if pieces > MAX_PIECES {
let extent = (rect.u1 - rect.u0).max(rect.v1 - rect.v0);
emit_quad(mesh, key, rect, (MAX_FACE / extent).min(1.0));
return;
}
let mut u0 = rect.u0;
while u0 < rect.u1 {
let u1 = rect.u1.min(u0 + MAX_FACE);
let mut v0 = rect.v0;
while v0 < rect.v1 {
let v1 = rect.v1.min(v0 + MAX_FACE);
emit_quad(mesh, key, Rect { u0, v0, u1, v1 });
emit_quad(mesh, key, Rect { u0, v0, u1, v1 }, 1.0);
v0 = v1;
}
u0 = u1;
}
}
fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) {
/// `uv_scale` shrinks the UVs, in blocks from the face's corner, of a face
/// too wide for them.
fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect, uv_scale: f32) {
let Rect { u0, v0, u1, v1 } = rect;
let p = f32::from_bits(key.plane);
let mut points = match key.direction {
@@ -994,7 +1062,10 @@ fn emit_quad(mesh: &mut SectionMesh, key: FaceKey, rect: Rect) {
out.extend(points.into_iter().zip(uvs).map(|(pos, uv)| {
TerrainVertex::tiled(
pos,
[uv[0] - corner[0], uv[1] - corner[1]],
[
(uv[0] - corner[0]) * uv_scale,
(uv[1] - corner[1]) * uv_scale,
],
key.layer,
key.color,
key.light,
@@ -1093,6 +1164,84 @@ mod tests {
}
assert_eq!(area, 2500.0 * 15.5);
}
#[test]
fn far_columns_are_heightmaps_of_their_surface_and_water() {
let fluid = |s: &Span| s.state.0 == 2;
let strata = Column {
surface: None,
spans: vec![span(0, 60, 1), span(60, 63, 3), span(63, 64, 4)],
};
let flat = heightmap(&strata, fluid);
assert_eq!(flat.spans, [span(-64, 64, 4)]);
let sea = Column {
surface: None,
spans: vec![span(0, 50, 1), span(50, 62, 2)],
};
let flat = heightmap(&sea, fluid);
assert_eq!(flat.spans, [span(-64, 50, 1), span(50, 62, 2)]);
assert!(heightmap(&columns()[0], fluid).spans.is_empty());
// A rolling landscape of strata: far tiles need a fraction of the quads.
let rolling: Vec<Column> = (0..CELLS * CELLS)
.map(|i| {
let top = 64 + (i * 7 % 5) as i16;
Column {
surface: None,
spans: vec![
span(0, top - 4, 1),
span(top - 4, top - 1, 3),
span(top - 1, top, 4),
],
}
})
.collect();
let quads = |level| {
let key = TileKey { x: 0, z: 0, level };
let mesh = mesh_columns(key, &rolling, &FxHashSet::default(), None, material);
mesh.solid.len() / 4
};
let (voxel, height) = (quads(HEIGHTMAP_LEVEL - 1), quads(HEIGHTMAP_LEVEL));
assert!(height * 3 < voxel * 2, "{height} quads against {voxel}");
}
#[test]
fn world_sized_faces_are_one_quad_with_uvs_that_fit() {
let mut mesh = SectionMesh::default();
let key = FaceKey {
direction: Direction::Up,
plane: 64.0f32.to_bits(),
overlay: 0,
layer: 1,
light: 0,
color: 0,
translucent: false,
};
let size = 4_194_304.0;
emit(
&mut mesh,
key,
Rect {
u0: 0.0,
v0: 0.0,
u1: size,
v1: size / 2.0,
},
);
assert_eq!(mesh.solid.len(), 4);
let corners: Vec<[f32; 3]> = mesh.solid.iter().map(|v| v.pos).collect();
assert!(
corners.iter().any(|p| p[0] == size && p[2] == size / 2.0),
"{corners:?}"
);
let top = mesh
.solid
.iter()
.map(tiled_uv)
.fold(0.0f32, |m, uv| m.max(uv[0]).max(uv[1]));
assert!(top <= MAX_FACE + 1.0, "UV reaches {top}");
assert!(
top > MAX_FACE / 4.0,
"the texture still repeats across the face"
);
}
fn normal(q: &[TerrainVertex; 4]) -> glam::Vec3 {
let p = |i: usize| glam::Vec3::from(q[i].pos);
(p(1) - p(0)).cross(p(2) - p(0))
+69 -2
View File
@@ -1178,6 +1178,8 @@ pub enum Page {
Graphics,
GraphicsAdvanced,
GraphicsHdr,
/// HDR calibration, full screen (`calibration`).
HdrCalibration,
/// Horizon's controller settings and button binds.
Controller,
PadBinds,
@@ -1356,6 +1358,10 @@ pub struct OptionsUi {
/// Scroll the selected language into the middle on the next layout.
center_language: bool,
pub graphics: crate::graphics_ui::GraphicsUi,
/// HDR calibration while its page is open.
pub calibration: Option<crate::hdr_calibration::Calibration>,
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
pub calibration_fullscreen: Option<bool>,
/// The AI Agent page's status lines (set by the game while it shows).
pub agent_status: Vec<String>,
}
@@ -1391,6 +1397,8 @@ impl Default for OptionsUi {
in_world: false,
center_language: false,
graphics: Default::default(),
calibration: None,
calibration_fullscreen: None,
agent_status: Vec::new(),
}
}
@@ -1602,7 +1610,9 @@ impl OptionsUi {
Page::Mods => "horizon.pages.mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr => "options.videoTitle",
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
"options.videoTitle"
}
Page::Controller => "horizon.pages.controller",
Page::PadBinds => "horizon.pages.controller_binds",
Page::Extras => "horizon.pages.extras",
@@ -2611,6 +2621,12 @@ impl OptionsUi {
);
return;
}
if self.page() == Page::HdrCalibration
&& let Some(c) = &self.calibration
{
c.draw(batch, atlas, cursor);
return;
}
if let Some(level) = Self::graphics_level(self.page()) {
self.graphics.binding = self.selected_key;
self.graphics
@@ -2870,6 +2886,15 @@ impl OptionsUi {
}
return Vec::new();
}
if self.page() == Page::HdrCalibration
&& let Some(c) = &mut self.calibration
{
if right {
return Vec::new();
}
let outcome = c.click((x, y), size, drag);
return self.calibration_outcome(s, outcome);
}
if let Some(level) = Self::graphics_level(self.page()) {
use crate::graphics_ui::Action;
// Waiting on a key: a mouse button binds, as on Key Binds.
@@ -2893,6 +2918,10 @@ impl OptionsUi {
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenHdr => self.push(Page::GraphicsHdr),
Action::BindKey(name) => self.selected_key = Some(name),
Action::Calibrate => {
self.calibration = Some(crate::hdr_calibration::Calibration::new(s));
self.push(Page::HdrCalibration);
}
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
@@ -3119,7 +3148,8 @@ impl OptionsUi {
// release, even if the pointer has left the widget or its value stayed.
let slider = self.dragging.take().is_some();
let graphics_slider = self.graphics.release();
if slider || graphics_slider {
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
if slider || graphics_slider || calibration_slider {
vec![Event::ButtonClick, Event::Changed]
} else {
Vec::new()
@@ -3277,6 +3307,37 @@ impl OptionsUi {
self.pack_screen.as_mut().expect("made above")
}
/// Escape or Done: leave the current page, applying page results.
/// After an input on HDR calibration: Save keeps what it landed on, Cancel what was
/// set before; both go back to Graphics: HDR.
fn calibration_outcome(
&mut self,
s: &mut Settings,
outcome: crate::hdr_calibration::Outcome,
) -> Vec<Event> {
use crate::hdr_calibration::Outcome;
match outcome {
Outcome::Nothing | Outcome::Changed => Vec::new(),
Outcome::Click => vec![Event::ButtonClick],
Outcome::Save | Outcome::Cancel => {
if let Some(c) = self.calibration.take()
&& outcome == Outcome::Save
{
c.save(s);
}
let mut events = vec![Event::ButtonClick];
events.extend(self.back(s));
events
}
}
}
/// HDR calibration's test picture, while its page is open: the step, its nits and
/// where the controls start (a share of the height `h`).
pub fn calibration_picture(&self, h: f32) -> Option<(crate::hdr_calibration::Step, f32, f32)> {
self.calibration
.as_ref()
.filter(|_| self.page() == Page::HdrCalibration)
.map(|c| c.picture(h))
}
pub fn back(&mut self, s: &mut Settings) -> Vec<Event> {
let mut events = Vec::new();
let page = self.stack.pop().unwrap_or(Page::Main);
@@ -3408,6 +3469,12 @@ impl OptionsUi {
text: Option<&str>,
) -> Vec<Event> {
use winit::keyboard::KeyCode as K;
if self.page() == Page::HdrCalibration
&& let Some(c) = &mut self.calibration
{
let outcome = c.key(key);
return self.calibration_outcome(s, outcome);
}
if self.resource_error.is_some() {
if matches!(key, K::Escape | K::Enter | K::NumpadEnter) {
self.resource_error = None;
@@ -658,15 +658,16 @@ mod tests {
f.write("pack.zip", &archive(&files(b"bbb"), false));
f.rebuild_matches(std::slice::from_ref(&pack));
assert_eq!(fs::read(f.0.join("cached").join(name)).unwrap(), b"bbb");
// The ZIP opens and enumerates, then its unsupported method fails after
// mirror has created directories. No reusable marker survives.
// A central directory whose second name runs past the archive fails
// the build (an unreadable entry alone only costs that file). No
// reusable marker survives.
let mut broken = archive(&files(b"ccc"), false);
let central = broken
.windows(4)
.position(|s| s == [0x50, 0x4b, 0x01, 0x02])
.unwrap();
let second = central + 46 + "pack.mcmeta".len();
broken[second + 10..second + 12].copy_from_slice(&99u16.to_le_bytes());
broken[second + 28..second + 30].copy_from_slice(&u16::MAX.to_le_bytes());
f.write("pack.zip", &broken);
let out = f.0.join("cached");
assert!(
@@ -5,6 +5,7 @@
//! outline, nether portal swirl). Positions are clip-space quads; textures
//! come from the shared 2D atlas and may repeat.
use crate::{
effects::EffectInstance,
hud::{Rect, TextureFlags},
hud_layout::GuiDraw,
};
@@ -271,6 +272,28 @@ impl ScreenEffects {
}
}
/// The nausea effect's blend-out advance: over its last `NAUSEA_BLEND_OUT`
/// ticks it has no effect (`MobEffects.NAUSEA`, `endsWithin(60)`).
const NAUSEA_BLEND_OUT: i32 = 60;
/// `MobEffectInstance.BlendState.hasEffect`: still past the blend-out point,
/// so the effect counts as on (`!endsWithin(60)` for `MobEffects.NAUSEA`).
pub fn nausea_has_effect(e: &EffectInstance) -> bool {
!e.ends_within(NAUSEA_BLEND_OUT)
}
/// A new nausea instance's `BlendState`: from nothing, or, when the server
/// asked for no blending, at once (`MobEffectInstance.skipBlending`'s
/// `BlendState.setImmediate`).
pub fn nausea_blend_added(e: &EffectInstance, blend: bool) -> (f32, f32) {
let factor = if blend || !nausea_has_effect(e) {
0.0
} else {
1.0
};
(factor, factor)
}
#[cfg(test)]
mod tests {
use super::*;
+74
View File
@@ -97,6 +97,50 @@ pub fn profile_skin_url(profile: &serde_json::Value) -> Option<String> {
let (value, signature) = parse_profile(profile).textures?;
unpack(&value, signature, &TEXTURE_URIS).skin
}
/// The textures of a Minecraft services `/minecraft/profile` answer: the
/// `ACTIVE` skin and cape (`MinecraftProfileResponse`). Unsigned, like the
/// local player's own textures, and only URLs under the allowed domains count.
pub fn services_profile_textures(profile: &serde_json::Value) -> Textures {
let active = |key: &str| {
profile
.get(key)?
.as_array()?
.iter()
.find(|t| t.get("state").and_then(|s| s.as_str()) == Some("ACTIVE"))
};
let skin_entry = active("skins");
let skin = skin_entry
.and_then(|t| t.get("url"))
.and_then(|u| u.as_str())
.filter(|url| {
TEXTURE_URIS
.iter()
.any(|u| url.starts_with(&u.replace("{textureId}", "")))
})
.map(str::to_owned);
let slim = skin_entry
.and_then(|t| t.get("variant"))
.and_then(|v| v.as_str())
.is_some_and(|v| v.eq_ignore_ascii_case("slim"));
let cape = active("capes")
.and_then(|t| t.get("url"))
.and_then(|u| u.as_str())
.filter(|url| {
TEXTURE_URIS
.iter()
.any(|u| url.starts_with(&u.replace("{textureId}", "")))
})
.map(str::to_owned);
Textures {
skin,
slim,
cape,
elytra: None,
signed: false,
}
}
/// `profile_skin_url` on a block entity's `profile` NBT (`SkullBlockEntity`):
/// the same `properties` list, held as a compound. A name-only profile (a
/// string tag) has no properties and no skin URL.
@@ -1033,6 +1077,36 @@ mod tests {
assert_eq!(p.dynamic, Some(NameOrId::Name("Notch".into())));
}
#[test]
fn services_profile_answers_give_the_active_skin_and_cape() {
let profile = serde_json::json!({
"skins": [
{"state": "INACTIVE", "url": "https://textures.minecraft.net/texture/old"},
{"state": "ACTIVE", "url": "https://textures.minecraft.net/texture/skin1", "variant": "CLASSIC"}
],
"capes": [{"state": "ACTIVE", "url": "http://textures.minecraft.net/texture/cape1"}]
});
let t = super::services_profile_textures(&profile);
assert_eq!(
t.skin.as_deref(),
Some("https://textures.minecraft.net/texture/skin1")
);
assert_eq!(
t.cape.as_deref(),
Some("http://textures.minecraft.net/texture/cape1")
);
assert!(!t.signed);
let elsewhere = serde_json::json!({"skins": [{"state": "ACTIVE", "url": "https://evil.example/texture/x"}]});
assert_eq!(
super::services_profile_textures(&elsewhere),
super::Textures::default()
);
assert_eq!(
super::services_profile_textures(&serde_json::json!({})),
super::Textures::default()
);
}
/// `getByName` and `getProfileById` answer from the services the
/// discovery document names; a profile resolves through both.
#[test]
+16 -8
View File
@@ -307,6 +307,13 @@ pub fn text_scaled(
pub fn hovered(cursor: (f32, f32), x: f32, y: f32, w: f32, h: f32) -> bool {
cursor.0 >= x && cursor.0 < x + w && cursor.1 >= y && cursor.1 < y + h
}
/// `TextAlignment.CENTER.calculateLeft` at the box's integer centre:
/// `x + w / 2 - width / 2`. Flooring `(w - width) / 2` sits odd-width labels
/// one pixel left of vanilla when `w` is even.
fn centered_left(x: f32, w: f32, width: f32) -> f32 {
x + (w / 2.0).floor() - (width / 2.0).floor()
}
/// Centered text with a shadow, scrolled when wider than `w` (AbstractWidget.renderScrollingString).
pub fn centered_text(
batch: &mut HudBatch,
@@ -320,14 +327,7 @@ pub fn centered_text(
let text = text.resolved();
let width = atlas.font.width_text(&text);
if width <= w {
batch.text_styled(
atlas,
&text,
x + ((w - width) / 2.0).floor(),
y,
color,
true,
);
batch.text_styled(atlas, &text, centered_left(x, w, width), y, color, true);
} else {
// Clip by characters (vanilla scrolls; we show the part that fits).
let mut shown = Text::empty();
@@ -1354,6 +1354,14 @@ mod tests {
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
}
#[test]
fn centered_label_matches_vanilla_integer_center() {
// Even box, odd text: (w - width) / 2 floors one pixel left of w/2 - width/2.
assert_eq!(centered_left(0.0, 200.0, 9.0), 96.0);
assert_eq!(centered_left(0.0, 200.0, 8.0), 96.0);
assert_eq!(centered_left(10.0, 196.0, 9.0), 104.0);
assert_eq!(centered_left(2.0, 196.0, 7.0), 97.0);
}
#[test]
fn slider_track_is_outlined_only_while_dragged() {
let (track, handle) = slider_sprites(true, true, false);
assert_eq!(track, "gui/sprites/widget/slider");
+2 -2
View File
@@ -40,7 +40,7 @@ impl Zip {
let Some(name) = archive.name_for_index(i) else {
continue;
};
let name = name.replace('\\', "/");
let name = name.map_err(invalid)?.replace('\\', "/");
match name.strip_suffix('/') {
Some(folder) => folders.insert(folder.to_owned(), i),
None => entries.insert(name, i),
@@ -374,7 +374,7 @@ mod tests {
.unwrap();
let entry = archive.by_index_raw(index).unwrap();
(
entry.data_start() as usize,
entry.data_start().unwrap() as usize,
entry.compressed_size() as usize,
)
};
+1 -1
View File
@@ -56,7 +56,7 @@ horizon-theme = { path = "../horizon-theme" }
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false, features = ["live"] }
horizon-bench = { path = "../horizon-bench" }
zip = { version = "2", default-features = false, features = ["deflate"] }
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
@@ -196,5 +196,8 @@
"Threads that generate, light and mesh chunks. Next start.": "Threads that generate, light and mesh chunks. Next start.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Mesh data sent to the GPU per frame. Lower is smoother while loading.",
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph."
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
"horizon.loading.opening_world": "Opening world",
"horizon.loading.cancelling_world": "Cancelling world"
}
@@ -11,5 +11,8 @@
"horizon.options.look_deadzone": "Zona muerta de vista",
"horizon.options.button_glyphs": "Símbolos de botones",
"horizon.options.menu_navigation": "Navegación de menús",
"horizon.options.on_screen_keyboard": "Teclado en pantalla"
"horizon.options.on_screen_keyboard": "Teclado en pantalla",
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
"horizon.loading.opening_world": "Abriendo el mundo",
"horizon.loading.cancelling_world": "Cancelando"
}
@@ -11,5 +11,8 @@
"horizon.options.look_deadzone": "Martwa strefa rozglądania",
"horizon.options.button_glyphs": "Symbole przycisków",
"horizon.options.menu_navigation": "Nawigacja po menu",
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa"
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa",
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
"horizon.loading.opening_world": "Otwieranie świata",
"horizon.loading.cancelling_world": "Anulowanie"
}
@@ -195,5 +195,8 @@
"Threads that generate, light and mesh chunks. Next start.": "Потоки, которые генерируют, освещают и строят чанки. Со следующего запуска.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, при сбое — DirectX 12 (Windows) или OpenGL (Linux). Со следующего запуска.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Данных меша в видеокарту за кадр. Меньше — плавнее во время загрузки.",
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком."
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
"horizon.loading.opening_world": "Открытие мира",
"horizon.loading.cancelling_world": "Отмена"
}
@@ -11,5 +11,8 @@
"horizon.options.look_deadzone": "Мертва зона огляду",
"horizon.options.button_glyphs": "Значки кнопок",
"horizon.options.menu_navigation": "Навігація в меню",
"horizon.options.on_screen_keyboard": "Екранна клавіатура"
"horizon.options.on_screen_keyboard": "Екранна клавіатура",
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
"horizon.loading.opening_world": "Відкриття світу",
"horizon.loading.cancelling_world": "Скасування"
}
@@ -0,0 +1,203 @@
{
"horizon.options.controller": "手柄",
"horizon.options.vibration": "振动",
"horizon.options.adaptive_triggers": "自适应扳机",
"horizon.options.lightbar": "光条",
"horizon.options.look_sensitivity": "视角灵敏度",
"horizon.options.invert_x": "反转视角 X 轴",
"horizon.options.invert_y": "反转视角 Y 轴",
"horizon.options.sneak_button": "潜行按钮",
"horizon.options.move_deadzone": "移动摇杆死区",
"horizon.options.look_deadzone": "视角摇杆死区",
"horizon.options.button_glyphs": "按键图标",
"horizon.options.menu_navigation": "菜单导航",
"horizon.options.on_screen_keyboard": "虚拟键盘",
"horizon.options.chat_delay": "聊天延迟",
"horizon.options.lightbar.health": "生命值",
"horizon.options.lightbar.dimension": "维度",
"horizon.options.glyphs.auto": "自动",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "焦点",
"horizon.options.menu_pointer": "指针",
"horizon.options.keyboard.auto": "自动",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "此功能在 Horizon 中尚不可用。",
"horizon.options.search": "搜索设置…",
"horizon.pages.mods": "模组",
"horizon.pages.shader_packs": "光影包",
"horizon.pages.controller": "手柄设置",
"horizon.pages.controller_binds": "手柄按键绑定",
"horizon.pages.extras": "Horizon 附加功能",
"horizon.pages.settings": "Horizon 设置",
"horizon.pages.agent": "AI 代理",
"horizon.pages.settings.tooltip": "Horizon 自带的设置:图形、手柄、附加功能和模组。",
"horizon.pages.graphics.tooltip": "简单的图形设置:每一项都标明了它的开销。",
"horizon.pages.graphics_advanced.tooltip": "每一项图形设置,包括 Horizon 自带的,并标明各自的开销。",
"horizon.pages.controller.tooltip": "手柄:按键、摇杆、振动和菜单导航。",
"horizon.pages.extras.tooltip": "可选的附加功能,默认全部关闭:不开启时游戏保持原版行为。",
"horizon.pages.agent.tooltip": "让你的 AI 代理来玩:本机上的 horizon-ctl 和 MCP 客户端。",
"horizon.pages.shader_packs.tooltip": "来自 shaderpacks 文件夹的光影包:Horizon 自带格式与 shaders/ 格式。同一时间只运行一个。",
"horizon.pages.mods.tooltip": "为 Horizon 制作的模组,来自 horizon-mods 文件夹。每个模组都在沙盒中运行,开启前保持关闭。",
"horizon.pages.open.graphics": "图形…",
"horizon.pages.open.graphics_advanced": "高级图形…",
"horizon.pages.open.shader_packs": "光影包…",
"horizon.pages.open.controller": "手柄设置…",
"horizon.pages.open.extras": "附加功能…",
"horizon.pages.open.mods": "模组…",
"horizon.pages.open.agent": "AI 代理…",
"horizon.pages.open.horizon_graphics": "Horizon 图形…",
"horizon.pages.open.controller_binds": "手柄按键绑定…",
"horizon.pages.open.horizon_settings": "Horizon 设置…",
"horizon.mods.empty": "没有模组:请将 .hmod 文件放入 horizon-mods 文件夹",
"horizon.mods.stopped": "(已停止)",
"horizon.mods.not_built": "(未构建)",
"horizon.mods.dev": "(开发版)",
"horizon.mods.settings": "设置",
"horizon.mods.open_folder": "打开模组文件夹",
"horizon.shader_packs.empty": "没有光影包:请将其放入 shaderpacks 文件夹",
"horizon.shader_packs.kind_horizon": "(Horizon)",
"horizon.agent.copy_details": "复制连接详情",
"horizon.multiplayer.join": "加入",
"horizon.multiplayer.cancel_reconnecting": "取消重新连接",
"horizon.multiplayer.join_friend": "加入好友",
"horizon.multiplayer.invite_code": "邀请码",
"horizon.multiplayer.relay": "中继(可选)",
"Preset": "预设",
"LOD Distance": "LOD 距离",
"Far Detail": "远景细节",
"Cave Culling": "洞穴剔除",
"Entity Culling": "实体剔除",
"Chunk Updates": "区块更新",
"Upload Budget": "上传预算",
"Sky": "天空",
"Fog": "雾",
"Weather": "天气",
"Animated Textures": "动画纹理",
"Present Mode": "呈现模式",
"Frame Pacing": "帧节奏",
"Worker Threads": "工作线程",
"Resolution Scale": "渲染比例",
"Anti-aliasing": "抗锯齿",
"Upscaling": "放大",
"HDR Settings...": "HDR 设置…",
"Paper White": "纸白亮度",
"Peak Brightness": "峰值亮度",
"Black Level": "黑位",
"UI Brightness": "界面亮度",
"Battery Mode": "电池模式",
"Graphics API": "图形 API",
"Performance Overlay": "性能叠加层",
"Full Detail Distance": "全细节距离",
"On": "开",
"Off": "关",
"Unlimited": "无限制",
"Auto": "自动",
"Custom": "自定义",
"Performance": "性能",
"Balanced": "均衡",
"Quality": "质量",
"Ultra": "超高",
"Ultra Performance": "极致性能",
"Native": "原生",
"Full": "完全",
"Reduced": "精简",
"Efficient": "高效",
"Precise": "精确",
"Auto (VSync)": "自动(垂直同步)",
"FIFO": "FIFO",
"Mailbox": "邮箱",
"Immediate": "立即",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS 与图表",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "无开销",
"Low cost": "低开销",
"Medium cost": "中等开销",
"High cost": "高开销",
"Saves time": "节省时间",
"Rendering": "渲染",
"Display": "显示",
"Preferences": "偏好",
"Overlay": "叠加层",
"HDR": "HDR",
"Graphics": "图形",
"Graphics: Advanced": "图形:高级",
"Graphics: HDR": "图形:HDR",
"Advanced...": "高级…",
"Simple...": "简单…",
"Done": "完成",
"This feature is not yet available in Horizon.": "此功能在 Horizon 中尚不可用。",
"Your display or system doesn't offer HDR output.": "你的显示器或系统不支持 HDR 输出。",
"Turn HDR on to use it.": "开启 HDR 后才能使用。",
"%s chunks": "%s 个区块",
"%s per frame": "每帧 %s",
"%s MiB/frame": "每帧 %s MiB",
"%s KiB/frame": "每帧 %s KiB",
"%s nits": "%s 尼特",
"Auto (%s)": "自动(%s)",
"Auto (%s nits)": "自动(%s 尼特)",
"%.2f nits": "%.2f 尼特",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "完整细节的地形范围。LOD 距离可以让本地世界延伸得更远。",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "本地世界的远景粗略地形。集成显卡最多 128 个区块。",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "“精简”把远景画得更粗略:在这块集成显卡上更快。",
"Reduced draws far terrain coarser, for GPUs it slows down.": "“精简”把远景画得更粗略,供带不动它的显卡使用。",
"Field of view. Wider shows more terrain at once.": "视野。越宽同一时间看到的地形越多。",
"Caps frames per second; a cap saves power and heat.": "限制每秒帧数;限制帧率可以省电降温。",
"Waits for the display's refresh: no tearing, a little latency.": "等待显示器的刷新:无画面撕裂,略有延迟。",
"Size of menus and the HUD.": "菜单与 HUD 的大小。",
"Sets the options below together. Changing one makes it Custom.": "一起设置下面的选项。单独改动其中一项会变成“自定义”。",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "熔岩、火与阳光比白色更亮。需要 HDR 显示器。",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "显示器最深的黑色。OLED 为 0;液晶屏会微微发灰。",
"How bright highlights get. Auto: the peak your system reports.": "高光能有多亮。“自动”使用系统报告的峰值。",
"How bright white paper and menus look. Auto: your desktop's white.": "白纸与菜单的亮度。“自动”使用你桌面的白色。",
"How bright the HUD and menus are, against paper white.": "HUD 和菜单相对纸白的亮度。",
"How far mobs, crops and machines keep working. CPU time.": "生物、农作物和机器持续工作的距离。占用 CPU 时间。",
"How far away mobs and items are drawn, as a share of normal.": "生物和物品的绘制距离,按正常值的比例。",
"Skips terrain hidden behind closed sections. Off costs a lot.": "跳过被封闭区块遮挡的地形。关闭的开销很大。",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "隐藏不透明方块后面的模型。实体仍会更新。自定义资源包时应关闭。",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "每帧上传的网格化区块数。越低加载时越流畅。",
"Soft shadows in corners. Costs chunk building time only.": "角落里的柔和阴影。只占用区块构建时间。",
"Smaller copies of block textures for distant terrain: less shimmer.": "为远处地形生成更小的方块纹理副本:减少闪烁。",
"Blends grass and water colours across biomes when building chunks.": "构建区块时在群系之间平滑过渡草地与水的颜色。",
"Cloud layer: off, flat (fast) or 3D (fancy).": "云层:关闭、平面(快速)或 3D(高品质)。",
"How far clouds reach, in chunks.": "云能延伸多远,以区块计。",
"Draws the sky, sun, moon and stars.": "绘制天空、太阳、月亮和星星。",
"Distance fog. Off shows the edge of the loaded world.": "距离雾。关闭后能看到已加载世界的边缘。",
"Draws rain and snow.": "绘制雨和雪。",
"How far rain and snow fall around you.": "你周围雨雪落下的范围。",
"How many particles effects spawn.": "效果生成的粒子数量。",
"Round shadows under mobs and items.": "生物和物品脚下的圆形阴影。",
"Leaves with see-through gaps. Off draws them solid.": "树叶带透光空隙。关闭后画成实心。",
"Sorts water and glass more exactly behind each other.": "更精确地排序互相重叠的水与玻璃。",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "哪些区块重建要等这一帧完成:无、附近的,或你身边的。",
"Animates water, lava, fire and other moving textures.": "让水、熔岩、火等动态纹理动起来。",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "每个屏幕像素对应的画面像素。低于 100% 时 GPU 更省力。",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "平滑锯齿边缘。FXAA 更柔和;TAA 最平滑,但移动物体可能拖影。",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "以更小的尺寸渲染世界再用 TAA 重建。更快,但更模糊。",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "在 HDR 显示器上输出 HDR:熔岩、火与阳光更亮。",
"How frames reach the screen while VSync is off.": "关闭垂直同步时帧画面如何送到屏幕。",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "“高效”会休眠到下一帧;“精确”持续空转,与原版相同。",
"Fills the screen (borderless).": "填满整个屏幕(无边框)。",
"Brightens dark places.": "提亮黑暗的地方。",
"Lowers the frame rate when minimised, or idle too.": "最小化或空闲时降低帧率。",
"Sways the view while walking.": "行走时晃动视角。",
"Fades newly loaded chunks in.": "新加载的区块淡入显示。",
"Darkens the screen's edges.": "加深屏幕边缘的暗角。",
"Shows when the world saves.": "显示世界保存的时机。",
"Shows attack cooldown.": "显示攻击冷却。",
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "限制 60 FPS(菜单或失焦时 30),工作线程减半:更省电。",
"Threads that generate, light and mesh chunks. Next start.": "负责生成、光照与网格化区块的线程数。下次启动生效。",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "优先 Vulkan,失败时用 DirectX 12(Windows)或 OpenGL(Linux)。下次启动生效。",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "每帧发送给 GPU 的网格数据。越低加载时越流畅。",
"Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。",
"horizon.multiplayer.finding_friend": "正在查找好友的世界…",
"horizon.loading.opening_world": "正在打开世界",
"horizon.loading.cancelling_world": "正在取消"
}
@@ -0,0 +1,38 @@
//! The stacks passed to item models for an entity's two arms.
use horizon_client::{
Result,
items::ItemStack,
mobs::{Extra, render},
};
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
pub fn main() -> Result<()> {
let args: Vec<String> = crate::args().collect();
let mut out = BufWriter::new(File::create(&args[2])?);
for line in BufReader::new(File::open(&args[1])?).lines() {
let case: Value = serde_json::from_str(&line?)?;
let mut extra = Extra::default();
let main = case["main"].as_str().ok_or("main")?;
if main != "air" {
extra.set_main_hand_stack(Some(ItemStack::new(&format!("minecraft:{main}"), 1)));
}
let off = case["off"].as_str().ok_or("off")?;
if off != "air" {
extra.off_hand = Some(format!("minecraft:{off}").into());
}
let left = case["left"].as_bool().ok_or("left")?;
for physical_left in [false, true] {
let stack = render::held_item_stack(&extra, physical_left != left);
writeln!(
out,
"{}",
stack.as_ref().map_or("minecraft:air", |s| &s.item)
)?;
}
}
Ok(())
}
@@ -48,6 +48,7 @@ mod frame_limiter_probe;
mod game_loop_probe;
mod ghost_recipe_fixture;
mod held_drop_fixture;
mod held_item_stack_probe;
mod hotbar_fixture;
mod hud_fixture;
mod input_probe;
@@ -101,6 +102,7 @@ mod tooltip_display_fixture;
mod trading_fixture;
mod translucency_fixture;
mod trident_probe;
mod vehicle_container_fixture;
mod weather_fixture;
mod world_border_render_fixture;
mod world_options_fixture;
@@ -144,6 +146,7 @@ probes! {
game_loop_probe,
ghost_recipe_fixture,
held_drop_fixture,
held_item_stack_probe,
hotbar_fixture,
hud_fixture,
input_probe,
@@ -196,6 +199,7 @@ probes! {
trading_fixture,
trident_probe,
translucency_fixture,
vehicle_container_fixture,
weather_fixture,
world_border_render_fixture,
world_options_fixture,
@@ -529,8 +529,12 @@ impl HiveHost for ProbeHive<'_, '_> {
}
fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<Mob> {
let index = self.first + self.added.len();
let mut m =
horizon_client::entity_nbt::mob_from_nbt(tag, self.types, 100_000 + index as u32)?;
let mut m = horizon_client::entity_nbt::mob_from_nbt(
tag,
self.types,
100_000 + index as u32,
None,
)?;
if m.species() != Species::Bee {
return None;
}
@@ -1180,6 +1184,7 @@ pub fn main() -> horizon_client::Result<()> {
block_tags: &items.block_tags,
biomes: &biomes,
mob_griefing: true,
universal_anger: false,
mob_drops: true,
max_entity_cramming: 24,
difficulty: LocalDifficulty::new(s.difficulty, world.day_time, 0, 0.0),
@@ -1426,7 +1431,7 @@ pub fn main() -> horizon_client::Result<()> {
else {
continue;
};
let tag = horizon_client::entity_nbt::mob_to_nbt(&mobs[i], [0; 4], None);
let tag = horizon_client::entity_nbt::mob_to_nbt(&mobs[i], [0; 4], None, None);
let flower = horizon_client::mobs::bee::get(&mobs[i]).and_then(|b| b.flower);
let mut honey = Vec::new();
let stored = {
@@ -353,8 +353,9 @@ pub fn main() -> horizon_client::Result<()> {
if let Tag::Compound(c) = &mut tag {
c.insert("id".into(), Tag::String(id.into()));
}
let mob = entity_nbt::mob_from_nbt(&tag, &types, 1).ok_or("unreadable villager")?;
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None);
let mob =
entity_nbt::mob_from_nbt(&tag, &types, 1, None).ok_or("unreadable villager")?;
let back = entity_nbt::mob_to_nbt(&mob, [0; 4], None, None);
writeln!(out, "{name} snbt {text}")?;
let mut kept = horizon_world::nbt::Compound::new();
for key in [
@@ -0,0 +1,33 @@
//! The menu a container vehicle opens for tools/test_vehicle_container_parity.py:
//! reads one entity type id per line and prints the menu type its `createMenu`
//! builds (`ChestMenu.threeRows` for a chest boat or chest minecart,
//! `HopperMenu` for a hopper minecart - `horizon_client::containers::vehicle_menu`,
//! the menu `lan_open_vehicle_container` sends), its container size, and the
//! title's name key (`MenuProvider.getDisplayName` = `Entity.getDisplayName`).
//!
//! usage: vehicle_container_fixture INPUT OUTPUT
use horizon_client::containers::{vehicle_menu, vehicle_menu_name};
use std::fs;
fn case(entity_type: &str) -> Option<String> {
let entity_type = entity_type.trim();
let (menu, size) = vehicle_menu(entity_type)?;
Some(format!("{menu} {size} {}", vehicle_menu_name(entity_type)))
}
pub fn main() -> horizon_client::Result<()> {
let args: Vec<String> = crate::args().collect();
let input = fs::read_to_string(
args.get(1)
.ok_or("usage: vehicle_container_fixture INPUT OUTPUT")?,
)?;
let lines: Vec<String> = input
.lines()
.map(|line| case(line).unwrap_or_else(|| "error".to_owned()))
.collect();
fs::write(
args.get(2).ok_or("missing OUTPUT")?,
lines.join("\n") + "\n",
)?;
Ok(())
}
+63 -8
View File
@@ -35,9 +35,19 @@ pub fn op_warning(stack: &crate::items::ItemStack) -> bool {
data(stack).is_some_and(|(id, _)| OP_ONLY.contains(&id.as_str()))
}
/// `EntityTypes.OP_ONLY_CUSTOM_DATA`: the entity types whose data only a player
/// who can use game master blocks sets (`EntityType.onlyOpCanSetNbt`).
fn egg_op_only(id: &str) -> bool {
matches!(
id,
"minecraft:falling_block"
| "minecraft:command_block_minecart"
| "minecraft:spawner_minecart"
)
}
/// `SpawnEggItem.shouldPrintOpWarning` for a player with game master
/// permission: a spawn egg carries data of an op-only entity type
/// (`EntityTypes.OP_ONLY_CUSTOM_DATA`).
/// permission: a spawn egg carries data of an op-only entity type.
pub fn spawn_egg_op_warning(stack: &crate::items::ItemStack) -> bool {
stack.item.ends_with("_spawn_egg")
&& matches!(
@@ -45,12 +55,7 @@ pub fn spawn_egg_op_warning(stack: &crate::items::ItemStack) -> bool {
.data()
.and_then(|d| d.other("minecraft:entity_data"))
.and_then(|data| data.get("id")),
Some(Tag::String(id)) if matches!(
id.to_string_lossy().as_str(),
"minecraft:falling_block"
| "minecraft:command_block_minecart"
| "minecraft:spawner_minecart"
)
Some(Tag::String(id)) if egg_op_only(&id.to_string_lossy())
)
}
@@ -76,6 +81,24 @@ pub fn spawn_egg_on_spawner(
(id, data.filter(|_| op).cloned())
}
/// `EntityType.updateCustomEntityTag`: the `minecraft:entity_data` a spawn egg
/// puts on the mob it spawned — only when the data names the spawned type (data
/// without an `id` is the egg's own), and an op-only type's data only for an op.
pub fn spawn_egg_entity_data<'a>(
stack: &'a crate::items::ItemStack,
spawned: &str,
op: bool,
) -> Option<&'a Tag> {
let data = stack.data()?.other("minecraft:entity_data")?;
if matches!(data.get("id"), Some(Tag::String(id)) if id.to_string_lossy() != spawned) {
return None;
}
if egg_op_only(spawned) && !op {
return None;
}
Some(data)
}
/// `Spawner.getSpawnEntityDisplayName` (`SpawnData.entity.id` of the item's
/// data, which `ItemStack.addDetailsToTooltip` reads for spawners and trial
/// spawners alike): the entity type a spawner item spawns.
@@ -517,6 +540,38 @@ mod tests {
);
}
#[test]
fn a_spawn_egg_data_needs_its_own_type_and_an_op_for_an_op_only_one() {
let egg = |entity_data: &str| {
let components = snbt(&format!("{{\"minecraft:entity_data\":{entity_data}}}"));
let (damage, extra) = crate::item_data::decode_components(Some(&components));
crate::items::ItemStack {
damage,
extra,
..crate::items::ItemStack::new("minecraft:zombie_spawn_egg", 1)
}
};
// `entity_data` without an id names the egg's own type: applied for anyone.
let own = egg("{NoAI:1b}");
assert_eq!(
spawn_egg_entity_data(&own, "minecraft:zombie", false)
.and_then(|data| data.get("NoAI")),
Some(&Tag::Byte(1))
);
// `entity_data.id` names another type: the spawned type keeps no data.
let other = egg("{id:\"minecraft:pig\",NoAI:1b}");
assert_eq!(
spawn_egg_entity_data(&other, "minecraft:zombie", false),
None
);
// An op-only type's data needs an op.
assert_eq!(
spawn_egg_entity_data(&own, "minecraft:falling_block", false),
None
);
assert!(spawn_egg_entity_data(&own, "minecraft:falling_block", true).is_some());
}
#[test]
fn item_data_names_its_spawn_and_op_only_types() {
let item = |text: &str| {
+43 -16
View File
@@ -25,32 +25,47 @@ static PENDING: Mutex<Pending> = Mutex::new(Pending {
save_failure: None,
});
impl Pending {
/// `reportChunkLoadFailure` after `warnOnLowDiskSpace` found `low_disk_space`.
fn load_failed(&mut self, x: i32, z: i32, low_disk_space: bool) {
self.low_disk_space |= low_disk_space;
self.load_failure = Some((x, z));
}
/// `reportChunkSaveFailure` after `warnOnLowDiskSpace` found `low_disk_space`.
fn save_failed(&mut self, x: i32, z: i32, low_disk_space: bool) {
self.low_disk_space |= low_disk_space;
self.save_failure = Some((x, z));
}
}
fn with(update: impl FnOnce(&mut Pending)) {
let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
update(&mut pending);
}
/// `MinecraftServer.warnOnLowDiskSpace` for the world folder `dir`.
fn warn_on_low_disk_space(dir: &Path) {
if save::low_disk_space(dir) {
fn warn_on_low_disk_space(dir: &Path) -> bool {
let low = save::low_disk_space(dir);
if low {
// `MinecraftServer.sendLowDiskSpaceWarning`.
log::warn!("Low disk space! Might not be able to save the world.");
with(|p| p.low_disk_space = true);
}
low
}
/// `reportChunkLoadFailure` for the chunk at (`x`, `z`) of the dimension in `dir`.
pub fn report_load_failure(dir: &Path, x: i32, z: i32, error: &dyn std::fmt::Display) {
log::error!("Failed to load chunk {x},{z}: {error}");
warn_on_low_disk_space(dir);
with(|p| p.load_failure = Some((x, z)));
let low = warn_on_low_disk_space(dir);
with(|p| p.load_failed(x, z, low));
}
/// `reportChunkSaveFailure`.
pub fn report_save_failure(dir: &Path, x: i32, z: i32, error: &dyn std::fmt::Display) {
log::error!("Failed to save chunk {x},{z}: {error}");
warn_on_low_disk_space(dir);
with(|p| p.save_failure = Some((x, z)));
let low = warn_on_low_disk_space(dir);
with(|p| p.save_failed(x, z, low));
}
/// `Minecraft.sendLowDiskSpaceWarning`: a server's `ClientboundLowDiskSpaceWarningPacket`
@@ -103,28 +118,40 @@ pub fn save_failure_toast(x: i32, z: i32) -> SystemToast {
mod tests {
use super::*;
// These tests keep off `PENDING` where they can: the save tests run in
// the same process and report their own chunk failures into it (#4854).
#[test]
fn failures_keep_the_newest_chunk_of_each_kind() {
let _ = take();
let missing = Path::new("/nonexistent-horizon-world");
report_load_failure(missing, 1, 2, &"a");
report_load_failure(missing, 3, 4, &"b");
report_save_failure(missing, 5, 6, &"c");
// A folder that cannot be asked has no low-space warning.
let mut pending = Pending::default();
pending.load_failed(1, 2, false);
pending.load_failed(3, 4, false);
pending.save_failed(5, 6, false);
assert_eq!(
take(),
pending,
Pending {
low_disk_space: false,
load_failure: Some((3, 4)),
save_failure: Some((5, 6)),
}
);
assert_eq!(take(), Pending::default());
// A later failure on a full disk raises the warning, and one with
// space left does not lower it.
pending.save_failed(7, 8, true);
pending.load_failed(9, 10, false);
assert_eq!(
pending,
Pending {
low_disk_space: true,
load_failure: Some((9, 10)),
save_failure: Some((7, 8)),
}
);
}
/// The only test that drains `PENDING`; other tests only add to it, so
/// the flag it raises is still there when it takes it.
#[test]
fn a_servers_warning_packet_toasts_low_disk_space() {
let _ = take();
report_low_disk_space();
assert!(take().low_disk_space);
}
+86 -25
View File
@@ -85,8 +85,9 @@ impl Placement {
self.slots.is_empty()
}
}
/// A recipe result: `ItemStackTemplate` (`id`, `count`, `components`), with the
/// id optional for the transmuting recipes that keep the input's item.
/// A recipe result: `ItemStackTemplate` (`id`, `count`, `components`), or a bare item id
/// (`ItemStackTemplate.CODEC` falls back to `Item.CODEC`), with the id optional for the
/// transmuting recipes that keep the input's item.
#[derive(Clone, Debug)]
pub struct Template {
pub item: Option<Arc<str>>,
@@ -113,7 +114,7 @@ impl Template {
None => (0, None),
};
Some(Self {
item: v["id"].as_str().map(Arc::from),
item: horizon_world::loot::recipe_result_id(v).map(Arc::from),
count: template_count(v)?,
damage,
extra,
@@ -1112,6 +1113,48 @@ pub fn recipe_result(v: &Value) -> Option<ItemStack> {
let template = Template::parse(v)?;
template.item.is_some().then(|| template.create())
}
/// The `.json` files under a `recipe` folder, its subfolders' too, in id
/// order: each one's path inside the folder without the extension (`a/b`, as a
/// recipe id has it) and its file. An error when `dir` itself can't be read; a
/// subfolder or an entry that can't be listed is warned and skipped, as
/// vanilla's `PathPackResources` keeps what it found.
pub(crate) fn recipe_files(dir: &Path) -> Result<Vec<(String, PathBuf)>> {
let mut files = Vec::new();
let mut stack = vec![fs::read_dir(dir)?];
while let Some(entries) = stack.pop() {
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
log::warn!("Horizon: failed to list an entry of {}: {e}", dir.display());
continue;
}
};
let path = entry.path();
if path.is_dir() {
match fs::read_dir(&path) {
Ok(list) => stack.push(list),
Err(e) => log::warn!("Horizon: failed to list {}: {e}", path.display()),
}
} else if path.extension().is_some_and(|e| e == "json") {
files.push(path);
}
}
}
let mut named: Vec<(String, PathBuf)> = files
.into_iter()
.map(|path| {
let inside = path.strip_prefix(dir).unwrap_or(&path).with_extension("");
let name: Vec<_> = inside.iter().map(|c| c.to_string_lossy()).collect();
(name.join("/"), path)
})
.collect();
// Vanilla lists resources by id, in a map keyed by it
// (`FallbackResourceManager`): `crushing` before `crushing/iron`, where
// a path's components put the subfolder's file first.
named.sort_by(|a, b| a.0.cmp(&b.0));
Ok(named)
}
/// The type of a recipe the game doesn't craft itself: one outside `minecraft`.
fn custom_type(v: &Value) -> Option<&str> {
v["type"]
@@ -1207,31 +1250,16 @@ impl Recipes {
let mut cooking = Vec::new();
let mut custom: HashMap<String, Vec<CustomRecipe>> = HashMap::new();
let mut meta = HashMap::new();
let json_files = |dir: &Path| -> Result<Vec<PathBuf>> {
let mut files: Vec<_> = fs::read_dir(dir)?
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
.collect();
files.sort();
Ok(files)
};
let stem = |p: &Path| {
p.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_owned()
};
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
for p in json_files(&root.join("data/minecraft/recipe"))? {
sources.push((stem(&p), fs::read(&p)?));
for (name, p) in recipe_files(&root.join("data/minecraft/recipe"))? {
sources.push((name, fs::read(&p)?));
}
for namespace in crate::mod_content::namespaces(root) {
let dir = root.join(format!("data/{namespace}/recipe"));
if dir.is_dir() {
for p in json_files(&dir)? {
sources.push((format!("{namespace}:{}", stem(&p)), fs::read(&p)?));
for (name, p) in recipe_files(&dir)? {
sources.push((format!("{namespace}:{name}"), fs::read(&p)?));
}
}
}
@@ -1334,7 +1362,7 @@ impl Recipes {
}
"minecraft:crafting_special_bannerduplicate" => special(
ing("banner")
.zip(v["result"]["id"].as_str())
.zip(horizon_world::loot::recipe_result_id(&v["result"]))
.map(|(banner, id)| Special::BannerDuplicate {
banner,
result: id.to_owned(),
@@ -1343,7 +1371,7 @@ impl Recipes {
"minecraft:crafting_special_shielddecoration" => special(
ing("banner")
.zip(ing("target"))
.zip(v["result"]["id"].as_str())
.zip(horizon_world::loot::recipe_result_id(&v["result"]))
.map(|((banner, target), id)| Special::ShieldDecoration {
banner,
target,
@@ -1820,6 +1848,12 @@ mod tests {
file("mymod:mixer", "c"),
)
.unwrap();
fs::create_dir_all(root.join("data/mymod/recipe/deep/er")).unwrap();
fs::write(
root.join("data/mymod/recipe/deep/er/d.json"),
file("mymod:mixer", "d"),
)
.unwrap();
fs::write(
root.join("data/minecraft/recipe/later.json"),
file("minecraft:from_a_later_version", "x"),
@@ -1831,7 +1865,7 @@ mod tests {
recipes.custom[kind].iter().map(|r| r.id.clone()).collect()
};
assert_eq!(ids("mymod:crusher"), ["mymod:a", "mymod:b"]);
assert_eq!(ids("mymod:mixer"), ["mymod:c"]);
assert_eq!(ids("mymod:mixer"), ["mymod:c", "mymod:deep/er/d"]);
assert_eq!(recipes.custom.len(), 2);
// Not crafted, and no recipe-book meta of their own.
assert!(recipes.crafting.is_empty() && recipes.cooking.is_empty());
@@ -1868,6 +1902,33 @@ mod tests {
assert!(result(r#"{"id":"minecraft:iron_ingot","count":0}"#).is_none());
}
#[test]
fn a_recipe_result_can_be_a_bare_item_id() {
// `ItemStackTemplate.CODEC` falls back to `Item.CODEC`: `"result": "mod:item"` is one item.
let result = |json: &str| recipe_result(&serde_json::from_str(json).unwrap());
let stack = result(r#""ruby:ruby""#).unwrap();
assert_eq!((&*stack.item, stack.count), ("ruby:ruby", 1));
// The `item` key of older versions is no `id`, and a number names no item.
assert!(result(r#"{"item":"ruby:ruby"}"#).is_none());
assert!(result("42").is_none());
}
#[test]
fn a_shapeless_recipe_makes_the_item_its_bare_result_names() {
let root = std::env::temp_dir().join(format!("horizon-bare-result-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
fs::write(
root.join("data/minecraft/recipe/bare.json"),
br#"{"type":"minecraft:crafting_shapeless","ingredients":["minecraft:flint"],"result":"minecraft:stick"}"#,
)
.unwrap();
let items = Items::for_tests(HashMap::new());
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
let grid = [Some(ItemStack::new("minecraft:flint", 1)), None, None, None];
let (_, made) = recipes.find(&grid, 2).unwrap();
assert_eq!((&*made.item, made.count), ("minecraft:stick", 1));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn cook_finds_the_first_recipe_the_scan_finds() {
let recipes = cooking_book();
for kind in [
+430
View File
@@ -0,0 +1,430 @@
//! The debug screen's entries (26.3 `DebugScreenEntries`, `DebugScreenEntryList`):
//! what each F3 line block and debug renderer is, whether it shows always,
//! only in the overlay or never, the two built-in profiles, and
//! `debug-profile.json` beside `options.txt`, which keeps them.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// `DebugScreenEntryStatus`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
AlwaysOn,
InOverlay,
Never,
}
impl Status {
/// `getSerializedName`.
pub fn name(self) -> &'static str {
match self {
Status::AlwaysOn => "alwaysOn",
Status::InOverlay => "inOverlay",
Status::Never => "never",
}
}
fn parse(name: &str) -> Option<Self> {
[Status::AlwaysOn, Status::InOverlay, Status::Never]
.into_iter()
.find(|s| s.name() == name)
}
}
/// `DebugScreenProfile`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Profile {
Default,
Performance,
}
impl Profile {
pub fn name(self) -> &'static str {
match self {
Profile::Default => "default",
Profile::Performance => "performance",
}
}
pub fn translation_key(self) -> &'static str {
match self {
Profile::Default => "debug.options.profile.default",
Profile::Performance => "debug.options.profile.performance",
}
}
fn parse(name: &str) -> Option<Self> {
[Profile::Default, Profile::Performance]
.into_iter()
.find(|p| p.name() == name)
}
/// `DebugScreenEntries.PROFILES`.
fn statuses(self) -> &'static [(&'static str, Status)] {
use Status::*;
match self {
Profile::Default => &[
("3d_crosshair", InOverlay),
("game_version", InOverlay),
("tps", InOverlay),
("fps", InOverlay),
("memory", InOverlay),
("system_specs", InOverlay),
("player_position", InOverlay),
("player_section_position", InOverlay),
("simple_performance_impactors", InOverlay),
],
Profile::Performance => &[
("tps", InOverlay),
("fps", AlwaysOn),
("gpu_utilization", InOverlay),
("memory", InOverlay),
("simple_performance_impactors", InOverlay),
],
}
}
}
/// `DebugEntryCategory`: the screen's two sections, by `sortKey`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum Category {
/// `SCREEN_TEXT`, `debug.options.category.text`.
Text,
/// `RENDERER` (`DebugEntryNoop`), `debug.options.category.renderer`.
Renderer,
}
impl Category {
pub fn translation_key(self) -> &'static str {
match self {
Category::Text => "debug.options.category.text",
Category::Renderer => "debug.options.category.renderer",
}
}
}
/// One registered entry: its path in the `minecraft` namespace, its section,
/// and whether it shows while debug info is reduced (`isAllowed(true)`).
#[derive(Clone, Copy, Debug)]
pub struct Entry {
pub path: &'static str,
pub category: Category,
pub reduced: bool,
}
const fn text(path: &'static str, reduced: bool) -> Entry {
Entry {
path,
category: Category::Text,
reduced,
}
}
const fn renderer(path: &'static str) -> Entry {
Entry {
path,
category: Category::Renderer,
reduced: false,
}
}
/// `DebugScreenEntries`' registrations, in their order.
pub const ENTRIES: [Entry; 46] = [
text("game_version", true),
text("fps", true),
text("tps", true),
text("memory", true),
text("detailed_memory", true),
text("system_specs", true),
text("looking_at_block_state", false),
text("looking_at_block_tags", false),
text("looking_at_fluid_state", false),
text("looking_at_fluid_tags", false),
text("looking_at_entity", false),
text("looking_at_entity_tags", false),
text("chunk_render_stats", true),
text("chunk_generation_stats", false),
text("entity_render_stats", true),
text("particle_render_stats", false),
text("chunk_source_stats", true),
text("player_position", false),
text("player_section_position", true),
text("player_speed", false),
text("light_levels", false),
text("heightmap", false),
text("biome", false),
text("local_difficulty", false),
text("day_count", false),
text("entity_spawn_counts", false),
text("sound_mood", false),
text("sound_cache", true),
text("post_effects", false),
renderer("entity_hitboxes"),
renderer("chunk_borders"),
renderer("3d_crosshair"),
renderer("chunk_section_paths"),
text("gpu_utilization", true),
text("simple_performance_impactors", true),
renderer("chunk_section_octree"),
renderer("visualize_water_levels"),
renderer("visualize_heightmap"),
renderer("visualize_collision_boxes"),
renderer("visualize_entity_supporting_blocks"),
renderer("visualize_block_light_levels"),
renderer("visualize_sky_light_levels"),
renderer("visualize_solid_faces"),
renderer("visualize_chunks_on_server"),
renderer("visualize_sky_light_sections"),
renderer("chunk_section_visibility"),
];
/// `DebugScreenEntries.getEntry` by path.
pub fn entry(path: &str) -> Option<&'static Entry> {
ENTRIES.iter().find(|e| e.path == path)
}
/// `DebugOptionsScreen.OptionList.updateSearch`: the entries whose path
/// contains `search`, by category then identifier.
pub fn search(search: &str) -> Vec<&'static Entry> {
let mut all: Vec<&Entry> = ENTRIES.iter().filter(|e| e.path.contains(search)).collect();
all.sort_by(|a, b| a.category.cmp(&b.category).then(a.path.cmp(b.path)));
all
}
/// `DebugScreenEntryList`: every entry's status (by full identifier, as the
/// file keeps them) and the profile they came from if unchanged. Whether the
/// F3 overlay is open (`isOverlayVisible`) is the game's `debug`.
#[derive(Clone, Debug)]
pub struct DebugEntries {
statuses: BTreeMap<String, Status>,
profile: Option<Profile>,
file: Option<PathBuf>,
}
impl Default for DebugEntries {
fn default() -> Self {
let mut entries = Self {
statuses: BTreeMap::new(),
profile: None,
file: None,
};
entries.reset_to_profile(Profile::Default);
entries
}
}
fn identifier(path: &str) -> String {
format!("minecraft:{path}")
}
impl DebugEntries {
/// `DebugScreenEntryList.load` from `debug-profile.json` in `dir`: the
/// default profile without a file; a file that does not parse is reset
/// to it and saved again.
pub fn load(dir: &Path) -> Self {
let file = dir.join("debug-profile.json");
let mut entries = Self {
file: Some(file.clone()),
..Self::default()
};
let Ok(text) = std::fs::read_to_string(&file) else {
return entries;
};
match Self::parse(&text) {
Some((Some(profile), _)) => entries.reset_to_profile(profile),
Some((None, custom)) => {
entries.statuses = custom;
entries.profile = None;
}
None => {
log::error!(
"Couldn't read debug profile file {}, resetting to default",
file.display()
);
entries.save();
}
}
entries
}
/// `SerializedOptions.CODEC`: an optional `profile` name and an optional
/// `custom` map of identifiers to statuses.
fn parse(text: &str) -> Option<(Option<Profile>, BTreeMap<String, Status>)> {
let json: serde_json::Value = serde_json::from_str(text).ok()?;
let object = json.as_object()?;
let profile = match object.get("profile") {
Some(name) => Some(Profile::parse(name.as_str()?)?),
None => None,
};
let mut custom = BTreeMap::new();
if let Some(map) = object.get("custom") {
for (key, status) in map.as_object()? {
// Identifier.CODEC: a bare path is in the minecraft namespace.
let key = if key.contains(':') {
key.clone()
} else {
identifier(key)
};
custom.insert(key, Status::parse(status.as_str()?)?);
}
}
Some((profile, custom))
}
/// `DebugScreenEntryList.save`: the profile's name while it is unchanged,
/// else every status, with the data version the codec wraps it in.
pub fn save(&self) {
let Some(file) = &self.file else {
return;
};
let mut json = serde_json::Map::new();
json.insert(
"DataVersion".into(),
horizon_core::version::DATA_VERSION.into(),
);
match self.profile {
Some(profile) => {
json.insert("profile".into(), profile.name().into());
}
None => {
let custom: serde_json::Map<String, serde_json::Value> = self
.statuses
.iter()
.map(|(k, s)| (k.clone(), s.name().into()))
.collect();
json.insert("custom".into(), custom.into());
}
}
if let Err(e) = std::fs::write(file, serde_json::Value::Object(json).to_string()) {
log::error!("Failed to save debug profile file {}: {e}", file.display());
}
}
fn reset_to_profile(&mut self, profile: Profile) {
self.profile = Some(profile);
self.statuses = profile
.statuses()
.iter()
.map(|(path, status)| (identifier(path), *status))
.collect();
}
/// `loadProfile` then `save` (the screen's profile buttons).
pub fn load_profile(&mut self, profile: Profile) {
self.reset_to_profile(profile);
self.save();
}
pub fn is_using_profile(&self, profile: Profile) -> bool {
self.profile == Some(profile)
}
/// `getStatus`: never for an entry the statuses leave out.
pub fn status(&self, path: &str) -> Status {
self.statuses
.get(&identifier(path))
.copied()
.unwrap_or(Status::Never)
}
/// `setStatus`: the statuses are custom from now on, and saved.
pub fn set_status(&mut self, path: &str, status: Status) {
self.profile = None;
self.statuses.insert(identifier(path), status);
self.save();
}
/// `toggleStatus` (F3+B, F3+G) with the overlay open or not: whether the
/// entry is on afterwards.
pub fn toggle_status(&mut self, path: &str, overlay: bool) -> bool {
let (status, on) = match self.statuses.get(&identifier(path)) {
Some(Status::AlwaysOn) => (Status::Never, false),
Some(Status::InOverlay) if overlay => (Status::Never, false),
Some(Status::Never) if overlay => (Status::InOverlay, true),
_ => (Status::AlwaysOn, true),
};
self.set_status(path, status);
on
}
/// `isCurrentlyEnabled` (`rebuildCurrentList`): on always, or in the
/// `overlay` while it is open, and allowed with the debug info `reduced`.
pub fn enabled(&self, path: &str, overlay: bool, reduced: bool) -> bool {
let shown = match self.status(path) {
Status::AlwaysOn => true,
Status::InOverlay => overlay,
Status::Never => false,
};
shown && entry(path).is_some_and(|e| e.reduced || !reduced)
}
/// `getCurrentlyEnabled().isEmpty()` negated: something to draw.
pub fn any_enabled(&self, overlay: bool, reduced: bool) -> bool {
ENTRIES
.iter()
.any(|e| self.enabled(e.path, overlay, reduced))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toggling_follows_the_overlay() {
let mut e = DebugEntries::default();
// Not in the default profile: on always while F3 is closed.
assert!(e.toggle_status("entity_hitboxes", false));
assert_eq!(e.status("entity_hitboxes"), Status::AlwaysOn);
assert!(!e.toggle_status("entity_hitboxes", false));
assert_eq!(e.status("entity_hitboxes"), Status::Never);
// With F3 open, a hidden entry goes in the overlay only.
assert!(e.toggle_status("chunk_borders", true));
assert_eq!(e.status("chunk_borders"), Status::AlwaysOn);
assert!(!e.toggle_status("chunk_borders", true));
assert_eq!(e.status("chunk_borders"), Status::Never);
assert!(e.toggle_status("chunk_borders", true));
assert_eq!(e.status("chunk_borders"), Status::InOverlay);
assert!(!e.toggle_status("chunk_borders", true));
assert!(!e.is_using_profile(Profile::Default));
}
#[test]
fn enabled_needs_the_overlay_and_full_debug_info() {
let mut e = DebugEntries::default();
assert!(!e.any_enabled(false, false));
assert!(e.enabled("player_position", true, false));
// Reduced debug info hides the position, not the section.
assert!(!e.enabled("player_position", true, true));
assert!(e.enabled("player_section_position", true, true));
e.load_profile(Profile::Performance);
assert!(e.enabled("fps", false, false));
assert!(!e.enabled("memory", false, false));
}
#[test]
fn the_file_keeps_a_profile_or_the_statuses() {
let dir =
std::env::temp_dir().join(format!("horizon-debug-profile-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let mut e = DebugEntries::load(&dir);
assert!(e.is_using_profile(Profile::Default));
e.load_profile(Profile::Performance);
let saved: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(dir.join("debug-profile.json")).unwrap())
.unwrap();
assert_eq!(saved["profile"], "performance");
assert!(DebugEntries::load(&dir).is_using_profile(Profile::Performance));
e.set_status("biome", Status::AlwaysOn);
let loaded = DebugEntries::load(&dir);
assert_eq!(loaded.status("biome"), Status::AlwaysOn);
assert_eq!(loaded.status("fps"), Status::AlwaysOn);
assert!(!loaded.is_using_profile(Profile::Performance));
// A broken file is reset to the default profile.
std::fs::write(dir.join("debug-profile.json"), "{\"profile\":\"nope\"}").unwrap();
assert!(DebugEntries::load(&dir).is_using_profile(Profile::Default));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn the_list_sorts_text_before_renderers() {
let all = search("");
assert_eq!(all.len(), 46);
assert_eq!(all[0].path, "biome");
let first_renderer = all
.iter()
.position(|e| e.category == Category::Renderer)
.unwrap();
assert_eq!(all[first_renderer].path, "3d_crosshair");
assert!(
all[first_renderer..]
.iter()
.all(|e| e.category == Category::Renderer)
);
assert_eq!(search("chunk_b").len(), 1);
}
}
+44 -37
View File
@@ -1004,49 +1004,45 @@ impl ArmorStand {
Vec3::new(p.x + w, p.y + h, p.z + w),
)
}
/// `isInWater`: some water block of the stand's box has its surface
/// (`FluidState.getHeight`, a full block under more water) at or above the
/// box's bottom, as `Entity.updateFluidHeightAndDoFluidPushing` counts it.
pub fn in_water(&self, world: &World, fluids: &crate::motion::FluidResources) -> bool {
let (lo, hi) = (self.bounds().min_position(), self.bounds().max_position());
let water = |x: i32, y: i32, z: i32| {
let state = fluids.fluid(world.block(x, y, z));
(state.kind() == horizon_world::fluid::FluidKind::Water).then_some(state)
};
(lo.x.floor() as i32..=hi.x.floor() as i32).any(|x| {
(lo.y.floor() as i32..hi.y.ceil() as i32).any(|y| {
(lo.z.floor() as i32..=hi.z.floor() as i32).any(|z| {
water(x, y, z).is_some_and(|state| {
let height = if water(x, y + 1, z).is_some() {
1.0
} else {
state.own_height()
};
f64::from(y) + f64::from(height) >= lo.y
})
})
})
})
}
/// LivingEntity.travel (armor stands have no other movement): move by
/// the velocity (a landing stops the fall,
/// `Block.updateEntityMovementAfterFallOn`), let the blocks it is inside
/// LivingEntity.travel (armor stands have no other movement): clamp tiny
/// velocity components (`LivingEntity.aiStep`), move by the velocity (a
/// landing stops the fall, `Block.updateEntityMovementAfterFallOn`, and a
/// wall collision zeros that horizontal axis), let the blocks it is inside
/// push it (`push`: a bubble column), then in water the 0.8 slowdown and
/// `getFluidFallingAdjustedMovement`, in air gravity 0.08 and the 0.98
/// drag, so a stand at rest on the ground keeps -0.0784 as vanilla's.
/// `getFluidFallingAdjustedMovement`, in air gravity 0.08, ground/air
/// horizontal drag (`friction * 0.91`), and the 0.98 vertical drag, so a
/// stand at rest on the ground keeps -0.0784 as vanilla's.
pub fn tick(
&mut self,
scene: &mut crate::collisions::SceneCollisions,
facts: &BlockFacts,
in_water: bool,
push: &mut dyn FnMut(Aabb, &mut Vec3),
) {
if self.marker {
return;
}
let wanted = self.velocity;
// `travelInFluid` reads `isFalling` before the move and any push.
let v = self.velocity;
let wanted = Vec3::new(
if v.x.abs() < 0.003 { 0.0 } else { v.x },
if v.y.abs() < 0.003 { 0.0 } else { v.y },
if v.z.abs() < 0.003 { 0.0 } else { v.z },
);
self.velocity = wanted;
// `travelInFluid` reads `isFalling` before the move and any push;
// `travelInAir` reads ground friction before the move.
let falling = wanted.y <= 0.0;
let mut y = wanted.y;
let friction = if !in_water && self.on_ground {
let below = scene.world.block(
self.position.x.floor() as i32,
(self.position.y - 0.500_001).floor() as i32,
self.position.z.floor() as i32,
);
facts.get(below).friction
} else {
1.0
};
let mut velocity = wanted;
if let Ok(moved) = scene.resolve(self.bounds(), wanted, 0.0, self.on_ground) {
self.position = Vec3::new(
self.position.x + moved.x,
@@ -1054,26 +1050,37 @@ impl ArmorStand {
self.position.z + moved.z,
);
self.on_ground = wanted.y < 0.0 && moved.y > wanted.y;
if (wanted.x - moved.x).abs() >= f64::from(1.0e-5_f32) {
velocity.x = 0.0;
}
if moved.y != wanted.y {
y = 0.0;
velocity.y = 0.0;
}
if (wanted.z - moved.z).abs() >= f64::from(1.0e-5_f32) {
velocity.z = 0.0;
}
}
let mut velocity = Vec3::new(0.0, y, 0.0);
push(self.bounds(), &mut velocity);
self.velocity = if in_water {
// travelInWater: slowDown 0.8, then `getFluidFallingAdjustedMovement`
// with gravity / 16 (the stand is falling while its fall is not
// already nearly stopped).
let y = velocity.y * f64::from(0.8f32);
let slow = f64::from(0.8f32);
let y = velocity.y * slow;
let adjusted =
if falling && (y - 0.005).abs() >= 0.003 && (y - 0.08 / 16.0).abs() < 0.003 {
-0.003
} else {
y - 0.08 / 16.0
};
Vec3::new(0.0, adjusted, 0.0)
Vec3::new(velocity.x * slow, adjusted, velocity.z * slow)
} else {
Vec3::new(0.0, (velocity.y - 0.08) * f64::from(0.98f32), 0.0)
let drag = f64::from(friction * 0.91f32);
Vec3::new(
velocity.x * drag,
(velocity.y - 0.08) * f64::from(0.98f32),
velocity.z * drag,
)
};
}
}
+160 -39
View File
@@ -1,5 +1,5 @@
//! Dropped-item immersion and currents through the shared vanilla fluid helpers.
use super::ItemEntity;
//! Dropped-item and armor-stand immersion and currents through the shared vanilla fluid helpers.
use super::{ArmorStand, ItemEntity};
use crate::{
motion::{FluidResources, FluidTags, LAVA, WATER},
world::World,
@@ -18,66 +18,111 @@ use horizon_world::{
registry::BlockStateId,
};
type Error = std::convert::Infallible;
struct ItemFluidWorld<'a> {
item: &'a mut ItemEntity,
struct EntityFluidWorld<'a> {
bounds: Aabb,
position: Vec3,
eye_y: f64,
velocity: &'a mut Vec3,
world: &'a World,
fluids: &'a FluidResources,
}
fn update_entity_fluids(
bounds: Aabb,
position: Vec3,
eye_y: f64,
velocity: &mut Vec3,
world: &World,
fluids: &FluidResources,
) -> (f64, f64) {
let mut interaction = EntityFluidInteraction::new([WATER, LAVA]);
let mut scene = EntityFluidWorld {
bounds,
position,
eye_y,
velocity,
world,
fluids,
};
if interaction.update(&mut scene, false, 4096).is_err() {
return (0.0, 0.0);
}
let water_height = interaction.fluid_height(&scene, WATER);
let lava_height = interaction.fluid_height(&scene, LAVA);
let _ = interaction.apply_current(&mut scene, WATER, 0.014);
let _ = interaction.apply_current(
&mut scene,
LAVA,
if world.fast_lava {
0.007
} else {
0.0023333333333333335
},
);
(water_height, lava_height)
}
impl ItemEntity {
pub(super) fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
let mut interaction = EntityFluidInteraction::new([WATER, LAVA]);
let mut scene = ItemFluidWorld {
item: self,
update_entity_fluids(
self.bounds(),
self.position,
self.position.y + f64::from(0.2125f32),
&mut self.velocity,
world,
fluids,
};
if interaction.update(&mut scene, false, 4096).is_err() {
return (0.0, 0.0);
}
let water_height = interaction.fluid_height(&scene, WATER);
let lava_height = interaction.fluid_height(&scene, LAVA);
let _ = interaction.apply_current(&mut scene, WATER, 0.014);
let _ = interaction.apply_current(
&mut scene,
LAVA,
if world.fast_lava {
0.007
} else {
0.0023333333333333335
},
);
(water_height, lava_height)
)
}
}
impl FluidMembership for ItemFluidWorld<'_> {
impl ArmorStand {
/// `Entity.updateFluidHeightAndDoFluidPushing`: refreshes water and lava
/// immersion over the stand's deflated box and pushes its velocity by any
/// water or lava current. Returns whether the stand is in water (`isInWater`).
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> bool {
if self.marker {
return false;
}
let eye = if self.small {
f64::from(0.9875f32 * 0.9)
} else {
f64::from(1.7775f32)
};
let (water_height, _) = update_entity_fluids(
self.bounds(),
self.position,
self.position.y + eye,
&mut self.velocity,
world,
fluids,
);
water_height > 0.0
}
}
impl FluidMembership for EntityFluidWorld<'_> {
fn fluid_in_tag(&self, kind: FluidKind, tag: FluidTagId) -> bool {
FluidTags.fluid_in_tag(kind, tag)
}
}
impl CurrentTarget for ItemFluidWorld<'_> {
impl CurrentTarget for EntityFluidWorld<'_> {
type Error = Error;
fn is_player_class(&self) -> bool {
false
}
fn velocity(&mut self) -> Result<Vec3, Error> {
Ok(self.item.velocity)
Ok(*self.velocity)
}
fn add_delta_movement(&mut self, delta: Vec3) -> Result<(), Error> {
let v = self.item.velocity;
let v = *self.velocity;
let sum = Vec3::new(v.x + delta.x, v.y + delta.y, v.z + delta.z);
if delta.is_finite() && sum.is_finite() {
self.item.velocity = sum;
*self.velocity = sum;
}
Ok(())
}
}
impl FluidInteractionWorld for ItemFluidWorld<'_> {
impl FluidInteractionWorld for EntityFluidWorld<'_> {
type Error = Error;
type Chunk = ();
fn interaction_box(&mut self) -> Result<Option<Aabb>, Error> {
Ok(Some(
self.item.bounds().deflate(Vec3::new(0.001, 0.001, 0.001)),
))
Ok(Some(self.bounds.deflate(Vec3::new(0.001, 0.001, 0.001))))
}
fn loaded_chunk(&mut self, x: i32, z: i32) -> Result<Option<()>, Error> {
Ok(self.world.chunks.contains_key(&(x, z)).then_some(()))
@@ -90,16 +135,16 @@ impl FluidInteractionWorld for ItemFluidWorld<'_> {
})
}
fn bounding_box_min_y(&self) -> f64 {
self.item.position.y
self.position.y
}
fn block_x(&self) -> i32 {
self.item.position.x.floor() as i32
self.position.x.floor() as i32
}
fn eye_y(&mut self) -> Result<f64, Error> {
Ok(self.item.position.y + f64::from(0.2125f32))
Ok(self.eye_y)
}
fn block_z(&self) -> i32 {
self.item.position.z.floor() as i32
self.position.z.floor() as i32
}
fn fluid_state(&mut self, p: BlockPos) -> Result<FluidState, Error> {
Ok(self.fluids.fluid(self.world.block(p.x, p.y, p.z)))
@@ -114,7 +159,7 @@ impl FluidInteractionWorld for ItemFluidWorld<'_> {
flow_at(self, f, p)
}
}
impl FluidHeightWorld for ItemFluidWorld<'_> {
impl FluidHeightWorld for EntityFluidWorld<'_> {
type Error = Error;
fn fluid(&mut self, p: [i32; 3]) -> Result<FluidState, Error> {
Ok(self.fluids.fluid(self.world.block(p[0], p[1], p[2])))
@@ -124,7 +169,7 @@ impl FluidHeightWorld for ItemFluidWorld<'_> {
Ok(self.fluids.sturdy(state, Direction::Down))
}
}
impl FluidFlowWorld for ItemFluidWorld<'_> {
impl FluidFlowWorld for EntityFluidWorld<'_> {
type Error = Error;
type Block = BlockStateId;
fn fluid(&mut self, p: BlockPos) -> Result<FluidState, Error> {
@@ -214,4 +259,80 @@ mod tests {
}
assert!(carried.position.x > at.x + 0.5);
}
#[test]
fn armor_stands_follow_flowing_water_but_markers_and_still_water_stay_in_place() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = FluidResources::new(&assets, &facts).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
let mut state = assets.registry.state(water).unwrap().clone();
state.properties.insert("level".into(), "1".into());
let flowing = assets.registry.resolve(&state).unwrap();
for x in 19..=21 {
for z in 19..=21 {
world.set(x, 3, z, stone).unwrap();
world.set(x, 4, z, water).unwrap();
}
}
let at = Vec3::new(20.5, 4.0, 20.5);
let stand = ArmorStand::new(1, at, 0.0);
let mut still = stand.clone();
let in_water = still.update_fluids(&world, &fluids);
assert!(in_water);
let mut scene = crate::collisions::SceneCollisions {
world: &world,
binding: &collision,
subject: None,
};
still.tick(&mut scene, &facts, in_water, &mut |_, _| {});
assert_eq!(still.position.x, at.x);
assert_eq!(still.velocity.x, 0.0);
world.set(21, 4, 20, flowing).unwrap();
let mut marker = stand.clone();
marker.marker = true;
let in_water = marker.update_fluids(&world, &fluids);
assert!(!in_water);
let mut scene = crate::collisions::SceneCollisions {
world: &world,
binding: &collision,
subject: None,
};
marker.tick(&mut scene, &facts, in_water, &mut |_, _| {});
assert_eq!(marker.position, at);
assert_eq!(marker.velocity, Vec3::ZERO);
let mut carried = stand;
let in_water = carried.update_fluids(&world, &fluids);
assert!(in_water);
let mut scene = crate::collisions::SceneCollisions {
world: &world,
binding: &collision,
subject: None,
};
carried.tick(&mut scene, &facts, in_water, &mut |_, _| {});
assert!((carried.position.x - (at.x + 0.014)).abs() < 1e-12);
assert!((carried.velocity.x - 0.014 * f64::from(0.8f32)).abs() < 1e-12);
for _ in 0..20 {
let in_water = carried.update_fluids(&world, &fluids);
let mut scene = crate::collisions::SceneCollisions {
world: &world,
binding: &collision,
subject: None,
};
carried.tick(&mut scene, &facts, in_water, &mut |_, _| {});
}
assert!(carried.position.x > at.x + 0.5);
}
}
+319 -33
View File
@@ -15,7 +15,11 @@ use crate::{
},
};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::{Compound, Tag};
use horizon_world::{
chunk::BlockState,
nbt::{Compound, Tag},
registry::{BlockRegistry, BlockStateId},
};
use std::{collections::HashMap, sync::Arc};
fn string(s: &str) -> Tag {
@@ -272,7 +276,58 @@ fn profile_from_nbt(tag: &Tag) -> Option<horizon_protocol::entity_data::Profile>
slim: text("model").map(|model| model == "slim"),
})
}
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
/// `BlockState.CODEC`: the block's name when the state is that block's
/// default, `{id, properties}` for any other state.
pub fn state_to_nbt(registry: &BlockRegistry, state: BlockStateId) -> Tag {
match registry.state(state) {
Some(s) if registry.default_state(&s.name) == Some(state) => string(&s.name),
Some(s) => s.to_nbt(),
None => string("minecraft:air"),
}
}
/// `BlockState.CODEC` read back: a block name or `{id, properties}` (or the
/// legacy `{Name, Properties}`). A name the registry knows only as a block
/// reads as its default state, and one it does not know at all as `None`.
pub fn state_from_nbt(registry: &BlockRegistry, tag: &Tag) -> Option<BlockStateId> {
let state = match tag.get("Name") {
Some(Tag::String(name)) => BlockState {
name: name.to_string_lossy(),
properties: match tag.get("Properties") {
Some(Tag::Compound(p)) => p
.iter()
.filter_map(|(k, v)| match v {
Tag::String(v) => Some((k.to_string_lossy(), v.to_string_lossy())),
_ => None,
})
.collect(),
_ => Default::default(),
},
},
_ => BlockState::from_nbt(tag).ok()?,
};
registry
.id(&state)
.or_else(|| registry.default_state(&state.name))
}
/// `BlockState.isAir`: air, cave air and void air.
fn is_air(registry: &BlockRegistry, state: BlockStateId) -> bool {
registry.state(state).is_some_and(|s| {
matches!(
s.name.as_str(),
"minecraft:air" | "minecraft:cave_air" | "minecraft:void_air"
)
})
}
/// The mob's entity compound. `registry` is what writes a field holding a block
/// state (an enderman's `carriedBlockState`): without one, a caller that has no
/// resources (unit tests, structure templates) keeps whatever the loaded
/// compound had for it.
pub fn mob_to_nbt(
m: &Mob,
uuid: [i32; 4],
raw: Option<&Tag>,
registry: Option<&BlockRegistry>,
) -> Tag {
let base = Base {
id: &m.kind.id,
uuid,
@@ -363,11 +418,15 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
.iter()
.map(|&(effect, duration, amplifier)| {
let shown = !m.extra.hidden_effects.contains(&effect);
let ambient = m.extra.ambient_effects.contains(&effect);
let mut e = vec![
("id", string(crate::brewing::namespaced(effect))),
("amplifier", Tag::Byte(amplifier.clamp(0, 255) as u8 as i8)),
("duration", Tag::Int(duration)),
];
if ambient {
e.push(("ambient", Tag::Byte(1)));
}
if !shown {
e.push(("show_particles", Tag::Byte(0)));
}
@@ -464,11 +523,27 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
);
}
}
// MushroomCow.addAdditionalSaveData: the Variant codec's name (stew
// effects are not modelled).
// MushroomCow.addAdditionalSaveData: the Variant codec's name and the
// stew effects, written only while it holds any (storeNullable).
"minecraft:mooshroom" => {
let kind = if m.extra.variant == 1 { "brown" } else { "red" };
map.insert("Type".into(), string(kind));
match &m.extra.stew_effects {
Some(effects) => {
let list = effects.iter().map(|(id, duration)| {
let mut entry = vec![("id", string(id))];
// SuspiciousStewEffects.Entry leaves out the default.
if *duration != 160 {
entry.push(("duration", Tag::Int(*duration)));
}
Tag::compound(entry)
});
map.insert("stew_effects".into(), Tag::List(list.collect()));
}
None => {
map.remove(&horizon_world::nbt::JavaString::from("stew_effects"));
}
}
}
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
"minecraft:rabbit" => {
@@ -698,6 +773,11 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let size = SALMON_SIZES[usize::from(m.extra.variant).min(2)];
map.insert("type".into(), string(size));
}
// Pufferfish.addAdditionalSaveData: the puff state (0 small, 1 mid,
// 2 full).
"minecraft:pufferfish" => {
map.insert("PuffState".into(), Tag::Int(m.extra.size));
}
// TropicalFish.addAdditionalSaveData: the packed variant.
"minecraft:tropical_fish" => {
let pattern = m.extra.variant.min(11);
@@ -821,6 +901,20 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
"minecraft:endermite" => {
map.insert("Lifetime".into(), Tag::Int(m.extra.counter));
}
// Enderman.addAdditionalSaveData: the block it carries, saved only
// while it carries one.
"minecraft:enderman" => match (m.extra.carried, registry) {
(Some(state), Some(registry)) => {
map.insert("carriedBlockState".into(), state_to_nbt(registry, state));
}
// Nothing carries a block state out without the registry, and a
// caller without one keeps what the loaded compound had, so the
// key is only dropped when a registry could have written it.
(Some(_), None) | (None, None) => {}
(None, Some(_)) => {
map.remove(&"carriedBlockState".into());
}
},
"minecraft:shulker" => {
map.insert("AttachFace".into(), Tag::Byte(m.extra.variant3 as i8));
map.insert("Peek".into(), Tag::Byte(m.extra.counter as i8));
@@ -833,6 +927,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
Tag::Int(crate::mobs::aquatic::dark_ticks(m)),
);
}
// Phantom.addAdditionalSaveData.
"minecraft:phantom" => {
map.insert("size".into(), Tag::Int(m.extra.size));
}
"minecraft:slime" | "minecraft:magma_cube" => {
map.insert("Size".into(), Tag::Int(m.extra.size.max(1) - 1));
map.insert("wasOnGround".into(), Tag::Byte(i8::from(m.extra.flag2)));
@@ -1171,7 +1269,15 @@ pub(crate) fn spawn_bonus(tag: &Tag) -> Option<f64> {
.find(|x| text(x.get("id")).as_deref() == Some("minecraft:random_spawn_bonus"))?;
float(bonus.get("amount"))
}
pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -> Option<Mob> {
/// The mob a saved compound describes. `registry` is what reads a field holding
/// a block state (an enderman's `carriedBlockState`); without one, such a field
/// is left out, as it was before it was modelled.
pub fn mob_from_nbt(
tag: &Tag,
types: &HashMap<String, Arc<MobType>>,
id: u32,
registry: Option<&BlockRegistry>,
) -> Option<Mob> {
let kind = types.get(&self::kind(tag)?)?.clone();
let position = position(tag)?;
let [yaw, pitch] = rotation(tag);
@@ -1316,6 +1422,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
if number(e.get("show_particles")) == Some(0) {
m.extra.hidden_effects.push(effect);
}
// Details.CODEC: `ambient` defaults to false.
m.extra.ambient_effects.retain(|h| *h != effect);
if number(e.get("ambient")).is_some_and(|a| a != 0) {
m.extra.ambient_effects.push(effect);
}
}
}
m.silent = flag(tag, "Silent");
@@ -1429,9 +1540,20 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
}
}
// MushroomCow.readAdditionalSaveData: a missing or unknown name is
// Variant.DEFAULT, red.
// Variant.DEFAULT, red; stew_effects decodes like the component's list.
crate::mobs::Species::Mooshroom => {
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("brown"));
if let Some(Tag::List(list)) = tag.get("stew_effects") {
m.extra.stew_effects = list
.iter()
.map(|entry| {
Some((
Arc::from(text(entry.get("id"))?.as_str()),
number(entry.get("duration")).unwrap_or(160) as i32,
))
})
.collect();
}
}
crate::mobs::Species::Rabbit => {
// Rabbit.Variant.byId: an unknown id is brown.
@@ -1655,6 +1777,20 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
if m.species() == crate::mobs::Species::IronGolem {
m.extra.player_created = flag(tag, "PlayerCreated");
}
// Pufferfish.readAdditionalSaveData: `getIntOr("PuffState", 0)`, at most 2.
if m.species() == crate::mobs::Species::Pufferfish {
m.extra.size = number(tag.get("PuffState")).unwrap_or(0).min(2) as i32;
}
// Enderman.readAdditionalSaveData: the block it carried; an air state
// reads as no block at all.
if m.species() == crate::mobs::Species::Enderman
&& let Some(registry) = registry
{
m.extra.carried = tag
.get("carriedBlockState")
.and_then(|t| state_from_nbt(registry, t))
.filter(|&state| !is_air(registry, state));
}
if m.species() == crate::mobs::Species::Shulker {
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
@@ -1681,6 +1817,10 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
}
m.extra.flag2 = flag(tag, "wasOnGround");
}
if m.species() == crate::mobs::Species::Phantom {
// Phantom.readAdditionalSaveData: setPhantomSize(size).
crate::mobs::phantom::set_size(&mut m, number(tag.get("size")).unwrap_or(0) as i32);
}
// Zombie, Piglin and Zoglin.readAdditionalSaveData: IsBaby (the baby
// zombies of trial spawners).
if !crate::mobs::finalize::ageable(m.species()) && flag(tag, "IsBaby") {
@@ -3120,7 +3260,7 @@ mod axolotl_nbt_tests {
Tag::compound([("test:unrelated", Tag::compound([("value", Tag::Int(9))]))]),
)]),
)]);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw));
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None);
let memories = tag.get("Brain").unwrap().get("memories").unwrap();
assert!(memories.get("test:unrelated").is_some());
assert_eq!(
@@ -3238,7 +3378,7 @@ mod axolotl_nbt_tests {
crate::mobs::animal_brain::set_hunting_cooldown(&mut m, 0);
assert!(bucket_data(&m).get("HuntingCooldown").is_none());
let saved = mob_to_nbt(&m, [1, 2, 3, 4], Some(&tag));
let saved = mob_to_nbt(&m, [1, 2, 3, 4], Some(&tag), None);
assert!(
saved
.get("Brain")
@@ -3276,7 +3416,7 @@ mod sheep_nbt_tests {
// Summoned white, then dyed red and sheared: the save has it now.
let raw = Tag::compound([("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))]);
(m.extra.color, m.extra.sheared) = (14, true);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw));
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw), None);
assert_eq!(
(number(tag.get("Color")), number(tag.get("Sheared"))),
(Some(14), Some(1))
@@ -3286,7 +3426,7 @@ mod sheep_nbt_tests {
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let back = mob_from_nbt(&tag, &types, 2).unwrap();
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
@@ -3312,12 +3452,12 @@ mod sheep_nbt_tests {
)]);
let mut m = Mob::new(1, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
m.add_effect(crate::brewing::effect("testfx:glow").unwrap().id, 200, 2);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
let Some(Tag::List(saved)) = tag.get("active_effects") else {
panic!("no active_effects");
};
assert_eq!(text(saved[0].get("id")).as_deref(), Some("testfx:glow"));
let back = mob_from_nbt(&tag, &types, 2).unwrap();
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert_eq!(back.extra.effects, [("testfx:glow", 200, 2)]);
}
@@ -3341,7 +3481,7 @@ mod sheep_nbt_tests {
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
assert_eq!(
text(tag.get("variant")).as_deref(),
Some("minecraft:temperate")
@@ -3352,13 +3492,13 @@ mod sheep_nbt_tests {
);
// Spawned in a warm biome with the mini sounds.
(m.extra.variant, m.extra.sound_variant) = (1, Some(2));
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:warm"));
assert_eq!(
text(tag.get("sound_variant")).as_deref(),
Some("minecraft:mini")
);
let back = mob_from_nbt(&tag, &types, 2).unwrap();
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert_eq!((back.extra.variant, back.extra.sound_variant), (1, Some(2)));
let mut hen = Mob::new(
3,
@@ -3367,9 +3507,9 @@ mod sheep_nbt_tests {
0.0,
);
(hen.extra.variant, hen.extra.egg_time) = (2, 7795);
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None);
let tag = mob_to_nbt(&hen, [1, 2, 3, 4], None, None);
assert_eq!(text(tag.get("variant")).as_deref(), Some("minecraft:cold"));
let back = mob_from_nbt(&tag, &types, 4).unwrap();
let back = mob_from_nbt(&tag, &types, 4, None).unwrap();
assert_eq!((back.extra.variant, back.extra.egg_time), (2, 7795));
// The sound variants are indices into the registries finalizeSpawn draws from.
use crate::mobs::{Species, finalize};
@@ -3408,16 +3548,16 @@ mod sheep_nbt_tests {
("Pos", doubles(&[0.5, 64.0, 0.5])),
("Age", Tag::Int(-21359)),
]);
let m = mob_from_nbt(&saved, &types, 1).unwrap();
let m = mob_from_nbt(&saved, &types, 1, None).unwrap();
assert_eq!(m.extra.age, -21359);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
assert_eq!(number(tag.get("Age")), Some(-21359));
assert_eq!(number(tag.get("AgeLocked")), Some(0));
assert_eq!(number(tag.get("VillagerDataFinalized")), Some(1));
}
#[test]
fn foxes_mooshrooms_rabbits_and_goats_keep_their_kind() {
fn mobs_keep_their_variant_puff_state_and_carried_block() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
@@ -3430,11 +3570,18 @@ mod sheep_nbt_tests {
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let registry = horizon_world::registry::BlockRegistry::from_facts_json(
std::fs::File::open(root.join("facts.json")).unwrap(),
)
.unwrap();
let new = |kind: &str| Mob::new(1, types[kind].clone(), Vec3::new(0.0, 4.0, 0.0), 0.0);
let round_trip = |m: &Mob| {
let tag = mob_to_nbt(m, [1, 2, 3, 4], None);
(mob_from_nbt(&tag, &types, 2).unwrap(), tag)
let tag = mob_to_nbt(m, [1, 2, 3, 4], None, Some(&registry));
(mob_from_nbt(&tag, &types, 2, Some(&registry)).unwrap(), tag)
};
// A save with nothing but where the mob stands.
let empty =
|kind: &str| Tag::compound([("id", string(kind)), ("Pos", doubles(&[0.5, 64.0, 0.5]))]);
let mut fox = new("minecraft:fox");
fox.extra.variant = 1;
let (back, tag) = round_trip(&fox);
@@ -3452,6 +3599,24 @@ mod sheep_nbt_tests {
let (back, tag) = round_trip(&mooshroom);
assert_eq!(text(tag.get("Type")).as_deref(), Some("red"));
assert_eq!(back.extra.variant, 0);
// Its stew effects round-trip as the component's bare {id, duration}
// entries, and a cleared list is not written at all.
mooshroom.extra.variant = 1;
mooshroom.extra.stew_effects = Some(vec![(Arc::from("minecraft:night_vision"), 100)]);
let (back, tag) = round_trip(&mooshroom);
assert_eq!(back.extra.stew_effects, mooshroom.extra.stew_effects);
let entry = match tag.get("stew_effects") {
Some(Tag::List(list)) => list.first().expect("one effect"),
other => panic!("stew_effects: {other:?}"),
};
assert_eq!(
(text(entry.get("id")), number(entry.get("duration"))),
(Some("minecraft:night_vision".into()), Some(100))
);
mooshroom.extra.stew_effects = None;
let (back, tag) = round_trip(&mooshroom);
assert_eq!(back.extra.stew_effects, None);
assert!(tag.get("stew_effects").is_none());
let mut rabbit = new("minecraft:rabbit");
rabbit.extra.variant = 4;
let (back, tag) = round_trip(&rabbit);
@@ -3465,13 +3630,80 @@ mod sheep_nbt_tests {
(back.extra.screaming, back.extra.lost_horns),
(true, [false, true])
);
// A goat saved without horn flags has both, as vanilla reads it.
let bare = Tag::compound([
("id", string("minecraft:goat")),
// Pufferfish.addAdditionalSaveData: the puff state, 0 when the save
// has none and at most 2 when it has more.
let mut pufferfish = new("minecraft:pufferfish");
pufferfish.extra.size = 2;
let (back, tag) = round_trip(&pufferfish);
assert_eq!(number(tag.get("PuffState")), Some(2));
assert_eq!(back.extra.size, 2);
assert_eq!(
mob_from_nbt(&empty("minecraft:pufferfish"), &types, 3, Some(&registry))
.unwrap()
.extra
.size,
0
);
let puffed = Tag::compound([
("id", string("minecraft:pufferfish")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
("PuffState", Tag::Int(5)),
]);
assert_eq!(
mob_from_nbt(&bare, &types, 3).unwrap().extra.lost_horns,
mob_from_nbt(&puffed, &types, 3, Some(&registry))
.unwrap()
.extra
.size,
2
);
// Enderman.addAdditionalSaveData: the block it carries, a block name
// for the block's default state and `{id, properties}` otherwise.
let mut enderman = new("minecraft:enderman");
let grass = registry.default_state("minecraft:grass_block");
enderman.extra.carried = grass;
let (back, tag) = round_trip(&enderman);
assert_eq!(
text(tag.get("carriedBlockState")).as_deref(),
Some("minecraft:grass_block")
);
assert_eq!(back.extra.carried, grass);
let log = registry
.id(&horizon_world::chunk::BlockState {
name: "minecraft:oak_log".into(),
properties: [("axis".to_owned(), "x".to_owned())].into(),
})
.unwrap();
enderman.extra.carried = Some(log);
let (back, tag) = round_trip(&enderman);
assert!(matches!(
tag.get("carriedBlockState"),
Some(Tag::Compound(_))
));
assert_eq!(back.extra.carried, Some(log));
// Nothing carried: no key, and none read back; an air state reads as
// no block at all.
enderman.extra.carried = None;
let (back, tag) = round_trip(&enderman);
assert_eq!(tag.get("carriedBlockState"), None);
assert_eq!(back.extra.carried, None);
let air = Tag::compound([
("id", string("minecraft:enderman")),
("Pos", doubles(&[0.5, 64.0, 0.5])),
("carriedBlockState", string("minecraft:air")),
]);
assert_eq!(
mob_from_nbt(&air, &types, 3, Some(&registry))
.unwrap()
.extra
.carried,
None
);
// A goat saved without horn flags has both, as vanilla reads it.
assert_eq!(
mob_from_nbt(&empty("minecraft:goat"), &types, 3, None)
.unwrap()
.extra
.lost_horns,
[false; 2]
);
}
@@ -3515,6 +3747,60 @@ mod item_component_tests {
apply_item_components(&mut sheep, &egg);
assert_eq!(sheep.extra.color, 5, "lime, not the white it started as");
}
/// `EntityType.updateCustomEntityTag`: the egg's `entity_data` on the mob it
/// spawned, when the type is its own — the mob's compound, the data merged
/// over it and loaded back, as the spawn egg site does it.
#[test]
fn a_spawn_eggs_entity_data_reaches_the_mob_it_spawned() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let egg = |entity_data: Option<Tag>| {
let mut egg = crate::items::ItemStack::new("minecraft:zombie_spawn_egg", 1);
if let Some(data) = entity_data {
egg.data_mut()
.set_other("minecraft:entity_data", Some(data));
}
egg
};
let spawn = |stack: &crate::items::ItemStack| {
let mut m = Mob::new(
2,
types["minecraft:zombie"].clone(),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
apply_item_components(&mut m, stack);
let Some(data) =
crate::block_entity_item::spawn_egg_entity_data(stack, &m.kind.id, false)
else {
return m;
};
let tag = crate::data_cmd::merged(&mob_to_nbt(&m, [1, 2, 3, 4], None, None), data);
mob_from_nbt(&tag, &types, 2, None).unwrap()
};
// `entity_data={NoAI:1b}`: no id, so the egg's own type holds the data.
assert!(spawn(&egg(Some(Tag::compound([("NoAI", Tag::Byte(1))])))).no_ai);
// `entity_data.id` names another type: the egg's own type, data skipped.
let other = spawn(&egg(Some(Tag::compound([
("id", string("minecraft:pig")),
("NoAI", Tag::Byte(1)),
]))));
assert_eq!(other.kind.id.as_str(), "minecraft:zombie");
assert!(!other.no_ai);
// A plain egg is unchanged.
assert!(!spawn(&egg(None)).no_ai);
}
}
#[cfg(test)]
@@ -3599,15 +3885,15 @@ mod neutral_anger_tests {
m.extra.anger = 400;
m.extra.anger_end = 3000;
m.extra.angry_at = Some(crate::mobs::Who::Player);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
assert_eq!(number(tag.get("anger_end_time")), Some(3000));
assert!(tag.get("angry_at").is_some());
let back = mob_from_nbt(&tag, &types, 2).unwrap();
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert_eq!(back.extra.anger_end, 3000);
assert_eq!(back.extra.angry_at, Some(crate::mobs::Who::Player));
// A mob that is no NeutralMob writes no anger.
let pig = Mob::new(3, types["minecraft:pig"].clone(), Vec3::ZERO, 0.0);
let tag = mob_to_nbt(&pig, [1, 2, 3, 5], None);
let tag = mob_to_nbt(&pig, [1, 2, 3, 5], None, None);
assert!(tag.get("anger_end_time").is_none());
// The anger that is left when the mob comes back.
assert_eq!(crate::mobs::anger_left(3000, 1000), 2000);
@@ -3637,8 +3923,8 @@ mod held_item_name_tests {
let mut sword = crate::items::ItemStack::new("minecraft:diamond_sword", 1);
sword.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Old Sword".into()));
mob.extra.set_main_hand_stack(Some(sword));
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None);
let mut loaded = mob_from_nbt(&raw, &types, 2).unwrap();
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None);
let mut loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
assert_eq!(
loaded
.extra
@@ -3651,8 +3937,8 @@ mod held_item_name_tests {
loaded
.extra
.set_main_hand(Some("minecraft:diamond_sword".into()));
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw));
let restored = mob_from_nbt(&saved, &types, 3).unwrap();
let saved = mob_to_nbt(&loaded, [1, 2, 3, 4], Some(&raw), None);
let restored = mob_from_nbt(&saved, &types, 3, None).unwrap();
assert_eq!(
restored.extra.main_hand.as_deref(),
Some("minecraft:diamond_sword")
+35
View File
@@ -547,6 +547,17 @@ impl HandSway {
let y_bob = crate::camera::lerp(partial, self.y_bob_o, self.y_bob);
[(view_x_rot - x_bob) * 0.1, (view_y_rot - y_bob) * 0.1]
}
/// The hands' view space to camera-relative world space, turned by the
/// sway. The hand pass draws with the camera's view, whose rotation a
/// ridden boat's turn spreads over the frames of a tick
/// (`LocalPlayer.getViewYRot`): anything else swings the hands back and
/// forth by that turn, up to 10 degrees, 20 times a second (#4583).
pub fn to_world(&self, partial: f32, camera: &crate::camera::Camera) -> glam::Mat4 {
let [x, y] = self.rotation(partial, camera.x_rot, camera.y_rot);
crate::camera::view_matrix(camera.y_rot, camera.x_rot).inverse()
* glam::Mat4::from_rotation_x(x.to_radians())
* glam::Mat4::from_rotation_y(y.to_radians())
}
}
/// `HitResult` at the crosshair (`Minecraft.hitResult`).
@@ -966,6 +977,30 @@ mod tests {
);
}
/// #4583: while a boat turns the player steadily, the hands keep their
/// place on screen through every frame of a tick.
#[test]
fn the_hands_hold_still_on_screen_while_a_boat_turns() {
const TURN: f32 = 10.0;
let mut sway = HandSway::default();
let mut yaw = 0.0;
for _ in 0..30 {
sway.tick(0.0, yaw);
yaw += TURN;
}
let on_screen = |partial: f32| {
let camera = crate::camera::Camera {
y_rot: yaw - TURN * (1.0 - partial),
..Default::default()
};
crate::camera::view_matrix(camera.y_rot, 0.0) * sway.to_world(partial, &camera)
};
let first = on_screen(0.0);
for partial in [0.25, 0.5, 0.75, 0.99] {
let frame = on_screen(partial);
assert!(frame.abs_diff_eq(first, 1.0e-3), "{partial}: {frame:?}");
}
}
#[test]
fn drawing_a_bow_or_holding_a_charged_crossbow_hides_a_hand() {
let (bow, apple) = (
+301 -104
View File
@@ -87,6 +87,7 @@ mod creatures;
mod crossbow;
pub(crate) mod cushions;
mod debug_keys;
mod debug_options;
mod debug_stick;
mod decorated_pot;
mod dedicated;
@@ -160,6 +161,7 @@ mod maps;
mod minecarts;
mod mob_blocks;
mod mob_gear;
mod mob_looting;
mod mob_sounds;
mod mods;
mod mount_menu;
@@ -706,6 +708,8 @@ enum Screen {
DialogWarning,
/// WinScreen: the end poem and the credits (game/win.rs).
Win,
/// DebugOptionsScreen, F3+F6 (game/debug_options.rs).
DebugOptions,
}
struct Mining {
pos: [i32; 3],
@@ -1777,8 +1781,23 @@ fn equipment_modifier_applies(group: &str, slot: &str) -> bool {
/// A chunk whose ticking future can complete (`ChunkMap.prepareTickingChunk`):
/// lit, with its neighbours loaded, as `postprocess_chunks` waits for.
fn ticking_chunk(world: &World, x: i32, z: i32) -> bool {
world.chunks.get(&(x, z)).is_some_and(|c| c.lit)
&& (-1..=1).all(|dz| (-1..=1).all(|dx| world.chunks.contains_key(&(x + dx, z + dz))))
let Some(center) = world.chunks.get(&(x, z)) else {
return false;
};
if !center.lit {
return false;
}
for dz in -1..=1 {
for dx in -1..=1 {
if dx == 0 && dz == 0 {
continue;
}
if !world.chunks.contains_key(&(x + dx, z + dz)) {
return false;
}
}
}
true
}
/// Key events carry the scancode's US-layout code, which ignores XKB options like
/// `caps:swapescape`. For the keys whose meaning the keymap may remap, take the code
@@ -2387,8 +2406,11 @@ struct Game<'a> {
debug_keys: horizon_client::debug_keys::DebugKeys,
/// The F3 overlay's charts (F3+2, F3+3).
debug_charts: horizon_client::debug_chart::DebugCharts,
show_hitboxes: bool,
show_chunk_borders: bool,
/// `Minecraft.debugEntries`: what the overlay and the debug renderers
/// show (`debug-profile.json`, F3+F6).
debug_entries: horizon_client::debug_entries::DebugEntries,
/// `DebugOptionsScreen` while it is open (game/debug_options.rs).
debug_options: Option<debug_options::DebugOptionsUi>,
/// PauseScreen.showPauseMenu (false after F3+Escape).
pause_menu: bool,
/// MultiPlayerGameMode.previousLocalPlayerMode (F3+N goes back to it).
@@ -2410,8 +2432,16 @@ struct Game<'a> {
/// `remote_handle_input`): picked once, so a creative break does not
/// move the use that follows it past the broken block.
tick_hit: Option<Option<Hit>>,
/// `Player.entityInteractionRange` and `blockInteractionRange`, refreshed
/// each tick from the attribute (a server's reach modifier, creative's
/// +2): the pick helpers' `&self` reach.
entity_reach: f64,
block_reach: f64,
/// The part of a right click a guest's packet asks for (game/lan_actions.rs).
guest_use: Option<GuestUse>,
/// The last `use_hand` that ended the click did so with `FAIL`, which
/// does not consume the action (handleUseItemOn's ANY_BLOCK_USE).
use_failed: bool,
/// Level events of this tick for the guests, and the guest acting now.
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
/// Jukebox level events 1010 (song name) and 1011 (`None`) for the guests;
@@ -2616,8 +2646,9 @@ impl Game<'_> {
}
Some((self.eye_position()?, view_vector(self.yaw, self.pitch)))
}
/// `Player.blockInteractionRange`: the block pick's and mining's reach.
fn reach(&self) -> f64 {
if self.creative { 5.0 } else { 4.5 }
self.block_reach
}
/// Outline shapes that depend on more than the block state, as one box
/// in block space: `LightBlock` and `ScaffoldingBlock.getShape` are a full
@@ -2690,7 +2721,24 @@ impl Game<'_> {
return hit;
}
let (origin, dir) = self.aim()?;
self.clip_ray(origin, dir, self.reach())
// GameRenderer.pick: the block ray runs out to the larger of the two
// interaction ranges; filterHitResult then drops a hit past the
// block's.
self.clip_ray(origin, dir, self.block_reach.max(self.entity_reach))
.filter(|hit| {
let distance: [f64; 3] = std::array::from_fn(|i| hit.point[i] - origin[i]);
distance.iter().map(|v| v * v).sum::<f64>() <= self.block_reach.powi(2)
})
}
/// The same traversal without `filterHitResult`: the block hit that
/// bounds the entity ray in `GameRenderer.pick`, out to the larger
/// interaction range — a wall between the two still hides the entity.
fn pick_unfiltered(&self) -> Option<Hit> {
if let Some(hit) = self.forced_hit {
return Some(hit);
}
let (origin, dir) = self.aim()?;
self.clip_ray(origin, dir, self.block_reach.max(self.entity_reach))
}
/// The first block outline hit within `reach` along the unit `dir`.
fn clip_ray(&self, origin: [f64; 3], dir: [f64; 3], reach: f64) -> Option<Hit> {
@@ -2841,8 +2889,8 @@ impl Game<'_> {
/// item in use (LocalPlayer.modifyInput), the helmet's Respiration
/// (decreaseAirSupply), the wings, leather boots on powder snow
/// (PowderSnowBlock.canEntityWalkOnPowderSnow), freeze-immune wear
/// (LivingEntity.canFreeze), the dimension's FAST_LAVA and the sleeping
/// pose.
/// (LivingEntity.canFreeze), the dimension's FAST_LAVA, the sleeping
/// pose and blindness (Player.isMobilityRestricted, which stops a sprint).
pub(crate) fn sync_player_body(&mut self) {
let glider = self.glider_worn();
let sleeping = self.sleeping.is_some();
@@ -2867,10 +2915,12 @@ impl Game<'_> {
let freeze_immune = self.wears_freeze_immune();
let burn_immune = self.wears_burn_from_stepping_immunity();
let nudge = self.closest_space_nudge();
let blind = self.potions.effects.get("blindness").is_some();
let Some(player) = self.player.as_mut() else {
return;
};
player.nudge = nudge;
player.mobility_restricted = blind;
player.yaw = self.yaw;
player.pitch = self.pitch;
player.auto_jump_enabled = self.settings.auto_jump;
@@ -3713,35 +3763,46 @@ impl Game<'_> {
self.tick_chunks.recycle_added(added);
}
/// `DistanceManager`'s tickets of the level in place, from every player in
/// it (the local player, LAN guests, a server's players), its forced
/// chunks and its portal tickets. A game with neither a server nor a
/// player (the headless fixtures) ticks everywhere.
/// it (the local player, LAN guests, a server's players; a spectator the
/// rule skips holds none), its forced chunks and its portal tickets. A game
/// with neither a server nor a player (the headless fixtures) ticks
/// everywhere.
fn update_chunk_tickets(&mut self) {
let mut players: Vec<(i32, i32)> = self.lan.as_ref().map_or_else(Vec::new, |h| {
let rule = self.ui.rules.bool("spectators_generate_chunks");
let mut players: Vec<((i32, i32), bool)> = self.lan.as_ref().map_or_else(Vec::new, |h| {
h.hub
.guests
.iter()
.filter(|g| h.hub.here(g))
.map(|g| g.chunk())
.map(|g| (g.chunk(), g.spectator()))
.collect()
});
if let Some(p) = &self.player {
players.push((
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
(
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
),
self.spectator(),
));
}
let sources =
(!self.options.execution_role.is_client() || !players.is_empty()).then(|| {
horizon_client::ticket_levels::Sources {
players,
forced: self.streaming.forced.iter().copied().collect(),
// Filled from the `PORTAL` tickets by `update`.
portal: Vec::new(),
view_distance: self.settings.render_distance.clamp(2, 32),
simulation_distance: self.settings.simulation_distance.max(2),
}
});
// A spectator the rule skips is still a player: a game that has one
// must not take the headless fixtures' tick-everywhere case below.
let any_player = !players.is_empty();
let sources = (!self.options.execution_role.is_client() || any_player).then(|| {
horizon_client::ticket_levels::Sources {
players: players
.into_iter()
.filter(|&(_, spectator)| game_mode::loads_chunks(rule, spectator))
.map(|(chunk, _)| chunk)
.collect(),
forced: self.streaming.forced.iter().copied().collect(),
// Filled from the `PORTAL` tickets by `update`.
portal: Vec::new(),
view_distance: self.settings.render_distance.clamp(2, 32),
simulation_distance: self.settings.simulation_distance.max(2),
}
});
self.tickets.update(sources);
}
/// `ServerLevel.startTickingChunk`: a loaded chunk's saved ticks are
@@ -3769,6 +3830,7 @@ impl Game<'_> {
self.sync_tick_chunks();
self.start_ticking_chunks();
let levels = self.tickets.levels();
let bounds = levels.random_tick_bounds();
// Keep the world map's chunk order, including empty chunks
// (tick_chunk_random still draws for precipitation in those).
let ticking: Vec<_> = self
@@ -3776,7 +3838,14 @@ impl Game<'_> {
.chunks
.keys()
.copied()
.filter(|&(x, z)| levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z))
.filter(|&(x, z)| {
if let Some((min_x, min_z, max_x, max_z)) = bounds
&& (x < min_x || x > max_x || z < min_z || z > max_z)
{
return false;
}
levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z)
})
.collect();
// Entity.checkInsideBlocks for the plates, tripwires, detector rails and
// buttons under the players (the host and the LAN guests), the minecarts
@@ -5080,6 +5149,12 @@ impl Game<'_> {
if entity && hand == Hand::Off && self.with_hand(hand, Game::interact_entity)? {
return Ok(Some(hand));
}
// A client of a server never hits a fence knot with its crosshair:
// `LeadItem.useOn` and `LeashFenceKnotEntity.interact` reach the
// server as a click on the fence block.
if on_block && self.with_hand(hand, Game::interact_knot) {
return Ok(Some(hand));
}
if self.with_hand(hand, |game| game.use_hand(block_hit.as_ref()))? {
return Ok(Some(hand));
}
@@ -5137,6 +5212,7 @@ impl Game<'_> {
/// One hand's turn in `startUseItem`: `useItemOn` the block aimed at,
/// then `useItem`. Whether the click ends here (it acted or failed).
fn use_hand(&mut self, block_hit: Option<&Hit>) -> Result<bool> {
self.use_failed = false;
// isSecondaryUseActive with something in either hand keeps blocks
// from reacting.
let sneaking = self.keys.contains(&KeyCode::ShiftLeft);
@@ -5148,6 +5224,8 @@ impl Game<'_> {
let mut canceled = false;
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
if canceled {
// Nothing was used, so nothing consumed the action.
self.use_failed = true;
return Ok(true);
}
}
@@ -5263,8 +5341,10 @@ impl Game<'_> {
let cake = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let adds_candle =
self.with_level(|b, _, _| b.cake_candle_state(cake, item.as_deref()).is_some());
let result = self.with_level(|b, level, ticks| {
b.use_item_on(
let pots_flower = self
.with_level(|b, _, _| b.potted_flower_state(cake, item.as_deref()).is_some());
let (result, branch) = self.with_level(|b, level, ticks| {
b.use_item_on_branch(
level,
ticks,
BlockPos::new(hit.pos[0], hit.pos[1], hit.pos[2]),
@@ -5282,7 +5362,15 @@ impl Game<'_> {
{
self.stats.add("used", &used.item, 1);
self.consume_held();
self.trigger_block("minecraft:item_used_on_block", hit.pos, used);
}
// FlowerPotBlock.useItemOn: the plant is potted, used up, and counted.
if pots_flower
&& result == horizon_world::level::InteractionResult::Success
&& let Some(used) = held.as_ref()
{
self.stats.add("used", &used.item, 1);
self.stats.custom("pot_flower", 1);
self.consume_held();
}
// NoteBlock.useWithoutItem (tuned) and BellBlock.onHit (rung).
if result == horizon_world::level::InteractionResult::Success {
@@ -5300,6 +5388,17 @@ impl Game<'_> {
}
}
if result.consumes_action() {
// ServerPlayerGameMode.useItemOn: ITEM_USED_ON_BLOCK with
// the stack before the use, or DEFAULT_BLOCK_USE.
match branch {
horizon_world::level::UseBranch::ItemOn => {
let used = held.unwrap_or_else(|| ItemStack::new("minecraft:air", 0));
self.trigger_block("minecraft:item_used_on_block", hit.pos, &used);
}
horizon_world::level::UseBranch::WithoutItem => {
self.trigger_default_block_use(hit.pos);
}
}
self.swing_arm();
return Ok(true);
}
@@ -5352,6 +5451,9 @@ impl Game<'_> {
// `ServerPlayerGameMode.useItemOn`: a consuming `useOn`
// triggers ITEM_USED_ON_BLOCK with the stack before the use.
self.trigger_block("minecraft:item_used_on_block", hit.pos, &stack);
} else {
// DebugStickItem.useOn's FAIL.
self.use_failed = true;
}
return Ok(true);
}
@@ -5370,7 +5472,10 @@ impl Game<'_> {
}
return Ok(true);
}
UseResult::Fail => return Ok(true),
UseResult::Fail => {
self.use_failed = true;
return Ok(true);
}
UseResult::Pass => {}
}
// PotionItem.useOn comes before PotionItem.use's drink.
@@ -5449,6 +5554,7 @@ impl Game<'_> {
|| self.place_painting(hit, &stack)
|| self.place_item_frame(hit, &stack)
|| self.use_lodestone_compass(hit, &stack)
|| self.use_map_on_banner(hit, &stack)
|| self.place_armor_stand(hit, &stack)
|| self.place_cushion(hit, &stack)
|| self.place_end_crystal(hit, &stack))
@@ -5511,6 +5617,9 @@ impl Game<'_> {
// which ItemStack.useOn counts as a use of the item.
if self.start_eating(&info) {
self.stats.add("used", &stack.item, 1);
} else {
// BlockItem.place's FAIL.
self.use_failed = true;
}
return Ok(true);
};
@@ -6157,6 +6266,10 @@ impl Game<'_> {
if self.agent.hold_server {
return Ok(());
}
// `Player.entityInteractionRange`/`blockInteractionRange` for the tick's
// picks: the attribute carries creative's and a server's modifiers.
self.entity_reach = self.interaction_range("minecraft:entity_interaction_range", 3.0, 5.0);
self.block_reach = self.interaction_range("minecraft:block_interaction_range", 4.5, 5.0);
self.tick_debug_keys()?;
self.tick_debug_charts();
self.tick_tutorial();
@@ -6588,9 +6701,12 @@ impl Game<'_> {
// ticks; ServerLevel.tick then runs the scheduled and random block
// ticks before any entity, so what they drop or spawn (dispensed
// items, popped crops) moves on the tick it appears, as in vanilla.
// On the server thread the window is a hub guest: drain its packets
// here too, before falling-block schedules run (#4510).
horizon_client::spans::time("tick_local_hand", || self.tick_local_hand())?;
horizon_client::spans::time("postprocess_chunks", || self.postprocess_chunks())?;
if running {
horizon_client::spans::time("tick_lan_packets", || self.tick_lan_packets());
horizon_client::spans::time("tick_blocks", || self.tick_blocks())?;
}
if running {
@@ -6799,6 +6915,7 @@ impl Game<'_> {
| Screen::ConfirmLink
| Screen::DialogWarning
| Screen::CommandBlock
| Screen::DebugOptions
) || self.book.is_some()
|| self.win_screen_pauses())
}
@@ -7155,7 +7272,16 @@ impl Game<'_> {
}
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
let mut batch = HudBatch::new(size);
// GlobalSettingsUniform's GameTime, for a resource pack's text shader.
batch.game_time = ((self.world.game_time.rem_euclid(24000)) as f32
+ self.clock.fraction() as f32)
/ 24000.0;
let (w, h) = (batch.width, batch.height);
// HDR calibration ends when its page does, whatever moved the screen
// off Options (a death screen opened by a server, say).
if self.screen != Screen::Options && self.ui.options.calibration_fullscreen.is_some() {
self.show_calibration(h);
}
if self.screen == Screen::Win {
self.draw_win_screen(&mut batch);
return batch;
@@ -7455,6 +7581,7 @@ impl Game<'_> {
Screen::DialogWarning => self.draw_dialog_warning(&mut batch),
Screen::CommandBlock => self.draw_command_block(&mut batch),
Screen::BlockEditor => self.draw_block_editor(&mut batch),
Screen::DebugOptions => self.draw_debug_options(&mut batch),
Screen::Inventory => {
self.draw_inventory(&mut batch);
self.draw_mod_browser(&mut batch);
@@ -9418,23 +9545,23 @@ impl Game<'_> {
p.z + (q.z - p.z) * a,
)
};
let mut casters: Vec<(glam::DVec3, &str, f32)> =
let mut casters: Vec<(glam::DVec3, f32, f32)> =
horizon_client::shadows::mob_casters(&self.mobs, a).collect();
let fixed = |id: &'static str| (id, horizon_client::shadows::shadow_params(id).0);
let (item, item_radius) = fixed("minecraft:item");
let item = horizon_client::shadows::shadow_params("minecraft:item");
casters.extend(
self.dropped
.iter()
.map(|i| (lerp(i.previous, i.position), item, item_radius)),
.map(|i| (lerp(i.previous, i.position), item.0, item.1)),
);
let (orb, orb_radius) = fixed("minecraft:experience_orb");
let orb = horizon_client::shadows::shadow_params("minecraft:experience_orb");
casters.extend(
self.orbs
.iter()
.map(|o| (lerp(o.previous, o.position), orb, orb_radius)),
.map(|o| (lerp(o.previous, o.position), orb.0, orb.1)),
);
for (pos, id, radius) in casters {
let strength = horizon_client::shadows::shadow_params(id).1;
// Text, block and item displays (DisplayRenderer's shadow radius).
casters.extend(self.text_displays.values().filter_map(|d| d.shadow(a)));
for (pos, radius, strength) in casters {
horizon_client::shadows::shadow(
&self.world,
self.res.facts(),
@@ -9802,13 +9929,15 @@ impl Game<'_> {
}
fn mob_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64)> = None;
for (i, m) in self.mobs.iter().enumerate() {
// ProjectileUtil.getEntityHitResult: the mob the player rides
// shares its root vehicle and is never the hit, nor is the
// camera entity the pick starts from.
if !m.alive() || Some(m.id) == self.riding || Some(m.id) == self.remote_camera_id() {
// camera entity the pick starts from. EntitySelector.CAN_BE_PICKED
// is isPickable — `!isRemoved`, not alive: a health-0 fake player
// still answers a click.
if m.removed || Some(m.id) == self.riding || Some(m.id) == self.remote_camera_id() {
continue;
}
if let Some(t) = ray_box(eye, dir, reach, m.bounds())
@@ -9828,14 +9957,10 @@ impl Game<'_> {
target_kind: &str,
living: bool,
) -> (f32, f32, bool, bool) {
let hand_damage = 1.0 + self.attack_item_info().map_or(0.0, |i| i.attack_damage);
let hand_damage = self.attack_damage();
// Riptide's spin replaces the attack damage attribute.
let spin = self.player.as_ref().map_or(0.0, |p| p.auto_spin_attack_dmg);
let damage = if spin > 0.0 {
spin
} else {
hand_damage + self.effect_attack_bonus()
};
let damage = if spin > 0.0 { spin } else { hand_damage };
let scale = self.attack_strength_scale();
// getEnchantedDamage(target, damage, source) - damage, taken before the
// attack strength scales the base damage.
@@ -10003,14 +10128,7 @@ impl Game<'_> {
// A painting nearer than the targeted block and mob breaks instead.
if let Some((i, d)) = self.painting_hit()
&& self.mob_hit().is_none_or(|m| d < m.1)
&& self.pick().is_none_or(|h| {
let eye = self.eye_position().unwrap_or_default();
(0..3)
.map(|a| (h.point[a] - eye[a]).powi(2))
.sum::<f64>()
.sqrt()
> d
})
&& self.before_block(d)
{
self.break_painting(i, true);
return true;
@@ -10032,15 +10150,8 @@ impl Game<'_> {
let Some((index, distance)) = self.mob_hit() else {
return false;
};
if let Some(hit) = self.pick() {
let eye = self.eye_position().unwrap_or_default();
let d = ((hit.point[0] - eye[0]).powi(2)
+ (hit.point[1] - eye[1]).powi(2)
+ (hit.point[2] - eye[2]).powi(2))
.sqrt();
if d < distance {
return false;
}
if !self.before_block(distance) {
return false;
}
self.attack_mob(index);
// Minecraft.startAttack swings after the attack.
@@ -10356,8 +10467,8 @@ impl Game<'_> {
creative,
options,
} => self.open_world(&folder, Some(&name), seed, creative, Some(&options))?,
Action::Connect { address } => {
self.connect_server(&address, &self.options.player_name())?
Action::Connect { address, lan } => {
self.connect_server_with_options(&address, &self.options.player_name(), None, lan)?
}
Action::DataPacks { dir, selected } => {
self.keys.clear();
@@ -10479,6 +10590,13 @@ impl Game<'_> {
self.score = Default::default();
self.fns = Default::default();
self.world_tables = self.build_world_tables();
// The registries a preload or an earlier world read are not this
// world's packs': a world with packs sends its own (their dialogs,
// recipes and advancements). For a world without packs, `sync_data`
// drops what was read with packs.
if self.world_tables.is_some() {
lan::forget_sync_data();
}
// Warm the loot data with this world's packs before chunks decode
// (`save::roll_loaded_loot` reads the cached copy).
let _ = horizon_client::structure_blocks::world_loot_data(
@@ -12118,12 +12236,8 @@ impl Game<'_> {
}
});
let heights = self.heights.interpolated(a);
let view = horizon_client::camera::view_matrix(self.yaw, self.pitch);
// The hand sway: both hands turn by a tenth of the bobs' lag.
let [sway_x, sway_y] = self.sway.rotation(a, self.pitch, self.yaw);
let to_world = view.inverse()
* glam::Mat4::from_rotation_x(sway_x.to_radians())
* glam::Mat4::from_rotation_y(sway_y.to_radians());
let to_world = self.sway.to_world(a, &self.view);
let eye = self.eye_position().unwrap_or_default();
let light = self.world.light(
eye[0].floor() as i32,
@@ -13299,10 +13413,16 @@ impl Game<'_> {
horizon_client::level_loading::draw_progress(
&mut hud,
&self.gui.atlas,
if loader.cancelled {
"Cancelling world"
&if loader.cancelled {
horizon_client::lang::get_or(
"horizon.loading.cancelling_world",
"Cancelling world",
)
} else {
"Opening world"
horizon_client::lang::get_or(
"horizon.loading.opening_world",
"Opening world",
)
},
0.0,
);
@@ -13970,6 +14090,7 @@ impl Game<'_> {
Screen::BlockEditor => self.close_block_editor(),
Screen::Chat => self.close_chat(chat::ChatExit::Intentional),
Screen::Win => self.close_win_screen()?,
Screen::DebugOptions => self.close_debug_options(),
},
KeyCode::Digit1
| KeyCode::Digit2
@@ -14182,7 +14303,7 @@ impl Game<'_> {
Some(renderer) => {
horizon_client::graphics::lod_distance_capped(distance, &renderer.info)
}
None => distance.min(1024),
None => distance.min(horizon_client::lod::MAX_CHUNKS),
}
}
/// Only enabled LOD worlds extend the renderer's range, never simulation.
@@ -14843,6 +14964,9 @@ impl ApplicationHandler for Game<'_> {
if self.screen == Screen::ServerLinks && d != 0.0 {
self.server_links_scroll(d);
}
if self.screen == Screen::DebugOptions && d != 0.0 {
self.debug_options_scroll(d);
}
if self.screen == Screen::Advancements {
self.advancements_scroll(scaled(raw_x) as f32, d);
}
@@ -15040,6 +15164,9 @@ impl ApplicationHandler for Game<'_> {
Screen::ServerLinks if pressed && button == MouseButton::Left => {
self.server_links_click()?
}
Screen::DebugOptions if pressed && button == MouseButton::Left => {
self.debug_options_click()
}
Screen::ConfirmLink if pressed && button == MouseButton::Left => {
self.confirm_link_click()
}
@@ -15210,6 +15337,9 @@ impl ApplicationHandler for Game<'_> {
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::CommandBlock => {
self.command_block_key(&event)
}
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::DebugOptions => {
self.debug_options_key(&event)
}
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::BlockEditor => {
self.block_editor_key(&event)
}
@@ -15293,7 +15423,7 @@ impl ApplicationHandler for Game<'_> {
// Screen.keyPressed sees the physical key, repeats included.
if event.state == ElementState::Pressed
&& self.screen == Screen::Inventory
&& self.merchant_key(key)
&& (self.merchant_key(key) || self.recipe_book_key(key))
{
return Ok(());
}
@@ -16045,8 +16175,10 @@ impl<'a> Game<'a> {
friends: None,
debug_keys: Default::default(),
debug_charts: Default::default(),
show_hitboxes: false,
show_chunk_borders: false,
debug_entries: horizon_client::debug_entries::DebugEntries::load(
&std::env::current_dir().unwrap_or_default(),
),
debug_options: None,
pause_menu: true,
previous_mode: None,
gamemode_switcher: None,
@@ -16057,7 +16189,10 @@ impl<'a> Game<'a> {
world_icon: None,
forced_hit: None,
tick_hit: None,
entity_reach: 3.0,
block_reach: 4.5,
guest_use: None,
use_failed: false,
lan_events: Vec::new(),
lan_songs: Vec::new(),
lan_cushion_breaks: Vec::new(),
@@ -16191,6 +16326,7 @@ enum PendingWorldEntry {
Server {
address: String,
name: String,
lan: bool,
/// A transfer's carried-over server cookies (`TransferState`).
cookies: Option<horizon_client::net::Cookies>,
},
@@ -16288,8 +16424,9 @@ impl Game<'_> {
address,
name,
cookies,
lan,
}) => {
self.connect_server_with_cookies(&address, &name, cookies)?;
self.connect_server_with_options(&address, &name, cookies, lan)?;
}
None => {}
}
@@ -16378,18 +16515,24 @@ impl Game<'_> {
}
self.world_tables = self.build_world_tables();
horizon_client::structure_blocks::forget_loot_data();
lan::forget_sync_data();
// PlayerList.reloadResources: the tags and recipes go to the players
// and their advancements reload.
if self.lan.is_some() {
match lan::sync_data(&self.options.resources, &self.pack_files()) {
Ok(sync) => {
if let Some(host) = self.lan.as_mut() {
host.hub.reload_resources(sync);
// and their advancements reload. The synchronized registries (the
// dialogs, and the commands' ids of them) stay the hub's.
let kept = self.lan.as_ref().map(|host| host.hub.lan.shared.sync());
match kept {
Some(kept) => {
match lan::reload_sync_data(&self.options.resources, &self.pack_files(), &kept) {
Ok(sync) => {
if let Some(host) = self.lan.as_mut() {
host.hub.reload_resources(sync);
}
}
Err(e) => {
log::warn!("Horizon: the players are not sent the reloaded data: {e}")
}
}
Err(e) => log::warn!("Horizon: the players are not sent the reloaded data: {e}"),
}
None => lan::forget_sync_data(),
}
let overlay = pack_files(&self.world_tables);
self.adv.tree = Arc::new(horizon_client::advancements::Tree::load_with(
@@ -17085,16 +17228,20 @@ fn build_renderer(
.iter()
.map(|(rect, animation)| (*rect, animation.clone(), (usize::MAX, 0, 0)))
.collect();
renderer
.hud
.set_text_shader(&renderer.device, atlas.text_shader.as_deref());
Ok(renderer)
}
/// The loading screen's renderer: the real HUD atlas and font over one opaque
/// white block-sprite layer, an empty border texture and the title panorama
/// once the menu bundle is ready.
/// The loading screen's renderer: one atlas (the boot screen's until the
/// pack's GUI atlas is ready) over one opaque white block-sprite layer, an
/// empty border texture and the title panorama once the menu bundle is ready.
fn loading_renderer(
window: Arc<Window>,
offscreen: bool,
gui: Option<&UiResources>,
atlas: Option<&horizon_client::hud::HudAtlas>,
panorama: Option<&horizon_client::gfx::panorama::Faces>,
previous: Option<&Renderer>,
api: horizon_client::graphics::GraphicsApi,
) -> Result<Renderer> {
@@ -17105,16 +17252,14 @@ fn loading_renderer(
size: 1,
pixels: [0u8; 4].repeat(6),
};
let panorama = gui.map_or(&empty_panorama, |gui| gui.panorama.as_ref());
let atlas = gui.map(|gui| &gui.atlas);
let (pixels, width, height) = match atlas {
Some(atlas) => (atlas.pixels.as_slice(), atlas.width, atlas.height),
None => (EMPTY_HUD.as_slice(), 1, 1),
};
let (glint_pixels, glint_width, glint_height) = match atlas {
Some(atlas) => (atlas.glint.0.as_slice(), atlas.glint.1, atlas.glint.2),
None => (EMPTY_GLINT.as_slice(), 1, 1),
};
let panorama = panorama.unwrap_or(&empty_panorama);
let (pixels, width, height) = atlas.map_or((EMPTY_HUD.as_slice(), 1, 1), |a| {
(a.pixels.as_slice(), a.width, a.height)
});
let (glint_pixels, glint_width, glint_height) = atlas
.map_or((EMPTY_GLINT.as_slice(), 1, 1), |a| {
(a.glint.0.as_slice(), a.glint.1, a.glint.2)
});
// A glyph rasterized into an earlier texture is not in these pixels; the
// new texture gets it again when the character is drawn (see
// `build_renderer`).
@@ -17150,6 +17295,20 @@ fn loading_renderer(
}
/// A title-shaped frame: black clear and panorama, only the HUD is drawn.
/// The window's `HWND`, for the boot screen's GDI paint (`horizon_threads`):
/// the raw handle read is safe, the paint is not.
#[cfg(target_os = "windows")]
fn hwnd(window: &Window) -> Option<isize> {
use winit::raw_window_handle::{HasWindowHandle as _, RawWindowHandle};
match window.window_handle() {
Ok(raw) => match raw.as_raw() {
RawWindowHandle::Win32(handle) => Some(handle.hwnd.get()),
_ => None,
},
Err(_) => None,
}
}
fn loading_frame(hud: &HudBatch) -> Frame<'_> {
Frame {
weather: &[],
@@ -17411,6 +17570,8 @@ fn run_event_loop(mut game: Game<'_>) -> Result<()> {
struct Loading {
window: Option<Arc<Window>>,
renderer: Option<Renderer>,
/// The boot screen's atlas, until the pack's GUI atlas replaces it.
boot: horizon_client::hud::HudAtlas,
gui: Option<UiResources>,
settings: Settings,
fullscreen: horizon_client::window::FullscreenState,
@@ -17466,21 +17627,54 @@ impl Loading {
}
};
horizon_client::telemetry::note_display(&window);
// The renderer takes seconds to build and winit's window sits white
// until the first frame presents, so paint the boot screen on it and
// keep repainting it as the console's lines arrive; the wgpu frames
// redraw the same design when they start. GDI is unsafe:
// horizon-threads holds it (this crate forbids unsafe code).
#[cfg(target_os = "windows")]
let boot_paint_stop = hwnd(&window).map(|hwnd| {
let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let repaint_stop = stop.clone();
std::thread::spawn(move || {
while !repaint_stop.load(std::sync::atomic::Ordering::Relaxed) {
horizon_threads::boot_paint::paint(
hwnd,
horizon_theme::fonts::GEIST_SEMIBOLD,
horizon_theme::fonts::GEIST_MONO,
&horizon_client::logging::recent_lines(),
);
std::thread::sleep(std::time::Duration::from_millis(300));
}
});
stop
});
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
match loading_renderer(
window.clone(),
offscreen,
self.gui.as_ref(),
Some(&self.boot),
None,
None,
self.settings.horizon.api,
) {
Ok(r) => self.renderer = Some(r),
Err(e) => {
#[cfg(target_os = "windows")]
if let Some(stop) = &boot_paint_stop {
stop.store(true, std::sync::atomic::Ordering::Relaxed);
}
self.error = Some(e);
event_loop.exit();
return;
}
}
// The wgpu frames take the window over: stop the boot paint thread
// before the first one presents.
#[cfg(target_os = "windows")]
if let Some(stop) = &boot_paint_stop {
stop.store(true, std::sync::atomic::Ordering::Relaxed);
}
horizon_client::hud::GUI_SCALE.store(self.settings.gui_scale, Ordering::Relaxed);
self.window = Some(window);
}
@@ -17512,10 +17706,12 @@ impl Loading {
if let Some(renderer) = &mut self.renderer {
renderer.release_surface();
}
let bundle = self.gui.as_ref().expect("the menu bundle was just set");
match loading_renderer(
window,
offscreen,
self.gui.as_ref(),
Some(&bundle.atlas),
Some(bundle.panorama.as_ref()),
self.renderer.as_ref(),
self.settings.horizon.api,
) {
@@ -17768,6 +17964,7 @@ fn run_loading(
let loading = Loading {
window: None,
renderer: None,
boot: horizon_client::hud::HudAtlas::boot(),
gui: None,
settings,
fullscreen: horizon_client::window::FullscreenState::new(false),
@@ -1424,6 +1424,38 @@ impl Game<'_> {
adv::conditions(&c["location"], &ctx, w)
});
}
/// `DefaultBlockInteractionTrigger` (default_block_use): the player, and
/// the block's `BLOCK_USE` context at its centre, with no tool.
pub(super) fn trigger_default_block_use(&mut self, pos: [i32; 3]) {
self.trigger_block_use("minecraft:default_block_use", pos, None);
}
/// `AnyBlockInteractionTrigger` (any_block_use): the player, and the
/// block's `ADVANCEMENT_LOCATION` context at its centre with the stack in
/// hand as the use left it.
pub(super) fn trigger_any_block_use(&mut self, pos: [i32; 3], stack: &ItemStack) {
self.trigger_block_use("minecraft:any_block_use", pos, Some(stack));
}
fn trigger_block_use(&mut self, name: &str, pos: [i32; 3], stack: Option<&ItemStack>) {
let me = self.player_entity();
let it = stack.map(item_view);
let centre = pos.map(|c| f64::from(c) + 0.5);
self.trigger(name, move |c, w| {
let player = adv::Entities {
this: Some(&me),
origin: me.position,
tool: None,
block_pos: None,
};
let location = adv::Entities {
origin: centre,
tool: it.as_ref(),
block_pos: Some(pos),
..player
};
adv::conditions(&c["player"], &player, w)
&& adv::conditions(&c["location"], &location, w)
});
}
/// `LightningStrikeTrigger`: the bolt, and the mobs around it that it did
/// not hit (`bystander`), each tested from this player's position.
pub(super) fn trigger_lightning_strike(&mut self, bolt: &EntityView, viewers: &[EntityView]) {
+4 -1
View File
@@ -424,6 +424,7 @@ impl Game<'_> {
Screen::CommandBlock => "command-block",
Screen::BlockEditor => "block-editor",
Screen::Win => "win",
Screen::DebugOptions => "debug-options",
}
}
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
@@ -659,7 +660,9 @@ impl Game<'_> {
list.push((d, v));
}
};
for m in self.mobs.iter().filter(|m| m.alive()) {
// CAN_BE_PICKED's isPickable (`!isRemoved`): a dead-but-tracked mob
// — a fake player the server keeps at health 0 — is still here.
for m in self.mobs.iter().filter(|m| !m.removed) {
let mut v = json!({ "id": m.id, "kind": "mob", "health": m.health, "baby": m.is_baby(), "hurt_time": m.hurt_time });
// VillagerData as this game knows it: on a client, what the
// server's synched data says.
+35 -20
View File
@@ -29,14 +29,17 @@ impl Game<'_> {
.map(|(k, _)| *k)
.collect()
});
let dancing = jukeboxes.iter().any(|k| {
(f64::from(k.0) + 0.5 - pos.x).powi(2)
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
<= 100.0
});
self.mobs[i].extra.flag =
dancing && !horizon_client::mobs::goals::should_panic(&self.mobs[i]);
let nearest = jukeboxes
.iter()
.map(|k| {
(f64::from(k.0) + 0.5 - pos.x).powi(2)
+ (f64::from(k.1) + 0.5 - pos.y).powi(2)
+ (f64::from(k.2) + 0.5 - pos.z).powi(2)
})
.fold(f64::INFINITY, f64::min);
// `Allay.setDancing` wants effective AI: a `NoAI` allay never dances.
let panicking = horizon_client::mobs::goals::should_panic(&self.mobs[i]);
self.mobs[i].extra.flag = allay::dances(self.mobs[i].no_ai, panicking, nearest);
let Some(held) = self.mobs[i].extra.main_hand_stack().cloned() else {
self.mobs[i].extra.point = None;
continue;
@@ -47,9 +50,23 @@ impl Game<'_> {
.items()
.get(&held.item)
.map_or(64, |i| i.max_stack as i32);
// GoToWantedItem / wantsToPickUp: a stack it considers equal within 32
// that the allay sees (NearestItemSensor's hasLineOfSight, eye to eye).
let wanted = if griefing && carried < max && self.mobs[i].extra.ints[3] <= 0 {
// `Mob.aiStep`'s looting loop asks for no AI: `canPickUpLoot`, the
// inventory room in `wantsToPickUp` and mob_griefing are its gates.
let wants = self.mobs[i].can_pick_up_loot() && griefing && carried < max;
let reach = allay::pickup_reach(self.mobs[i].bounds());
// GoToWantedItem: a stack it considers equal within 32 that the
// allay sees (NearestItemSensor's hasLineOfSight, eye to eye). The
// sensor is the brain's, so without AI only `Mob.aiStep`'s looting
// loop runs: it takes an equal drop already in `getPickupReach`.
let wanted = if !wants {
None
} else if self.mobs[i].no_ai {
self.dropped.iter().position(|e| {
e.pickup_delay == 0
&& allay::considers_item_equal(&held, &e.stack)
&& reach.contains(e.position)
})
} else {
let w = self.mob_world_base(&[]);
let eye = horizon_core::geometry::Vec3::new(pos.x, self.mobs[i].eye_y(), pos.z);
// The sensor looks in the allay's box grown by (32, 16, 32).
@@ -78,20 +95,18 @@ impl Game<'_> {
w.line_of_sight(eye, at)
})
.min_by(|a, b| a.1.total_cmp(&b.1))
} else {
None
.map(|(j, _)| j)
};
let Some((j, _)) = wanted else {
let Some(j) = wanted else {
self.mobs[i].extra.point = None;
self.allay_deliver(i);
// `GiveInventoryToLookTargetPlayer` is the brain's too.
if !self.mobs[i].no_ai {
self.allay_deliver(i);
}
continue;
};
let e = &self.dropped[j];
// getPickupReach (1, 1, 1) around the box.
let b = self.mobs[i]
.bounds()
.inflate(horizon_core::geometry::Vec3::new(1.0, 1.0, 1.0));
if b.contains(e.position) {
if reach.contains(e.position) {
let take = (max - carried).min(e.stack.count as i32);
let (item, id) = (e.clone(), self.mobs[i].id);
// It carries the fetched stack, potion and all, not just its id.
+12 -5
View File
@@ -441,7 +441,7 @@ impl Game<'_> {
/// The armor stand under the crosshair, its distance and the hit point.
pub(super) fn armor_stand_hit(&self) -> Option<(usize, f64, [f64; 3])> {
let (eye, dir) = self.aim()?;
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64, [f64; 3])> = None;
for (i, a) in self.armor_stands.iter().enumerate() {
if a.marker {
@@ -707,14 +707,14 @@ impl Game<'_> {
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
continue;
}
let in_water = a.in_water(&self.world, &self.res.motion().fluids);
let in_water = a.update_fluids(&self.world, &self.res.motion().fluids);
let (world, registry, facts, collision) = (
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
);
a.tick(&mut scene, in_water, &mut |bounds, velocity| {
a.tick(&mut scene, facts, in_water, &mut |bounds, velocity| {
horizon_client::motion::bubble_column_push(
world, registry, facts, collision, bounds, velocity,
);
@@ -1092,7 +1092,8 @@ fn swap_with_equipment_slot(
}
impl Game<'_> {
/// `Equippable.swapWithEquipmentSlot`: right-clicking armor swaps it into
/// its slot (the worn piece comes back to the hand).
/// its slot (the worn piece comes back to the hand). It runs on a client
/// too (`Item.use`), as the prediction the server's slot packets confirm.
pub(super) fn equip_from_hand(&mut self, stack: &ItemStack) -> bool {
let Some(info) = self.items().get(&stack.item) else {
return false;
@@ -1125,11 +1126,17 @@ impl Game<'_> {
if let Some(old) = swap.to_inventory
&& let Some(rest) = self.give(old)
&& !self.creative
// Player.drop is the server's on a client: it spawns the item.
&& self.remote.is_none()
&& let Some(eye) = self.eye_position()
{
self.spawn_item(rest, eye, [0.0; 3], 40);
}
self.apply_equipment();
// The attribute modifiers of a worn piece come from the server's
// ClientboundUpdateAttributesPacket on a client.
if self.remote.is_none() {
self.apply_equipment();
}
self.swing_arm();
true
}
+33 -1
View File
@@ -1,7 +1,7 @@
//! Placed banners (`BannerBlockEntity`): the pattern layers move from the
//! banner item to the block when placed and back to the dropped item when
//! broken (`copy_components` in the banner loot tables).
use super::Game;
use super::{Game, maps::MapBanner};
use horizon_client::{
banner::{self, BannerLayer},
containers::BlockEntity,
@@ -16,6 +16,38 @@ impl Game<'_> {
};
layer_textures(&self.stations().banners, &b.layers)
}
/// The banner at `pos` as a map marker source (`MapBanner.fromWorld`).
pub(super) fn map_banner_at(&self, pos: [i32; 3]) -> Option<MapBanner> {
let state = self.world.block(pos[0], pos[1], pos[2]);
map_banner(
pos,
self.world.block_entities.get(&(pos[0], pos[1], pos[2])),
&self.res.facts().get(state).name,
)
}
}
/// `MapBanner.fromWorld`: the banner whose block entity is `entity` at `pos`,
/// with the base colour of its block (`BannerBlockEntity.getBaseColor` is
/// `AbstractBannerBlock.getColor`) and its custom name; none when the block
/// entity is not a banner.
pub(super) fn map_banner(
pos: [i32; 3],
entity: Option<&BlockEntity>,
block: &str,
) -> Option<MapBanner> {
let Some(BlockEntity::Banner(banner)) = entity else {
return None;
};
let block = block.strip_prefix("minecraft:").unwrap_or(block);
let color = block
.strip_suffix("_wall_banner")
.or_else(|| block.strip_suffix("_banner"))?;
Some(MapBanner {
pos,
color: color.to_owned(),
name: banner.name.clone(),
})
}
/// Texture names and tints of banner layers (`Sheets.getBannerSprite`).
+60 -17
View File
@@ -832,7 +832,7 @@ impl Game<'_> {
}
pub(super) fn boat_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64)> = None;
for (i, b) in self.boats.iter().enumerate() {
if Some(b.id) == self.riding {
@@ -877,16 +877,7 @@ impl Game<'_> {
}
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
if self.boats[i].chest && (sneaking || self.riding.is_some() || !self.boat_has_room(i)) {
let b = &self.boats[i];
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
self.world.block_entities.insert(
key,
horizon_client::containers::BlockEntity::Chest(b.items.clone()),
);
self.container = Some(key);
self.screen = super::Screen::Inventory;
self.capture(false);
self.right = false;
self.open_chest_boat_container(i);
self.swing_arm();
return true;
}
@@ -904,6 +895,51 @@ impl Game<'_> {
self.trigger_player("minecraft:started_riding");
true
}
/// `ContainerEntity.interactWithContainerVehicle` (`AbstractChestBoat`):
/// the chest's 27 slots go under a placeholder key that the menu edits; a
/// server hands them to the player as a `ChestMenu` (`as_guest` ->
/// `lan_open_container`). `AbstractChestBoat.interact` fires
/// `CONTAINER_OPEN` and angers the piglins that see it.
pub(super) fn open_chest_boat_container(&mut self, i: usize) {
let b = &self.boats[i];
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
let at = [b.position.x, b.position.y, b.position.z];
let items = b.items.clone();
self.world
.block_entities
.insert(key, horizon_client::containers::BlockEntity::Chest(items));
self.container = Some(key);
self.screen = super::Screen::Inventory;
self.capture(false);
self.right = false;
let ctx = self.player_context(None);
self.post_game_event("minecraft:container_open", at, ctx);
self.anger_piglins(true);
}
/// `AbstractChestBoat.openCustomInventoryScreen` (`HasCustomInventoryScreen`),
/// the inventory key while riding a chest boat: its chest opens, not the
/// player's inventory.
pub(super) fn open_ridden_chest_boat(&mut self) -> bool {
let Some(id) = self.riding else {
return false;
};
let Some(i) = self.boats.iter().position(|b| b.id == id) else {
return false;
};
if !self.boats[i].chest {
return false;
}
self.open_chest_boat_container(i);
true
}
/// Whether the player rides a chest boat: `HasCustomInventoryScreen`, so
/// the inventory key sends `ServerboundPlayerCommandPacket.Action.OPEN_INVENTORY`
/// (`ServerGamePacketListenerImpl.handlePlayerCommand` opens its screen).
pub(super) fn riding_chest_boat(&self) -> bool {
self.riding
.and_then(|id| self.boats.iter().find(|b| b.id == id))
.is_some_and(|b| b.chest)
}
/// `VehicleEntity.hurtServer` from the player's attack.
pub(super) fn attack_boat(&mut self) -> bool {
let Some((i, d)) = self.boat_hit() else {
@@ -922,8 +958,11 @@ impl Game<'_> {
true
}
/// `VehicleEntity.hurtServer`: wobble, add the damage and break past 40
/// (`creative`: a creative player's hit removes it without drops).
/// True when the boat was removed.
/// (`creative`: a creative player's hit discards it straight away).
/// `AbstractChestBoat.remove` drops the chest contents for every removal
/// reason that destroys the boat (DISCARDED included), while the boat item
/// itself only drops on the death path (`VehicleEntity.destroy`), not when
/// a creative hit discards it. True when the boat was removed.
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
let b = &mut self.boats[i];
b.hurt_dir = -b.hurt_dir;
@@ -936,11 +975,15 @@ impl Game<'_> {
let at = [b.position.x, b.position.y, b.position.z];
// AbstractBoat.remove: a destroyed boat drops its lead.
self.boat_removed(&b);
// `AbstractChestBoat.remove` spills the chest through
// `Containers.dropContents` for every destroying removal reason;
// that has no `entity_drops` gate of its own.
for stack in b.items.iter().flatten() {
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
}
// `VehicleEntity.destroy(level, dropItem)` drops the boat item on
// the death path only, and only with `entity_drops` on.
if !creative && self.ui.rules.bool("entity_drops") {
// ChestBoat.destroy: chestVehicleDestroyed spills the contents.
for stack in b.items.iter().flatten() {
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
}
let item = b.entity_type();
self.spawn_item(
ItemStack {
@@ -191,7 +191,8 @@ impl MapData {
m.unlimited = self.unlimited;
m
}
/// `MapItemSavedData.locked`: the same pixels and banner markers, locked.
/// `MapItemSavedData.locked`: the same pixels, banner markers and
/// decorations, locked (the frame markers are not copied).
pub(super) fn locked_copy(&self) -> MapData {
let mut m = MapData::fresh(
f64::from(self.center[0]),
@@ -202,6 +203,7 @@ impl MapData {
m.center = self.center;
m.colors = self.colors.clone();
m.banners = self.banners.clone();
m.decorations = self.decorations.clone();
m.tracking = self.tracking;
m.unlimited = self.unlimited;
m.locked = true;
@@ -212,7 +214,7 @@ impl MapData {
#[cfg(test)]
mod tests {
use super::*;
use crate::game::maps::Decoration;
use crate::game::maps::{Decoration, Key};
fn close(a: [[f32; 2]; 4], b: [[f32; 2]; 4]) {
for (a, b) in a.iter().zip(&b) {
@@ -239,7 +241,8 @@ mod tests {
x,
y,
rot,
holder: None,
name: None,
key: Key::Item,
};
// The centre, facing north: 8 map pixels wide, nudged by (-0.5, 0.5).
close(
@@ -87,6 +87,9 @@ impl Game<'_> {
return None;
}
if !occupied {
// useWithoutItem's CONSUME on an empty slot still consumes the
// click (ServerPlayerGameMode.useItemOn's DEFAULT_BLOCK_USE).
self.trigger_default_block_use(pos);
return Some(false);
}
// removeBook: the book goes to the inventory, or is dropped.
@@ -100,6 +103,8 @@ impl Game<'_> {
self.audio.play(sound, Some(centre), 1.0, 1.0);
self.give_or_drop(retrieved);
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
// ServerPlayerGameMode.useItemOn: useWithoutItem consumed the click.
self.trigger_default_block_use(pos);
Some(true)
}
@@ -48,8 +48,8 @@ impl Game<'_> {
/// block under a sprinting player's feet streams back from them every
/// tick, for the window's own player and for the other players it sees.
fn sprint_dust(&mut self) {
// Each runner's feet, movement and supporting block.
let mut runners: Vec<(Vec3, Vec3, Option<[i32; 3]>)> = Vec::new();
// Each runner's feet, movement, supporting block and box width.
let mut runners: Vec<(Vec3, Vec3, Option<[i32; 3]>, f64)> = Vec::new();
// Entity.canSpawnSprintParticle.
if let Some(p) = self.player.as_ref()
&& p.sprinting
@@ -62,7 +62,21 @@ impl Game<'_> {
&& p.fluid_height(LAVA) <= 0.0
{
let support = p.ground.supporting_block.map(|b| [b.x, b.y, b.z]);
runners.push((p.position, p.velocity, support));
runners.push((p.position, p.velocity, support, f64::from(0.6f32)));
}
// IronGolem.canSpawnSprintParticle replaces the sprinting test: one
// tick in five while it moves at all, in water or not, drawn from the
// golem's own random as its client copy's Entity.baseTick does.
for m in self.mobs.iter_mut() {
if &*m.kind.id != "minecraft:iron_golem" {
continue;
}
let movement = m.position.subtract(m.previous);
let moving =
movement.x * movement.x + movement.z * movement.z > f64::from(2.5000003E-7_f32);
if moving && m.random.next_int_bound(5).unwrap_or(1) == 0 {
runners.push((m.position, movement, None, f64::from(m.width())));
}
}
let guests = self.lan.iter().flat_map(|host| host.models.values());
for m in self.mobs.iter().chain(guests) {
@@ -72,6 +86,10 @@ impl Game<'_> {
m.position.y.floor() as i32,
m.position.z.floor() as i32,
];
if &*m.kind.id == "minecraft:iron_golem" {
continue;
}
let movement = m.position.subtract(m.previous);
if m.extra.sprinting
&& !m.extra.crouching
&& m.alive()
@@ -83,10 +101,10 @@ impl Game<'_> {
.fluid_level
== 0
{
runners.push((m.position, m.position.subtract(m.previous), None));
runners.push((m.position, movement, None, f64::from(0.6f32)));
}
}
for (position, movement, support) in runners {
for (position, movement, support, width) in runners {
// Entity.getOnPosLegacy: getOnPos(0.2).
let below = (position.y - f64::from(0.2f32)).floor() as i32;
let tall = |game: &Self, s: [i32; 3]| {
@@ -111,7 +129,6 @@ impl Game<'_> {
let Some((sprite, tint)) = self.particle_sprite(state, at) else {
continue;
};
let width = f64::from(0.6f32);
let r = self.particles.random();
let mut x = position.x + (r.next_double() - 0.5) * width;
let mut z = position.z + (r.next_double() - 0.5) * width;
@@ -175,7 +192,7 @@ impl Game<'_> {
.effects
.iter()
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
.filter_map(|e| effect_particle(e.0, false))
.filter_map(|e| effect_particle(e.0, m.extra.ambient_effects.contains(&e.0)))
.collect();
swirl(
&mut self.particles,
+38 -22
View File
@@ -88,9 +88,13 @@ pub(super) struct CombatTracker {
/// known (`AbstractArrow.onHitEntity` makes an ownerless arrow its own
/// cause).
pub(super) projectile_direct: Option<Text>,
pub(super) projectile_attacker: Option<(u32, Text)>,
/// The projectile hitting the player now is another player's that
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
pub(super) refuse_player_hit: bool,
/// A firework rocket's burst is hurting the player now: the rocket is
/// the direct entity (`rocket_hurt_player`).
pub(super) rocket_hit: bool,
/// The player whose melee hit is landing on the player now: where they
/// stand and the seconds their weapon disables a shield for
/// (`getSecondsToDisableBlocking`).
@@ -112,6 +116,10 @@ pub(super) struct CombatTracker {
/// skull, wither or TNT minecart, the sources that empty a hive they
/// destroy (`BeehiveBlock.getDrops`).
pub(super) hive_emptying_blast: bool,
/// The blast now going off comes from a primed TNT minecart, which
/// neither resists on nor breaks a rail or the block under one
/// (`MinecartTNT.getBlockExplosionResistance`, `shouldBlockExplode`).
pub(super) rail_proof_blast: bool,
/// The mob that owns the projectile exploding now (`getIndirectSourceEntity`):
/// the mobs the blast hurts record it as their attacker.
pub(super) explosion_owner: Option<u32>,
@@ -269,6 +277,7 @@ impl Game<'_> {
self.combat.pending.clear();
self.combat.pending_direct.clear();
self.combat.projectile_direct = None;
self.combat.projectile_attacker = None;
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
}
/// `FallLocation.getCurrentFallLocation` and the fall distance so far.
@@ -329,31 +338,32 @@ impl Game<'_> {
.find(|m| m.id == *id)
.map(|m| self.mob_entity(m))
});
let projectile = matches!(
e.damage_type.as_str(),
"minecraft:arrow"
| "minecraft:trident"
| "minecraft:mob_projectile"
| "minecraft:fireball"
| "minecraft:unattributed_fireball"
| "minecraft:wither_skull"
| "minecraft:wind_charge"
| "minecraft:thrown"
| "minecraft:spit"
);
let direct = if projectile {
Some(EntityView {
kind: e.damage_type.clone(),
let projectile = match e.damage_type.as_str() {
// The rocket is the direct entity of its `fireworks` damage.
"minecraft:fireworks" => Some("minecraft:firework_rocket"),
t @ ("minecraft:arrow"
| "minecraft:trident"
| "minecraft:mob_projectile"
| "minecraft:fireball"
| "minecraft:unattributed_fireball"
| "minecraft:wither_skull"
| "minecraft:wind_charge"
| "minecraft:thrown"
| "minecraft:spit") => Some(t),
_ => None,
};
let direct = match projectile {
Some(kind) => Some(EntityView {
kind: kind.to_owned(),
..Default::default()
})
} else {
source.clone()
}),
None => source.clone(),
};
Some(DamageView {
kind: e.damage_type.clone(),
direct,
source,
is_direct: !projectile,
is_direct: projectile.is_none(),
original: e.damage,
actual: e.damage,
blocked: false,
@@ -561,9 +571,15 @@ impl Game<'_> {
.map(|e| {
let attacker = e.source.attacker.and_then(|w| name(self, w));
// AbstractArrow.onHitEntity: an arrow nobody shot is its own
// cause.
let direct = (attacker.is_none() && e.source.kind == DamageKind::Arrow)
.then(|| Text::translate("entity.minecraft.arrow", &[]));
// cause; a rocket nobody fired still names itself.
let direct = match e.source.kind {
_ if attacker.is_some() => None,
DamageKind::Arrow => Some(Text::translate("entity.minecraft.arrow", &[])),
DamageKind::Fireworks => {
Some(Text::translate("entity.minecraft.firework_rocket", &[]))
}
_ => None,
};
Entry {
damage_type: super::creatures::source_damage_type(e.source).to_owned(),
damage: e.damage,
+7 -1
View File
@@ -1934,7 +1934,13 @@ impl Game<'_> {
self.creatures.projectiles.retain(|p| p.id != id);
}
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
Target::Frame(id) => self.item_frames.retain(|f| f.id != id),
Target::Frame(id) => {
// ItemFrame.onKilled.
if let Some(frame) = self.item_frames.iter().find(|f| f.id == id).cloned() {
self.remove_framed_map(&frame);
}
self.item_frames.retain(|f| f.id != id);
}
Target::Painting(id) => self.paintings.retain(|p| p.id != id),
Target::Boat(id) => {
// AbstractBoat.remove: a killed boat drops its lead.
@@ -149,8 +149,9 @@ impl Game<'_> {
})
}
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
/// rebuilt from its compound, keeping its id and UUID.
fn apply_entity_nbt(&mut self, t: Target, mut tag: Tag) -> R<()> {
/// rebuilt from its compound, keeping its id and UUID. Also what
/// `TypedEntityData.loadInto` is: a spawn egg's data on the mob it spawned.
pub(in crate::game) fn apply_entity_nbt(&mut self, t: Target, mut tag: Tag) -> R<()> {
let stale = || err("commands.data.merge.failed", &[]);
match t {
Target::Player => return Err(err("commands.data.entity.invalid", &[])),
@@ -165,8 +166,13 @@ impl Game<'_> {
if let Some(k) = kind {
set_key(&mut tag, "id", Tag::String(k.as_str().into()));
}
let new =
entity_nbt::mob_from_nbt(&tag, self.res.mob_types(), id).ok_or_else(stale)?;
let new = entity_nbt::mob_from_nbt(
&tag,
self.res.mob_types(),
id,
Some(self.pack.assets.registry),
)
.ok_or_else(stale)?;
if let Some(slot) = self.mobs.iter_mut().find(|m| m.id == id) {
// Runtime-only state (AI, animation) carries over.
let old = std::mem::replace(slot, new);
@@ -73,11 +73,8 @@ impl Game<'_> {
Target::Mob(id) => match self.mobs.iter_mut().find(|m| m.id == id) {
Some(m) => {
let name = namespaced(effect.id);
m.add_effect(name, duration, amplifier);
m.extra.hidden_effects.retain(|h| *h != name);
if !particles {
m.extra.hidden_effects.push(name);
}
// `new MobEffectInstance(effect, duration, amplifier, false, particles)`.
m.add_effect_with(name, duration, amplifier, false, particles);
true
}
None => false,
@@ -144,6 +141,9 @@ impl Game<'_> {
m.extra
.hidden_effects
.retain(|h| left.iter().any(|x| x.0 == *h));
m.extra
.ambient_effects
.retain(|h| left.iter().any(|x| x.0 == *h));
m.extra.effects.len() != before
}
None => false,
@@ -14,6 +14,9 @@ use horizon_world::nbt::Tag;
pub(in crate::game) enum Slot {
/// `inventory[0..36]`: hotbar then the main rows.
Inv(usize),
/// The selected hotbar slot as `weapon.mainhand` names it: the equipment's
/// `setItemSlot`, where `hotbar.N` is the inventory's `setItem`.
MainHand,
/// `armor[0..4]` (head, chest, legs, feet).
Armor(usize),
Offhand,
@@ -114,7 +117,7 @@ impl Game<'_> {
}
}
if matches_name(name, "weapon") || matches_name(name, "weapon.mainhand") {
add(Slot::Inv(self.selected));
add(Slot::MainHand);
}
if matches_name(name, "weapon.offhand") {
add(Slot::Offhand);
@@ -175,6 +178,7 @@ impl Game<'_> {
.for_player(Target::Guest(*conn), |g| g.slot_get(slot))
.flatten(),
Slot::Inv(i) => self.inventory.get(*i).cloned().flatten(),
Slot::MainHand => self.inventory.get(self.selected).cloned().flatten(),
Slot::Armor(i) => self.armor.get(*i).cloned().flatten(),
Slot::Offhand => self.offhand.clone(),
Slot::Ender(i) => self.ender_items.get(*i).cloned().flatten(),
@@ -227,6 +231,14 @@ impl Game<'_> {
None => false,
},
// LivingEntity's equipment SlotAccess: setItemSlot, so onEquipItem.
Slot::MainHand => match self.inventory.get_mut(self.selected) {
Some(slot) => {
let old = std::mem::replace(slot, stack.clone());
self.player_on_equip("mainhand", old.as_ref(), stack.as_ref());
true
}
None => false,
},
Slot::Armor(i) => match self.armor.get_mut(*i) {
Some(slot) => {
let old = std::mem::replace(slot, stack.clone());
+16 -6
View File
@@ -345,8 +345,11 @@ impl Game<'_> {
if action == "tap" {
self.native_key_event(physical, false, false);
}
// The merchant's focused offer takes Enter and Space, as the window's.
if action != "up" && self.screen == Screen::Inventory && self.merchant_key(physical)
// The merchant's focused offer and the recipe book's last
// recipe take Enter and Space, as the window's.
if action != "up"
&& self.screen == Screen::Inventory
&& (self.merchant_key(physical) || self.recipe_book_key(physical))
{
return ok();
}
@@ -530,8 +533,12 @@ impl Game<'_> {
}
"lod-distance" => {
let n: u32 = arg(1)?.parse()?;
if n > 1024 {
return Err("LOD distance must be 0..1024".into());
if n > horizon_client::lod::MAX_CHUNKS {
return Err(format!(
"LOD distance must be 0..{}",
horizon_client::lod::MAX_CHUNKS
)
.into());
}
self.settings.horizon.lod_distance = n;
self.apply_settings();
@@ -635,6 +642,7 @@ impl Game<'_> {
Screen::Chat => self.chat_type(text),
Screen::CommandBlock => self.command_block_type(text),
Screen::BlockEditor => self.block_editor_type(text),
Screen::DebugOptions => self.debug_options_type(text),
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
// The port, or the game rules' search and number boxes.
Screen::WorldOptions if text == "\\b" => self.world_options_text(None),
@@ -914,6 +922,7 @@ impl Game<'_> {
Screen::CommandBlock => "command-block",
Screen::BlockEditor => "block-editor",
Screen::Win => "win",
Screen::DebugOptions => "debug-options",
}
}
@@ -1032,6 +1041,7 @@ impl Game<'_> {
"cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1),
"far_detail": self.settings.horizon.far_detail.name(),
"resident_quads": self.renderer.as_ref().map_or(0, |r| r.lod_quads()),
"levels": self.renderer.as_ref().map_or_else(Vec::new, |r| r.lod_levels()),
"pending_observations": self.lod_observe.len(),
"refined_samples": self.lod.as_ref().map_or(0, |lod| lod.refined_samples()),
});
@@ -1045,8 +1055,8 @@ impl Game<'_> {
"screen": self.screen_name(),
"debug": {
"overlay": self.debug,
"hitboxes": self.show_hitboxes,
"chunk_borders": self.show_chunk_borders,
"hitboxes": self.debug_entry("entity_hitboxes"),
"chunk_borders": self.debug_entry("chunk_borders"),
"pause_menu": self.pause_menu,
"advanced_tooltips": self.settings.file.bool("advancedItemTooltips"),
"pause_on_lost_focus": self.settings.file.bool("pauseOnLostFocus"),
+296 -52
View File
@@ -17,6 +17,7 @@ use horizon_client::{
farm::Climate,
spawning::{self, BiomeFacts},
},
particles::{ParticleOptions, ParticleType},
projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld},
text::Text,
};
@@ -390,11 +391,90 @@ impl Game<'_> {
self.mobs.push(mob);
self.native_mobs_joined(self.mobs.len() - 1, false);
self.enchant_spawn_equipment(id);
self.halloween_spawn_head(id);
if !persistent && !scripted {
self.spawn_jockeys(id, true);
}
Some(id)
}
/// `Mob.burnUndead` for a mob in daylight with an item on its sun
/// protection slot (the head, or the body for a zombie horse or
/// nautilus): `hurtAndBreak(random.nextInt(2))` on a damageable item, and
/// when it breaks the slot empties and `onEquippedItemBroken` broadcasts
/// the break event.
fn wear_sun_protection(&mut self, id: u32) {
use horizon_world::player_inventory::EquipmentSlot;
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let body = horizon_client::mobs::drops::head_is_body(m.species());
let slot = if body {
EquipmentSlot::Body
} else {
EquipmentSlot::Head
};
let Some(mut stack) = m.item_by_slot(slot) else {
return;
};
if !horizon_client::stations::is_damageable(self.res.items(), &stack) {
return;
}
let at = [m.position.x, m.position.y, m.position.z];
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
let amount = m.random.next_int_bound(2).unwrap_or(0) as u32;
if amount == 0 {
return;
}
let loss = self.durability_loss(&stack, amount);
if loss == 0 {
return;
}
let max = self
.res
.items()
.get(&stack.item)
.map_or(0, |i| i.max_damage);
stack.damage += loss;
let broke = stack.damage >= max;
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
match (body, broke) {
(true, false) => m.extra.body_damage = stack.damage as i32,
(true, true) => {
m.extra.head = None;
m.extra.body_damage = 0;
}
(false, false) => m.extra.armor[0] = Some(stack),
(false, true) => {
m.extra.head = None;
m.extra.armor[0] = None;
}
}
if broke {
// LivingEntity.entityEventForEquipmentBreak: head 49, body 65.
self.lan_entity_event(id as i32, if body { 65 } else { 49 }, at);
}
}
/// What a converted frog gets once its tadpole has grown up: the spawn
/// finalization of a conversion, then persistence.
fn finalize_converted(&mut self, id: u32) {
let Some(at) = self.mobs.iter().find(|m| m.id == id).map(|m| m.position) else {
return;
};
let facts = self.biome_facts(
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
);
let difficulty = self.local_difficulty([at.x, at.y, at.z]);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
spawning::finalize(m, &facts, &difficulty);
m.persistent = true;
}
}
/// `Mob.convertTo(type, ConversionParams.single(...))` for a mob already
/// removed: a new mob of `kind`, made without spawn finalization, takes
/// its place with what `conversion::single` carries over, and the old
@@ -571,6 +651,7 @@ impl Game<'_> {
block_tags: &self.items().block_tags,
biomes: self.res.biomes(),
mob_griefing: self.ui.rules.bool("mob_griefing"),
universal_anger: self.ui.rules.bool("universal_anger"),
mob_drops: self.ui.rules.bool("mob_drops"),
max_entity_cramming: self.ui.rules.int("max_entity_cramming"),
end: self.dim == Dim::End,
@@ -617,17 +698,21 @@ impl Game<'_> {
/// Generation's animals for the chunks generated since, once lit: chunk
/// generation places them whether the level ticks or not.
pub(super) fn spawn_fresh_chunks(&mut self) {
if !self.world.chunks.has_fresh() {
return;
}
let fresh: Vec<(i32, i32)> = self
.world
.chunks
.iter()
.filter(|(_, c)| c.fresh && c.lit)
.map(|(k, _)| *k)
.fresh_keys_ordered()
.into_iter()
.filter(|k| self.world.chunks.get(k).is_some_and(|c| c.fresh && c.lit))
.collect();
for key in fresh {
if let Some(c) = self.world.chunks.get_mut(&key) {
Arc::make_mut(c).fresh = false;
}
self.world.chunks.remove_fresh(&key);
self.spawn_generation_creatures(key.0, key.1);
}
}
@@ -723,6 +808,7 @@ impl Game<'_> {
.map(super::boat_leads::boat_state)
.collect();
let base = self.mob_world_base(&[]);
let path_changes = horizon_client::mobs::collision_changes(&base, &path_writes);
// Each mob keeps its snapshot slot all tick; the first slot of an
// id answers, as a scan would.
let mut index = horizon_client::fast_hash::FxHashMap::<u32, u32>::default();
@@ -783,7 +869,7 @@ impl Game<'_> {
continue;
}
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
horizon_client::mobs::blocks_changed(m, &w, &path_writes);
horizon_client::mobs::blocks_changed(m, &path_changes);
let from = events.len();
let kept = m.tick(&w, &mut scratch, &mut events);
// A killed mob's body is removed: its effects that act on
@@ -888,6 +974,7 @@ impl Game<'_> {
self.tick_fangs();
self.tick_allays();
self.tick_pandas();
self.tick_mob_looting();
self.tick_breeze_effects();
self.tick_sniffer_effects();
self.piglin_pickups();
@@ -1053,6 +1140,7 @@ impl Game<'_> {
let projectile = match d.kind {
DamageKind::Arrow => Some("minecraft:arrow"),
DamageKind::Fireball => Some("minecraft:fireball"),
DamageKind::Fireworks => Some("minecraft:firework_rocket"),
_ => None,
};
let direct = match projectile {
@@ -1154,6 +1242,10 @@ impl Game<'_> {
kind: "minecraft:fireball".into(),
..LootEntity::default()
}),
DamageKind::Fireworks => Some(LootEntity {
kind: "minecraft:firework_rocket".into(),
..LootEntity::default()
}),
_ => source.clone(),
};
LootDamage {
@@ -1225,8 +1317,9 @@ impl Game<'_> {
let mut killed = false;
let mut hurt = false;
let mut infested = false;
let aim = self.attacker_aim(source.attacker);
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
let hit = t.damage(amount, source);
let hit = t.damage_aimed(amount, source, aim);
hurt = hit;
if hit {
killed = !t.alive();
@@ -1396,6 +1489,7 @@ impl Game<'_> {
.map_or([0.0; 3], |m| [m.velocity.x, m.velocity.y, m.velocity.z]);
self.drown_particles(at, v);
}
MobEvent::SunProtectionWear { mob } => self.wear_sun_protection(mob),
MobEvent::EntityEvent { mob, event, at } => {
self.lan_entity_event(mob as i32, event, at)
}
@@ -1538,12 +1632,20 @@ impl Game<'_> {
self.combat.explosion_source = Some(mob);
// The creeper causes the blast's damage, but it is out of
// the mob list already: its name comes with the event.
self.combat.exploded.push((mob, name.clone()));
self.note_named_attacker("minecraft:explosion", mob, name);
self.combat.exploded.push((mob, name));
if charged {
self.combat.charged_exploders.push(mob);
}
}
MobEvent::LingeringCloud {
at,
effects,
hidden,
ambient,
color,
} => {
self.spawn_creeper_cloud(at, effects, hidden, ambient, color);
}
MobEvent::Explode(at, radius, species) => {
// The exploding mob causes the damage (Creeper.explodeCreeper).
let noted = self.combat.explosion_source.is_some();
@@ -1557,9 +1659,6 @@ impl Game<'_> {
.map(|m| m.id)
.next()
});
if !noted && let Some(id) = source_mob {
self.note_attacker("minecraft:explosion", id);
}
self.combat.explosion_source = source_mob;
self.combat.explosion_source_named = noted;
self.combat.explosion_cause = match species {
@@ -1786,7 +1885,21 @@ impl Game<'_> {
}
}
MobEvent::Convert { from, kind, params } => {
self.convert_to(&from, kind, params);
let id = self.convert_to(&from, kind, params);
// `Piglin.finishConversion` and `Hoglin.finishConversion`:
// the converted mob is nauseous for 10 seconds.
if matches!(kind, "minecraft:zombified_piglin" | "minecraft:zoglin")
&& let Some(m) = self.mobs.iter_mut().find(|m| Some(m.id) == id)
{
m.add_effect("minecraft:nausea", 200, 0);
}
// `Tadpole.ageUp`: the frog is finalized for the
// conversion and made persistent.
if kind == "minecraft:frog"
&& let Some(id) = id
{
self.finalize_converted(id);
}
}
MobEvent::DropItem { item, count, at } => self.spawn_item(
ItemStack {
@@ -1803,6 +1916,23 @@ impl Game<'_> {
dispenser::new_item_motion(),
10,
),
MobEvent::DropGoatHorn {
mob,
screaming,
at,
motion,
} => {
let instrument =
horizon_client::mobs::goat::create_horn(self.mob_uuid(mob), screaming);
let mut horn = ItemStack::new("minecraft:goat_horn", 1);
horn.data_mut().set_other(
"minecraft:instrument",
Some(horizon_world::nbt::Tag::String(instrument.into())),
);
// Goat.dropHorn's ItemEntity constructor leaves pickupDelay at
// 0 (no setDefaultPickUpDelay).
self.spawn_item(horn, at, motion, 0);
}
MobEvent::DropGift { table, at } => {
let stacks =
self.res
@@ -2675,17 +2805,21 @@ impl Game<'_> {
damage_type: &str,
known: Option<Option<u32>>,
) -> bool {
let rocket = std::mem::take(&mut self.combat.rocket_hit);
// ServerPlayer.canHarmPlayer refused another player's projectile.
if self.creative || self.combat.refuse_player_hit {
return false;
}
let projectile = self
.res
.stations()
.enchantments
.damage_tags
.get("minecraft:is_projectile")
.is_some_and(|t| t.contains(damage_type));
// A projectile entity dealt it: `#is_projectile`, or a firework
// rocket (its `fireworks` type is not in the tag).
let projectile = rocket
|| self
.res
.stations()
.enchantments
.damage_tags
.get("minecraft:is_projectile")
.is_some_and(|t| t.contains(damage_type));
let blocking = self.player_blocking(from, damage_type, false);
let raised = blocking.is_some();
// Melee: the attacker stands at `from` (another player's hit has no mob behind it).
@@ -2711,6 +2845,9 @@ impl Game<'_> {
if let Some(a) = attacker.or(owner) {
self.note_attacker(damage_type, a);
}
if let Some((id, name)) = self.combat.projectile_attacker.take() {
self.note_named_attacker(damage_type, id, name);
}
// The projectile is the direct entity: named when it has no owner.
if let Some(name) = self.combat.projectile_direct.take() {
self.note_direct_entity(damage_type, name);
@@ -2766,7 +2903,17 @@ impl Game<'_> {
.map(|m| self.mob_entity(m));
self.trigger_entity_hurt_player(&horizon_client::advancements::DamageView {
kind: damage_type.to_owned(),
direct: source.clone().filter(|_| !projectile),
// Arrows and the like name no direct entity here; a
// rocket's burst names the rocket.
direct: if rocket {
Some(horizon_client::advancements::EntityView {
kind: "minecraft:firework_rocket".to_owned(),
position: from.unwrap_or_default(),
..Default::default()
})
} else {
source.clone().filter(|_| !projectile)
},
is_direct: !projectile,
source,
original: amount,
@@ -3591,6 +3738,7 @@ impl Game<'_> {
attacker: p.owner,
from: Some([p.previous.x, p.previous.y, p.previous.z]),
};
let aim = self.attacker_aim(source.attacker);
match (&p.kind, hit) {
// ThrownTrident.onHitEntity: 8 damage (Impaling on aquatic mobs),
// dealt whether or not the hurt lands, then it bounces off.
@@ -3611,11 +3759,9 @@ impl Game<'_> {
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:trident",
),
Who::Mob(id) => self
.mobs
.iter_mut()
.find(|m| m.id == id)
.is_some_and(|m| m.damage(amount, source)),
Who::Mob(id) => self.mobs.iter_mut().find(|m| m.id == id).is_some_and(|m| {
m.damage_projectile(amount, source, aim, [p.velocity.x, p.velocity.z])
}),
};
let outcome = if hurt {
EntityHit::Hurt
@@ -3653,11 +3799,9 @@ impl Game<'_> {
hurt
}
Who::Mob(id) => {
let hurt = self
.mobs
.iter_mut()
.find(|m| m.id == id)
.is_some_and(|m| m.damage(amount, source));
let hurt = self.mobs.iter_mut().find(|m| m.id == id).is_some_and(|m| {
m.damage_projectile(amount, source, aim, [p.velocity.x, p.velocity.z])
});
dodged = !hurt
&& self.mobs.iter().find(|m| m.id == id).is_some_and(|m| {
m.species() == Species::Enderman
@@ -3805,7 +3949,7 @@ impl Game<'_> {
} else {
0.0
};
m.damage(amount, source);
m.damage_projectile(amount, source, aim, [p.velocity.x, p.velocity.z]);
}
self.thrown_break_event(p.id, atp);
self.item_burst(atp, "minecraft:snowball");
@@ -3816,7 +3960,7 @@ impl Game<'_> {
} = hit
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.damage(0.0, source);
m.damage_projectile(0.0, source, aim, [p.velocity.x, p.velocity.z]);
}
// ThrownEgg.onHit: 1/8 chance of a chick, 1/32 of four.
if self.world.random.next_int_bound(8).unwrap_or(1) == 0 {
@@ -3842,7 +3986,7 @@ impl Game<'_> {
} = hit
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.damage(0.0, source);
m.damage_projectile(0.0, source, aim, [p.velocity.x, p.velocity.z]);
}
if p.owner == Some(Who::Player) && self.player.is_some() {
// ThrownEnderpearl: 5% endermite where the player was
@@ -3902,7 +4046,8 @@ impl Game<'_> {
if !m.kind.flags.fire_immune {
m.ignite_for_ticks(100);
}
let hurt = m.damage(5.0, source);
let hurt =
m.damage_projectile(5.0, source, aim, [p.velocity.x, p.velocity.z]);
if !hurt {
m.fire_ticks = remaining;
}
@@ -3934,6 +4079,13 @@ impl Game<'_> {
Hit::None => {}
},
(ProjectileKind::LargeFireball { power }, hit) => {
// Fireball explosions are caused by the shooter. A direct hit
// on the player takes `projectile_owner`, so read the owner
// from the fireball.
let owner = match p.owner {
Some(Who::Mob(id)) => Some(id),
_ => None,
};
if let Hit::Entity { who, .. } = hit {
match who {
Who::Player => {
@@ -3944,22 +4096,15 @@ impl Game<'_> {
);
}
Who::Mob(id) => {
if self
.mobs
.iter_mut()
.find(|m| m.id == id)
.is_some_and(|m| m.damage(6.0, source))
{
if self.mobs.iter_mut().find(|m| m.id == id).is_some_and(|m| {
m.damage_projectile(6.0, source, aim, [p.velocity.x, p.velocity.z])
}) {
self.player_hurt_mob(p, id);
}
}
}
}
// Fireball explosions are caused by the shooter.
if let Some(owner) = self.combat.projectile_owner {
self.note_attacker("minecraft:explosion", owner);
}
self.combat.explosion_owner = self.combat.projectile_owner;
self.combat.explosion_owner = owner;
// The blast's source names the owner, so a dragon it kills
// remembers the player who shot it.
self.set_blast_by_player(p.owner == Some(Who::Player));
@@ -4057,7 +4202,7 @@ impl Game<'_> {
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.damage(1.0, source);
m.damage_projectile(1.0, source, aim, [p.velocity.x, p.velocity.z]);
}
}
},
@@ -4077,7 +4222,7 @@ impl Game<'_> {
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
&& m.damage(4.0, source)
&& m.damage_projectile(4.0, source, aim, [p.velocity.x, p.velocity.z])
{
m.add_effect("minecraft:levitation", 200, 0);
// Shulker.hurtServer: a shulker hit by a bullet.
@@ -4207,7 +4352,7 @@ impl Game<'_> {
let Some((index, distance)) = self.mob_hit() else {
return Ok(false);
};
if let Some(hit) = self.pick()
if let Some(hit) = self.pick_unfiltered()
&& let Some(eye) = self.eye_position()
{
let d = ((hit.point[0] - eye[0]).powi(2)
@@ -4395,6 +4540,16 @@ impl Game<'_> {
let mut drops: Vec<(String, u32)> = Vec::new();
let is_food = m.kind.food.contains(&item);
let baby_turtle = species == Species::Turtle && m.is_baby();
// MushroomCow.getEffectsFromItemStack: the item's block supplies them.
let flower = if species == Species::Mooshroom {
horizon_client::flowers::stew_effect(&item)
} else {
None
};
let mut give_stew: Option<Vec<(Arc<str>, i32)>> = None;
// `Some(true)` for the four spell particles, `Some(false)` for the two
// smoke puffs of a cow that already holds effects.
let mut stew_particles: Option<bool> = None;
match species {
Species::Cow | Species::Mooshroom if item == "minecraft:bucket" && !m.is_baby() => {
give = Some("minecraft:milk_bucket");
@@ -4411,10 +4566,22 @@ impl Game<'_> {
"entity.goat.milk"
});
}
// MushroomCow.mobInteract: a bowl gives back what a flower left as
// a suspicious stew, or plain stew when it holds no effects.
Species::Mooshroom if item == "minecraft:bowl" && !m.is_baby() => {
give = Some("minecraft:mushroom_stew");
consume = true;
sound = Some("entity.mooshroom.milk");
match m.extra.stew_effects.take() {
Some(effects) => {
give = Some("minecraft:suspicious_stew");
give_stew = Some(effects);
// `playSound(.., 1.0F, 1.0F)` at the feet.
eat = Some(("entity.mooshroom.suspicious_milk", 1.0, 1.0));
}
None => {
give = Some("minecraft:mushroom_stew");
sound = Some("entity.mooshroom.milk");
}
}
}
Species::Mooshroom if item == "minecraft:shears" && !m.is_baby() => {
// MushroomCow.shear: becomes a cow and drops five mushrooms.
@@ -4439,6 +4606,19 @@ impl Game<'_> {
}])?;
self.damage_held_tool();
}
// MushroomCow.mobInteract: a brown adult takes the flower's effects
// unless it holds some already, when it only puffs smoke.
Species::Mooshroom if m.extra.variant == 1 && !m.is_baby() && flower.is_some() => {
if m.extra.stew_effects.is_some() {
stew_particles = Some(false);
} else {
let (effect, ticks) = flower.expect("the guard checked");
m.extra.stew_effects = Some(vec![(Arc::from(effect), ticks)]);
consume = true;
eat = Some(("entity.mooshroom.eat", 2.0, 1.0));
stew_particles = Some(true);
}
}
Species::Sheep if item == "minecraft:shears" && !m.extra.sheared && !m.is_baby() => {
m.extra.sheared = true;
let color = horizon_client::mobs::render::DYE_NAMES[m.extra.color as usize & 15];
@@ -4830,6 +5010,31 @@ impl Game<'_> {
if !handled {
return Ok(false);
}
if let Some(fresh) = stew_particles {
// `SpellParticleOption.create(EFFECT, -1, 1)` is the plain white
// effect particle; the smoke is the already-fed answer.
let ty = if fresh {
ParticleType::Effect
} else {
ParticleType::Smoke
};
let options = ParticleOptions::simple(ty);
let at = [feet[0], feet[1] + 0.5, feet[2]];
let (spread, count) = ([0.5, 0.0, 0.5], if fresh { 4 } else { 2 });
// shortcut: scalar speed models DEFAULT randomization; 26.3 sends
// ALTERNATIVE_WITH_SPEED with a per-axis maximum speed.
self.particles
.send_particles(&options, at, spread, 0.2, count);
self.lan_send_particles(super::lan_particles::QueuedParticle {
dimension: self.dim.id(),
options,
state: None,
at,
spread,
speed: 0.2,
count: count as i32,
});
}
// PlayerInteractTrigger: the item used on the mob, once the interaction took.
if let Some(stack) = &held {
let target = self.mob_entity(&self.mobs[index]);
@@ -4864,12 +5069,18 @@ impl Game<'_> {
}
}
if let Some(g) = give {
let stack = ItemStack {
let mut stack = ItemStack {
item: g.into(),
count: 1,
damage: 0,
extra: None,
};
if let Some(effects) = give_stew {
stack.extra = Some(Box::new(horizon_client::item_data::StackData {
stew_effects: effects,
..Default::default()
}));
}
if self.stack_in(selected).is_none() {
*self.stack_in_mut(selected) = Some(stack);
} else if let Some(rest) = self.give(stack) {
@@ -5120,6 +5331,7 @@ impl Game<'_> {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::entity_nbt::apply_item_components(m, stack);
}
self.apply_spawn_egg_entity_data(id, stack);
self.swing_arm();
if !self.creative
&& let Some(s) = self.held_mut().as_mut()
@@ -5131,6 +5343,25 @@ impl Game<'_> {
}
Ok(true)
}
/// `EntityType.updateCustomEntityTag`: the egg's `minecraft:entity_data` on
/// the mob it spawned, once the guards let it through, as
/// `TypedEntityData.loadInto`: the mob's own compound, the data merged over
/// it, loaded back into the same mob (`/data merge entity`'s loader).
fn apply_spawn_egg_entity_data(&mut self, id: u32, stack: &ItemStack) {
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
return;
};
let kind = self.mobs[i].kind.id.clone();
let op = self.acting_player_is_op();
let Some(data) = horizon_client::block_entity_item::spawn_egg_entity_data(stack, &kind, op)
else {
return;
};
let own = self.mob_tree_nbt(i);
let tag = horizon_client::data_cmd::merged(&own, data);
// Nothing to report: the mob's own compound always loads back.
let _ = self.apply_entity_nbt(super::commands::Target::Mob(id), tag);
}
/// A beam target's centre (half its height), interpolated.
fn beam_target(&self, who: Option<Who>, alpha: f64) -> Option<glam::DVec3> {
match who? {
@@ -5266,8 +5497,20 @@ impl Game<'_> {
mob.position.z.floor() as i32,
);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
if mob.fire_ticks > 0 || matches!(mob.species(), Species::Blaze | Species::MagmaCube) {
packed = (15 * 16) | ((15 * 16) << 8);
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
// renderers' overrides; the sky light stays the level's.
if mob.fire_ticks > 0
|| matches!(
mob.species(),
Species::Allay
| Species::Blaze
| Species::MagmaCube
| Species::Vex
| Species::Wither
)
{
packed = (packed & 0xFF) | ((15 * 16) << 8);
}
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
// dark ticks run out (the block light is the second byte).
@@ -6035,6 +6278,7 @@ mod pearl_tests {
block_tags: &items.block_tags,
biomes: &biomes,
mob_griefing: true,
universal_anger: false,
mob_drops: true,
max_entity_cramming: 24,
difficulty: spawning::LocalDifficulty::new(2, 1000, 0, 0.0),
+1 -1
View File
@@ -146,7 +146,7 @@ impl Game<'_> {
/// The cushion under the crosshair, its distance and the hit point.
pub(super) fn cushion_hit(&self) -> Option<(usize, f64, [f64; 3])> {
let (eye, dir) = self.aim()?;
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64, [f64; 3])> = None;
for (i, c) in self.cushions.iter().enumerate() {
let Some(d) = super::ray_box(eye, dir, reach, aabb(c.bounds())) else {
+127 -72
View File
@@ -278,14 +278,26 @@ impl Game<'_> {
format!("To edit: press {}", key("key.debug.debugOptions")),
]
}
/// `DebugScreenOverlay.extractRenderState` with the default profile's
/// entries, over everything the frame drew (menus too), at the debug GUI
/// scale. F1 hides it unless a screen is open.
/// `Gui.extractRenderState`'s debug overlay, over everything the frame
/// drew (menus too); the debug options screen draws it under itself.
pub(super) fn draw_debug_overlay(&self, batch: &mut HudBatch, size: [u32; 2]) {
if self.screen != Screen::DebugOptions {
self.draw_debug_overlay_under(batch, size);
}
}
/// `DebugScreenOverlay.extractRenderState`: the enabled entries' lines
/// (`debug-profile.json`), at the debug GUI scale, and the charts while
/// the overlay is open. F1 hides it unless a screen is open.
pub(super) fn draw_debug_overlay_under(&self, batch: &mut HudBatch, size: [u32; 2]) {
let screen_open = self.screen != Screen::Playing;
if self.title.is_some() || !self.debug || (self.ui.hide_gui && !screen_open) {
let reduced = self.reduced_debug_info();
if self.title.is_some()
|| !self.debug_entries.any_enabled(self.debug, reduced)
|| (self.ui.hide_gui && !screen_open)
{
return;
}
let on = |path: &str| self.debug_entries.enabled(path, self.debug, reduced);
let framebuffer = size.map(|v| v as i32);
let (scale, [width, height]) = debug_overlay::scale(
framebuffer,
@@ -296,6 +308,83 @@ impl Game<'_> {
);
let mut d = debug_overlay::Displayer::default();
// fps: DebugEntryFps.
if on("fps") {
self.debug_fps_line(&mut d);
}
// game_version: DebugEntryVersion (the launched version and brand).
if on("game_version") {
d.priority(format!("Minecraft {VERSION_NAME} ({VERSION_NAME}/horizon)"));
}
// player_position, player_section_position.
if let Some(p) = self.player.as_ref() {
let mut lines = debug_overlay::position(
[p.position.x, p.position.y, p.position.z],
self.yaw,
self.pitch,
self.dim.id(),
self.streaming.forced.len(),
);
let section = lines.pop().unwrap_or_default();
if on("player_position") {
d.group("position", lines);
}
if on("player_section_position") {
d.group("position", [section]);
}
}
if on("simple_performance_impactors") {
self.debug_performance_lines(&mut d);
}
if on("system_specs") {
self.debug_system_lines(&mut d, framebuffer);
}
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
debug_overlay::shader_pass_lines(&r.shader_pass_times())
});
if self.debug && !pass_lines.is_empty() {
d.group("shader pack", pass_lines);
}
if on("tps") {
self.debug_tps_line(&mut d);
}
// The chart keys and the charts only while the overlay is open.
let footer = if self.debug {
self.debug_chart_lines().to_vec()
} else {
Vec::new()
};
let (left, right) = d.columns(&footer);
let atlas = &self.gui.atlas;
let mut draws = debug_overlay::draw(&atlas.font, [&left, &right], scale, width);
if self.debug {
let charts = self.debug_charts.draw(
&atlas.font,
width,
height,
self.settings.max_fps,
1000.0 / self.clock.rate(),
!self.has_singleplayer_server(),
);
draws.push(hud_layout::GuiDraw::Push(vec![hud_layout::PoseOp::Scale(
scale, scale,
)]));
draws.extend(charts);
if self.debug_charts.lightmap
&& let Some(r) = &self.renderer
{
draws.extend(horizon_client::debug_chart::lightmap(
&r.lightmap_pixels,
width,
height,
));
}
draws.push(hud_layout::GuiDraw::Pop);
}
hud_layout::paint(batch, atlas, &draws);
}
/// `DebugEntryFps`.
fn debug_fps_line(&self, d: &mut debug_overlay::Displayer) {
let limit = self.framerate_limit();
let present = self
.renderer
@@ -324,24 +413,9 @@ impl Game<'_> {
limit.to_string()
},
));
// game_version: DebugEntryVersion (the launched version and brand).
d.priority(format!("Minecraft {VERSION_NAME} ({VERSION_NAME}/horizon)"));
// player_position, player_section_position.
if let Some(p) = self.player.as_ref() {
let mut lines = debug_overlay::position(
[p.position.x, p.position.y, p.position.z],
self.yaw,
self.pitch,
self.dim.id(),
self.streaming.forced.len(),
);
let section = lines.pop().unwrap_or_default();
if !self.reduced_debug_info() {
d.group("position", lines);
}
d.group("position", [section]);
}
// simple_performance_impactors.
}
/// `DebugEntrySimplePerformanceImpactors`.
fn debug_performance_lines(&self, d: &mut debug_overlay::Displayer) {
let s = &self.settings;
d.line(format!(
"{}{}B: {}",
@@ -366,7 +440,9 @@ impl Game<'_> {
"naive"
}
));
// system_specs (no Java line: Horizon has no JVM).
}
/// `DebugEntrySystemSpecs` (no Java line: Horizon has no JVM).
fn debug_system_lines(&self, d: &mut debug_overlay::Displayer, framebuffer: [i32; 2]) {
if let Some(r) = self.renderer.as_ref() {
static CPU: std::sync::OnceLock<String> = std::sync::OnceLock::new();
d.group(
@@ -432,14 +508,9 @@ impl Game<'_> {
{
d.group("system", [r.hdr_line()]);
}
// Horizon's own: the shader pack's GPU time per pass (an empty group would move others).
let pass_lines = self.renderer.as_ref().map_or_else(Vec::new, |r| {
debug_overlay::shader_pass_lines(&r.shader_pass_times())
});
if !pass_lines.is_empty() {
d.group("shader pack", pass_lines);
}
// tps: DebugEntryTps for local and remote connections.
}
/// `DebugEntryTps` for local and remote connections.
fn debug_tps_line(&self, d: &mut debug_overlay::Displayer) {
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() {
@@ -471,32 +542,6 @@ impl Game<'_> {
));
}
}
let (left, right) = d.columns(&self.debug_chart_lines());
let atlas = &self.gui.atlas;
let mut draws = debug_overlay::draw(&atlas.font, [&left, &right], scale, width);
let charts = self.debug_charts.draw(
&atlas.font,
width,
height,
self.settings.max_fps,
1000.0 / self.clock.rate(),
!self.has_singleplayer_server(),
);
draws.push(hud_layout::GuiDraw::Push(vec![hud_layout::PoseOp::Scale(
scale, scale,
)]));
draws.extend(charts);
if self.debug_charts.lightmap
&& let Some(r) = &self.renderer
{
draws.extend(horizon_client::debug_chart::lightmap(
&r.lightmap_pixels,
width,
height,
));
}
draws.push(hud_layout::GuiDraw::Pop);
hud_layout::paint(batch, atlas, &draws);
}
/// `KeyMapping.getTranslatedKeyMessage`.
fn key_text(&self, mapping: &str) -> Text {
@@ -525,13 +570,17 @@ impl Game<'_> {
self.debug_feedback(Text::translate(key, &[]));
}
/// `LocalPlayer.isReducedDebugInfo`: the server's rule or the option.
fn reduced_debug_info(&self) -> bool {
pub(super) fn reduced_debug_info(&self) -> bool {
self.settings.reduced_debug_info || self.ui.rules.bool("reduced_debug_info")
}
/// The default profile's `3d_crosshair` entry: on while the overlay is
/// open, unless debug info is reduced.
/// `DebugScreenEntryList.isCurrentlyEnabled` for an entry's path.
pub(super) fn debug_entry(&self, path: &str) -> bool {
self.debug_entries
.enabled(path, self.debug, self.reduced_debug_info())
}
/// The `3d_crosshair` entry.
pub(super) fn debug_crosshair(&self) -> bool {
self.debug && !self.reduced_debug_info()
self.debug_entry("3d_crosshair")
}
/// `Permissions.COMMANDS_GAMEMASTER` (`GameModeCommand.PERMISSION_CHECK`).
pub(super) fn gamemaster(&self) -> bool {
@@ -555,8 +604,10 @@ impl Game<'_> {
true
}
DebugAction::ShowHitboxes if player => {
self.show_hitboxes = !self.show_hitboxes;
self.debug_translated(if self.show_hitboxes {
let on = self
.debug_entries
.toggle_status("entity_hitboxes", self.debug);
self.debug_translated(if on {
"debug.show_hitboxes.on"
} else {
"debug.show_hitboxes.off"
@@ -569,8 +620,10 @@ impl Game<'_> {
true
}
DebugAction::ShowChunkBorders if player => {
self.show_chunk_borders = !self.show_chunk_borders;
self.debug_translated(if self.show_chunk_borders {
let on = self
.debug_entries
.toggle_status("chunk_borders", self.debug);
self.debug_translated(if on {
"debug.chunk_boundaries.on"
} else {
"debug.chunk_boundaries.off"
@@ -674,11 +727,13 @@ impl Game<'_> {
self.debug_charts.toggle_lightmap(&mut self.debug);
true
}
// Screens and overlays Horizon does not draw yet (the debug
// options screen, the profiler and its chart, the texture dump):
// the keys are used up as in vanilla.
DebugAction::DebugOptions
| DebugAction::DumpDynamicTextures
DebugAction::DebugOptions => {
self.toggle_debug_options();
true
}
// Screens and overlays Horizon does not draw yet (the profiler and
// its chart, the texture dump): the keys are used up as in vanilla.
DebugAction::DumpDynamicTextures
| DebugAction::Profiling
| DebugAction::ProfilingChart => true,
DebugAction::ShowHitboxes
@@ -859,7 +914,7 @@ impl Game<'_> {
pub(super) fn debug_gizmos(&self, alpha: f32, camera: [f64; 3]) -> Gizmos {
let mut g = Gizmos::default();
self.bounding_box_gizmos(camera, &mut g);
if self.show_chunk_borders
if self.debug_entry("chunk_borders")
&& let Some(p) = &self.player
{
let block = [
@@ -870,7 +925,7 @@ impl Game<'_> {
let min_y = self.world.bottom_y();
g.chunk_borders(block, min_y, min_y + self.world.layout().height() - 1);
}
if !self.show_hitboxes {
if !self.debug_entry("entity_hitboxes") {
return g;
}
let lerp = |a: f64, b: f64| a + f64::from(alpha) * (b - a);
@@ -0,0 +1,466 @@
//! `DebugOptionsScreen` (F3+F6): every debug entry with Off / In Overlay /
//! Always buttons, a search box over them, the two built-in profiles and the
//! debug overlay's GUI scale. The statuses live in `debug-profile.json`
//! (`horizon_client::debug_entries`).
//! https://mcsrc.dev/2/26.3/net/minecraft/client/gui/screens/debug/DebugOptionsScreen
use super::{Game, Screen};
use horizon_client::{
debug_entries::{Category, Entry, Profile, Status, search},
hud::HudBatch,
options_file::V,
text::Text,
ui::{self, EditBox, Scroll},
};
use winit::event::{ElementState, KeyEvent};
use winit::keyboard::{KeyCode, PhysicalKey};
/// `HeaderAndFooterLayout(this, 61, 33)`.
const HEADER: f32 = 61.0;
const FOOTER: f32 = 33.0;
/// `OptionList.getRowWidth` and `ITEM_HEIGHT`.
const ROW_WIDTH: f32 = 350.0;
const ITEM: f32 = 20.0;
/// `OptionEntry.BUTTON_WIDTH`, at the CycleButtons' 16 pixels high.
const BUTTON: f32 = 60.0;
/// The footer: two profile buttons, the GUI scale and Done, 4 pixels apart.
const FOOTER_BUTTONS: [f32; 4] = [120.0, 120.0, 110.0, 60.0];
/// `debug.options.warning`'s colour (-2142128) and the buttons' text colours.
const WARNING: u32 = 0xDF_5050;
const UNSELECTED: u32 = 0xBA_BABA;
const OVERLAY: u32 = 0xFF_FF55;
const NOT_ALLOWED: u32 = 0x80_8080;
/// `EditBox.SEARCH_HINT_STYLE`: `ChatFormatting.GRAY`.
const HINT: u32 = 0xAA_AAAA;
/// The open screen: its search box and the list's scroll.
pub(super) struct DebugOptionsUi {
search: EditBox,
scroll: Scroll,
}
impl DebugOptionsUi {
pub(super) fn search_focused(&self) -> bool {
self.search.focused
}
}
/// One list row (`AbstractOptionEntry`).
enum Row {
Category(Category),
Option(&'static Entry),
}
/// `OptionList.updateSearch`: a category row before each category's entries.
fn rows(value: &str) -> Vec<Row> {
let mut rows = Vec::new();
let mut current = None;
for entry in search(value) {
if current != Some(entry.category) {
rows.push(Row::Category(entry.category));
current = Some(entry.category);
}
rows.push(Row::Option(entry));
}
rows
}
/// `AbstractSelectionList.contentHeight`.
fn content_height(n: usize) -> f32 {
n as f32 * ITEM + 4.0
}
/// `getRowLeft`: the list spans the screen, its rows centred.
fn row_left(w: f32) -> f32 {
(w / 2.0).floor() - ROW_WIDTH / 2.0
}
/// The three buttons' left edges (Off, In Overlay, Always) for a row whose
/// content starts at `x` (`extractContent`).
fn button_xs(content_x: f32) -> [f32; 3] {
let start = content_x + ROW_WIDTH - 4.0 - 3.0 * BUTTON;
[start, start + BUTTON, start + 2.0 * BUTTON]
}
/// The footer buttons' left edges and their row's top.
fn footer(w: f32, h: f32) -> ([f32; 4], f32) {
let total: f32 = FOOTER_BUTTONS.iter().sum::<f32>() + 3.0 * 4.0;
let mut x = ((w - total) / 2.0).floor();
let mut xs = [0.0; 4];
for (slot, width) in xs.iter_mut().zip(FOOTER_BUTTONS) {
*slot = x;
x += width + 4.0;
}
(xs, h - FOOTER + ((FOOTER - 20.0) / 2.0).floor())
}
/// The three buttons' statuses and texts, in their order on the row.
const BUTTONS: [(Status, &str, u32); 3] = [
(Status::Never, "options.off", 0xFF_FFFF),
(Status::InOverlay, "debug.entry.overlay", OVERLAY),
(Status::AlwaysOn, "debug.entry.always", WARNING),
];
impl Game<'_> {
/// `KeyboardHandler`'s F3+F6: closes the screen if it is open, else opens
/// it over the game or the pause menu (`canInterruptScreen`).
pub(super) fn toggle_debug_options(&mut self) {
match self.screen {
Screen::DebugOptions => self.close_debug_options(),
Screen::Playing | Screen::Paused if !self.screen_over_playing() => {
let mut search = EditBox::new(0.0, 0.0, (ROW_WIDTH / 3.0).floor(), 20.0);
search.max_length = 32;
self.debug_options = Some(DebugOptionsUi {
search,
scroll: Scroll::default(),
});
self.screen = Screen::DebugOptions;
self.pause_menu = true;
self.capture(false);
self.mining = None;
}
_ => {}
}
}
/// `Screen.onClose`: back to the game.
pub(super) fn close_debug_options(&mut self) {
self.debug_options = None;
self.screen = Screen::Playing;
self.capture(true);
}
/// The header's title row and warning (`init`'s vertical layout,
/// centred in the header): the title's x, the row's top, the warning's
/// lines with their y.
fn debug_options_header(&self, w: f32) -> (f32, f32, Vec<(Text, f32)>) {
let font = &self.gui.atlas.font;
let title = Text::translate("debug.options.title", &[]);
let warning = Text::translate("debug.options.warning", &[]).with_color(WARNING);
let lines = font.split(&warning, ROW_WIDTH);
let height = 20.0 + 8.0 + lines.len() as f32 * 9.0;
let top = ((HEADER - height) / 2.0).floor();
let third = (ROW_WIDTH / 3.0).floor();
let tw = font.width_text(&title);
let row = third + 8.0 + tw + 8.0 + third;
let title_x = ((w - row) / 2.0).floor() + third + 8.0;
let lines = lines
.into_iter()
.enumerate()
.map(|(i, line)| (line, top + 28.0 + i as f32 * 9.0))
.collect();
(title_x, top, lines)
}
/// `debugGuiScale`'s button: `GUI Scale: Unchanged`, `Auto` or the scale.
fn debug_gui_scale_label(&self) -> Text {
let value = match self.settings.file.int("debugGuiScale") {
-1 => Text::translate("options.debugGuiScale.unchanged", &[]),
0 => Text::translate("options.guiScale.auto", &[]),
n => Text::literal(n.to_string()),
};
Text::translate(
"options.generic_value",
&[Text::translate("options.guiScale", &[]), value],
)
}
/// `ClampingLazyMaxIntRange(-1, window.calculateScale(0, unicode))` as a
/// CycleButton: the next value, or the previous one with Shift.
fn cycle_debug_gui_scale(&mut self, back: bool) {
let size = self.renderer.as_ref().map_or([1280, 720], |r| {
[r.config.width as i32, r.config.height as i32]
});
let max = horizon_client::window::calculate_scale(size, 0, self.settings.force_unicode);
let current = self.settings.file.int("debugGuiScale").clamp(-1, max);
let next = match (back, current) {
(false, c) if c >= max => -1,
(false, c) => c + 1,
(true, -1) => max,
(true, c) => c - 1,
};
self.settings.file.set("debugGuiScale", V::Int(next));
self.settings.save(&Self::options_path());
}
pub(super) fn draw_debug_options(&mut self, batch: &mut HudBatch) {
let Some(mut s) = self.debug_options.take() else {
return;
};
let (w, h) = (batch.width, batch.height);
// extractBlurredBackground: the debug overlay goes under the screen.
let size = self
.renderer
.as_ref()
.map_or([w as u32, h as u32], |r| [r.config.width, r.config.height]);
self.draw_debug_overlay_under(batch, size);
let atlas = &self.gui.atlas;
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
let reduced = self.reduced_debug_info();
let rows = rows(&s.search.value);
let (top, bottom) = (HEADER, h - FOOTER);
let content = content_height(rows.len());
s.scroll.clamp(top, bottom, content);
// The list: background, rows clipped to it, separators, scrollbar.
ui::list_background(batch, atlas, top, bottom, true);
let first = batch.vertices.len();
let left = row_left(w);
let inside = self.cursor.1 >= top && self.cursor.1 < bottom;
let mut tooltip = None;
for (i, row) in rows.iter().enumerate() {
let y = top + 2.0 - s.scroll.amount.trunc() + i as f32 * ITEM;
if y + ITEM < top || y > bottom {
continue;
}
let (cx, cy) = (left + 2.0, y + 2.0);
match row {
Row::Category(category) => {
let label = Text::translate(category.translation_key(), &[])
.bold()
.underlined();
let lw = atlas.font.width_text(&label);
let lx = (cx + (ROW_WIDTH - 4.0) / 2.0 - (lw + 8.0) / 2.0).floor() + 4.0;
batch.text_styled(atlas, &label, lx, cy + 4.0, u32::MAX, true);
}
Row::Option(entry) => {
let allowed = entry.reduced || !reduced;
let name = if allowed {
Text::literal(entry.path)
} else {
Text::literal(entry.path).italic().with_color(NOT_ALLOWED)
};
batch.text_styled(atlas, &name, cx, cy + 5.0, u32::MAX, true);
let xs = button_xs(cx);
let status = self.debug_entries.status(entry.path);
for ((status_of, key, color), x) in BUTTONS.into_iter().zip(xs) {
let selected = status == status_of;
let label = Text::translate(key, &[]).with_color(if selected {
color
} else {
UNSELECTED
});
let hover = inside && ui::hovered(self.cursor, x, cy, BUTTON, 16.0);
ui::button(batch, atlas, x, cy, BUTTON, 16.0, &label, !selected, hover);
}
if !allowed && inside && ui::hovered(self.cursor, cx, y, xs[0] - cx, ITEM) {
tooltip = Some(Text::translate("debug.options.notAllowed.tooltip", &[]));
}
}
}
}
batch.clip_since(first, 0.0, top, w, bottom);
ui::separators(batch, atlas, top, bottom, true);
ui::scrollbar(
batch,
atlas,
left + ROW_WIDTH + 8.0,
top,
bottom,
content,
s.scroll.amount.trunc(),
);
// The header: title, search box, warning.
let (title_x, header_top, lines) = self.debug_options_header(w);
let title = Text::translate("debug.options.title", &[]);
batch.text_styled(atlas, &title, title_x, header_top + 5.0, u32::MAX, true);
s.search.x = title_x + atlas.font.width_text(&title) + 8.0;
s.search.y = header_top;
s.search.draw(batch, atlas, None);
if s.search.value.is_empty() {
let hint = Text::translate("debug.options.search", &[])
.with_color(HINT)
.italic();
batch.text_styled(
atlas,
&hint,
s.search.text_x(),
s.search.text_y(),
u32::MAX,
true,
);
}
for (line, y) in &lines {
let lw = atlas.font.width_text(line);
batch.text_styled(atlas, line, ((w - lw) / 2.0).floor(), *y, u32::MAX, true);
}
// The footer.
let (xs, fy) = footer(w, h);
let labels = [
Text::translate(Profile::Default.translation_key(), &[]),
Text::translate(Profile::Performance.translation_key(), &[]),
self.debug_gui_scale_label(),
Text::translate("gui.done", &[]),
];
let active = [
!self.debug_entries.is_using_profile(Profile::Default),
!self.debug_entries.is_using_profile(Profile::Performance),
true,
true,
];
for (i, label) in labels.iter().enumerate() {
let hover = ui::hovered(self.cursor, xs[i], fy, FOOTER_BUTTONS[i], 20.0);
ui::button(
batch,
atlas,
xs[i],
fy,
FOOTER_BUTTONS[i],
20.0,
label,
active[i],
hover,
);
if i == 2 && hover {
tooltip = Some(Text::translate("options.debugGuiScale.tooltip", &[]));
}
}
if let Some(tooltip) = tooltip {
let lines = atlas.font.split(&tooltip, 200.0);
ui::tooltip(batch, atlas, &lines, self.cursor);
}
self.debug_options = Some(s);
}
/// A left click: a status button, the search box, a footer button.
pub(super) fn debug_options_click(&mut self) {
let Some(mut s) = self.debug_options.take() else {
return;
};
let (w, h) = self.gui_size();
let (top, bottom) = (HEADER, h - FOOTER);
let shift =
self.keys.contains(&KeyCode::ShiftLeft) || self.keys.contains(&KeyCode::ShiftRight);
s.search.focused = ui::hovered(
self.cursor,
s.search.x,
s.search.y,
s.search.width,
s.search.height,
);
if s.search.focused {
s.search.click(&self.gui.atlas, self.cursor.0, shift, false);
}
let inside = self.cursor.1 >= top && self.cursor.1 < bottom;
let rows = rows(&s.search.value);
let left = row_left(w);
let mut clicked = None;
if inside {
for (i, row) in rows.iter().enumerate() {
let Row::Option(entry) = row else { continue };
let cy = top + 2.0 - s.scroll.amount.trunc() + i as f32 * ITEM + 2.0;
let status = self.debug_entries.status(entry.path);
for ((status_of, _, _), x) in BUTTONS.into_iter().zip(button_xs(left + 2.0)) {
if status != status_of && ui::hovered(self.cursor, x, cy, BUTTON, 16.0) {
clicked = Some((entry.path, status_of));
}
}
}
}
self.debug_options = Some(s);
if let Some((path, status)) = clicked {
// OptionEntry.setValue.
self.audio.play_button_click();
self.debug_entries.set_status(path, status);
return;
}
let (xs, fy) = footer(w, h);
let Some(i) =
(0..4).find(|&i| ui::hovered(self.cursor, xs[i], fy, FOOTER_BUTTONS[i], 20.0))
else {
return;
};
match i {
0 | 1 => {
let profile = if i == 0 {
Profile::Default
} else {
Profile::Performance
};
if self.debug_entries.is_using_profile(profile) {
return;
}
self.audio.play_button_click();
self.debug_entries.load_profile(profile);
}
2 => {
self.audio.play_button_click();
self.cycle_debug_gui_scale(shift);
}
_ => {
self.audio.play_button_click();
self.close_debug_options();
}
}
}
/// The mouse wheel over the list (`scrollRate`: half a row).
pub(super) fn debug_options_scroll(&mut self, delta: f32) {
let (_, h) = self.gui_size();
if let Some(s) = &mut self.debug_options {
let content = content_height(rows(&s.search.value).len());
s.scroll
.wheel(delta, ITEM / 2.0, HEADER, h - FOOTER, content);
}
}
/// A key the debug keys left: Escape closes, the rest edits the search.
pub(super) fn debug_options_key(&mut self, event: &KeyEvent) {
if event.state != ElementState::Pressed {
return;
}
let PhysicalKey::Code(key) = event.physical_key else {
return;
};
if key == KeyCode::Escape {
self.close_debug_options();
return;
}
let shortcut = self.edit_shortcut(event);
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
let shift = self.keys.contains(&KeyCode::ShiftLeft);
let types_text = self.types_text();
let Some(s) = &mut self.debug_options else {
return;
};
if !s.search.focused {
return;
}
if let Some(shortcut) = shortcut {
s.search.shortcut(shortcut, &mut self.clipboard);
} else if !s.search.key(key, ctrl, shift)
&& types_text
&& let Some(text) = event.text.as_deref()
{
self.debug_options_type(text);
}
}
/// Characters typed into the search box (`EditBox.charTyped`).
pub(super) fn debug_options_type(&mut self, typed: &str) {
let allowed: String = typed
.chars()
.filter(|&c| c != '\u{a7}' && c >= ' ' && c != '\u{7f}')
.collect();
if let Some(s) = &mut self.debug_options
&& s.search.focused
&& !allowed.is_empty()
{
s.search.insert_text(&allowed);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rows_put_a_category_before_each_section() {
let all = rows("");
assert_eq!(all.len(), 48);
assert!(matches!(all[0], Row::Category(Category::Text)));
let renderer = all
.iter()
.position(|r| matches!(r, Row::Category(Category::Renderer)))
.unwrap();
assert!(matches!(all[renderer + 1], Row::Option(e) if e.path == "3d_crosshair"));
// A search inside one section shows only its heading.
let hitboxes = rows("hitbox");
assert_eq!(hitboxes.len(), 2);
assert!(matches!(hitboxes[0], Row::Category(Category::Renderer)));
}
#[test]
fn the_layout_matches_vanilla_at_854_by_480() {
// 854x480 at GUI scale 2 is 427x240.
assert_eq!(row_left(427.0), 38.0);
assert_eq!(button_xs(40.0), [206.0, 266.0, 326.0]);
assert_eq!(footer(427.0, 240.0), ([2.0, 126.0, 250.0, 364.0], 213.0));
}
}
@@ -113,6 +113,8 @@ impl Game<'_> {
);
self.block_entity_event(pos, 1, WOBBLE_NEGATIVE);
self.post_block_game_event("minecraft:block_change", pos, Some(Who::Player), None);
// ServerPlayerGameMode.useItemOn: useWithoutItem consumed the click.
self.trigger_default_block_use(pos);
return true;
};
let stack = stack.clone();
+58 -5
View File
@@ -34,6 +34,7 @@ use std::{
time::{Duration, Instant},
};
pub(super) mod events;
mod report;
mod status;
@@ -165,8 +166,8 @@ pub(super) fn player_joined(id: u128, name: &str) {
}
}
/// `server.toml`'s `[anticheat]`, beside server.properties when horizon-server runs the game; off
/// without the file or the section, or (with an error logged) when the section is wrong.
/// `server.toml`'s `[anticheat]`, beside server.properties when horizon-server runs the game; the
/// default (on) without the file or the section, off (with an error logged) when the section is wrong.
fn load_anticheat() -> horizon_anticheat::Config {
let Ok(text) = std::fs::read_to_string("server.toml") else {
return Default::default();
@@ -174,7 +175,10 @@ fn load_anticheat() -> horizon_anticheat::Config {
match horizon_anticheat::Config::from_server_toml(&text) {
Ok(config) => {
if config.enabled {
log("INFO", "Anticheat on (server.toml [anticheat])");
log(
"INFO",
"Anticheat on (server.toml [anticheat]; enabled = false turns it off)",
);
}
config
}
@@ -183,11 +187,44 @@ fn load_anticheat() -> horizon_anticheat::Config {
"ERROR",
&format!("server.toml: {e}; the anticheat stays off"),
);
Default::default()
horizon_anticheat::Config {
enabled: false,
..Default::default()
}
}
}
}
/// The secret for Velocity's player information forwarding, from `forwarding.secret` beside server.properties.
/// No file means no forwarding. A file that is empty or can't be read stops the start, because an empty secret
/// would let anyone sign a login, and a typo must not leave a server open that was meant to be behind a proxy.
fn forwarding_secret(online: bool) -> Result<Option<Vec<u8>>> {
const FILE: &str = "forwarding.secret";
if !std::path::Path::new(FILE).exists() {
return Ok(None);
}
if online {
log(
"WARN",
"forwarding.secret is ignored: the proxy authenticates the players, so set \"online-mode\" to \"false\".",
);
return Ok(None);
}
let text = std::fs::read(FILE).map_err(|e| format!("{FILE}: {e}"))?;
let secret = text.trim_ascii().to_vec();
if secret.is_empty() {
return Err(format!(
"{FILE} is empty: put the proxy's forwarding secret in it, or remove the file"
)
.into());
}
log(
"INFO",
"Velocity player information forwarding is on (forwarding.secret)",
);
Ok(Some(secret))
}
/// Log4j's console pattern: `[HH:mm:ss] [thread/LEVEL]: message`.
pub(super) fn log(level: &str, message: &str) {
log_from("Server thread", level, message);
@@ -697,6 +734,8 @@ impl Game<'_> {
self.server_lists = Some(lists.clone());
log("INFO", &format!("Preparing level \"{level_name}\""));
let folder = world_dir.to_string_lossy().into_owned();
// MinecraftServer.loadLevel's start: nothing is loaded yet.
self.native_server_about_to_start(true);
self.native_lifecycle_open_started();
// The registries the server sends are read while the world prepares.
let sync = super::lan::preload_sync_data(&self.options.resources);
@@ -759,6 +798,8 @@ impl Game<'_> {
let favicon = std::fs::read("server-icon.png")
.ok()
.filter(|b| png_size(b) == Some((64, 64)));
// Behind a Velocity proxy: `forwarding.secret` in the server folder, like the proxy's own file.
let forwarding_secret = forwarding_secret(online.is_some())?;
let net = NetConfig {
bind: (!props.server_ip.is_empty())
.then(|| props.server_ip.parse().ok())
@@ -786,6 +827,7 @@ impl Game<'_> {
})),
gate: Some(gate),
online,
forwarding_secret,
log: Some(NetLog {
write: Arc::new(log_from),
lang: Arc::new(horizon_client::net::load_language(&self.options.resources)),
@@ -1032,11 +1074,12 @@ impl Game<'_> {
/// nobody is online a dedicated server keeps none (vanilla has no spawn
/// ticket), an integrated one the spawn chunk's.
pub(super) fn update_dedicated_streaming(&mut self) {
let rule = self.ui.rules.bool("spectators_generate_chunks");
let guest = self.lan.as_ref().and_then(|h| {
h.hub
.guests
.iter()
.find(|g| h.hub.here(g))
.find(|g| h.hub.here(g) && super::game_mode::loads_chunks(rule, g.spectator()))
.map(|g| g.chunk())
});
if self.standalone_server() {
@@ -1636,6 +1679,14 @@ impl Game<'_> {
.collect();
for conn in conns {
h.hub.kick(conn, reason.clone());
if let Some(g) = h.hub.guest(conn) {
events::record(
"kick",
&g.name,
uuid,
serde_json::json!({ "reason": g.left_reason }),
);
}
}
}
}
@@ -1662,6 +1713,8 @@ impl Game<'_> {
fn stop_dedicated(&mut self) -> Result<()> {
// The close request starts before player and world saves.
self.native_lifecycle.begin_close();
// MinecraftServer.stopServer's start: everyone is still here.
self.native_server_stopping(true);
log("INFO", "Stopping server");
log("INFO", "Saving players");
if let Some(host) = self.lan.take() {
@@ -0,0 +1,69 @@
//! What happened on the dedicated server, typed, for horizon-server's event stream (`GET /api/v1/events`,
//! docs/api/server.md): players joining, leaving and kicked, chat, and deaths. The log says the same in
//! vanilla's words; these carry the player's UUID and the parts apart, so a bot needn't parse lines.
//! `server-events SINCE` on the control endpoint answers the events after the SINCE-th.
use serde_json::{Value, json};
use std::{collections::VecDeque, sync::Mutex};
/// The events kept for a reader that polls late.
const KEPT: usize = 1000;
struct Events {
/// The number of the next event (the first is 1).
next: u64,
kept: VecDeque<Value>,
}
static EVENTS: Mutex<Events> = Mutex::new(Events {
next: 1,
kept: VecDeque::new(),
});
/// Records one event: its kind, the player's name and UUID, and what else it says.
pub(in crate::game) fn record(kind: &str, name: &str, uuid: u128, mut fields: Value) {
let mut events = EVENTS.lock().unwrap_or_else(|e| e.into_inner());
fields["seq"] = events.next.into();
fields["type"] = kind.into();
fields["name"] = name.into();
fields["uuid"] = horizon_protocol::crypto::uuid_string(uuid).into();
fields["time"] = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as u64)
.into();
events.next += 1;
if events.kept.len() == KEPT {
events.kept.pop_front();
}
events.kept.push_back(fields);
}
/// `server-events SINCE`: the events numbered above `since`, and the number to ask from next.
pub(super) fn since(since: u64) -> Value {
let events = EVENTS.lock().unwrap_or_else(|e| e.into_inner());
let after: Vec<&Value> = events
.kept
.iter()
.filter(|e| e["seq"].as_u64().is_some_and(|s| s > since))
.collect();
json!({ "next": events.next - 1, "events": after })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn events_are_numbered_and_read_from_a_number() {
let first = since(u64::MAX)["next"].as_u64().unwrap();
record("join", "Kim0", 1, json!({}));
record("chat", "Kim0", 1, json!({ "message": "hi" }));
let read = since(first);
let events = read["events"].as_array().unwrap();
assert_eq!(read["next"].as_u64(), Some(first + 2));
assert_eq!(events.len(), 2);
assert_eq!(events[1]["type"], "chat");
assert_eq!(events[1]["message"], "hi");
assert_eq!(events[1]["uuid"], "00000000-0000-0000-0000-000000000001");
assert_eq!(since(first + 1)["events"].as_array().unwrap().len(), 1);
}
}
@@ -1,6 +1,7 @@
//! What `horizon-server` asks the dedicated server on its control endpoint (`--control-socket`, docs/server.md
//! section 2): `server-status` answers with the tick rate, the tick times `/tick query` reports, the players
//! and the loaded chunks; `console <line>` runs a console command and answers with the lines it logged.
//! and the loaded chunks; `console <line>` runs a console command and answers with the lines it logged;
//! `server-events <since>` answers the typed events after that number (events.rs).
use super::{ServerState, capture_log};
use crate::game::Game;
use serde_json::{Value, json};
@@ -41,6 +42,9 @@ impl Game<'_> {
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
let reply = match request.line.trim().split_once(' ') {
_ if request.line.trim() == "server-status" => self.server_status(state),
Some(("server-events", since)) => {
super::events::since(since.trim().parse().unwrap_or(0))
}
Some(("console", line)) => {
// Command feedback is logged as system chat; the reply carries the text alone.
let output: Vec<String> = capture_log(|| self.console_command(state, line))
+3 -3
View File
@@ -72,7 +72,7 @@ impl Game<'_> {
/// reach, and how far along the view it is.
pub(super) fn redirectable_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let reach: f64 = self.entity_reach;
self.creatures
.projectiles
.iter()
@@ -94,7 +94,7 @@ impl Game<'_> {
if self.mob_hit().is_some_and(|(_, d)| d < t) {
return false;
}
if let (Some(hit), Some(eye)) = (self.pick(), self.eye_position()) {
if let (Some(hit), Some(eye)) = (self.pick_unfiltered(), self.eye_position()) {
let d = (0..3)
.map(|a| (hit.point[a] - eye[a]).powi(2))
.sum::<f64>()
@@ -122,7 +122,7 @@ impl Game<'_> {
let d2: f64 = (0..3)
.map(|a| (eye[a] - eye[a].clamp(lo[a], hi[a])).powi(2))
.sum();
let range = if self.creative { 5.0 } else { 3.0 } + 3.0;
let range = self.entity_reach + 3.0;
d2 < range * range
}
+102 -10
View File
@@ -1190,7 +1190,7 @@ impl Game<'_> {
/// The dragon part under the crosshair within reach: its id and distance.
pub(super) fn dragon_hit(&self) -> Option<(u32, f64)> {
let (eye, dir) = self.aim()?;
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let reach: f64 = self.entity_reach;
let mut best: Option<(u32, f64)> = None;
for (id, b) in self.special_targets() {
let (lo, hi) = (b.min_position(), b.max_position());
@@ -1706,15 +1706,7 @@ impl Game<'_> {
}
}
Phase::Attacking { ticks } => {
if *ticks == 0 {
let pitch = 0.8 + self.world.random.next_float() * 0.3;
self.audio.play(
"minecraft:entity.ender_dragon.growl",
Some([pos.x, pos.y, pos.z]),
2.5,
pitch,
);
}
// The growl is `doClientTick`'s (`remote_dragon_phase_tick`).
*ticks += 1;
if *ticks > 40 {
next = Some(Phase::Flaming { ticks: 0 });
@@ -3656,8 +3648,108 @@ impl Game<'_> {
let d = self.dragon.as_mut().expect("dragon");
d.yaw = wrap(d.yaw);
d.record();
self.remote_dragon_phase_tick();
self.place_dragon_parts();
}
/// `DragonLandingPhase.doClientTick`, `DragonSittingFlamingPhase.doClientTick`,
/// `DragonDeathPhase.doClientTick` and `DragonSittingAttackingPhase.doClientTick`,
/// which `EnderDragon.aiStep` runs on a client. The first two breathe
/// dragon breath from the head (their `look.yRot(...)` results are thrown
/// away in vanilla, so every puff leaves along the same line), the third
/// puffs an explosion every 10 ticks and the last growls on every tick.
fn remote_dragon_phase_tick(&mut self) {
let look = self.dragon_look();
let norm = (look[0] * look[0] + look[1] * look[1] + look[2] * look[2]).sqrt();
let look = if norm < 1.0e-5 {
[0.0; 3]
} else {
[look[0] / norm, look[1] / norm, look[2] / norm]
};
let Some(d) = self.dragon.as_mut() else {
return;
};
let head = d.parts[0].0;
let head_y = head.y + PART_SIZES[0].1 * 0.5;
let motion = d.velocity;
let at = [d.position.x, d.position.y, d.position.z];
let mut puffs: Vec<([f64; 3], [f64; 3])> = Vec::new();
let mut emitter = None;
let mut growl = None;
match &mut d.phase {
Phase::Landing { .. } => {
for _ in 0..8 {
let r = &mut d.random;
let p = [
head.x + r.next_gaussian() / 2.0,
head_y + r.next_gaussian() / 2.0,
head.z + r.next_gaussian() / 2.0,
];
puffs.push((
p,
[
-look[0] * f64::from(0.08_f32) + motion.x,
-look[1] * f64::from(0.3_f32) + motion.y,
-look[2] * f64::from(0.08_f32) + motion.z,
],
));
}
}
Phase::Flaming { ticks } => {
*ticks += 1;
if *ticks % 2 == 0 && *ticks < 10 {
for _ in 0..8 {
let r = &mut d.random;
let p = [
head.x + r.next_gaussian() / 2.0,
head_y + r.next_gaussian() / 2.0,
head.z + r.next_gaussian() / 2.0,
];
for j in 0..6 {
puffs.push((
p,
[
-look[0] * f64::from(0.08_f32) * f64::from(j),
-look[1] * f64::from(0.6_f32),
-look[2] * f64::from(0.08_f32) * f64::from(j),
],
));
}
}
}
}
Phase::Dying { time, .. } => {
if *time % 10 == 0 {
let r = &mut d.random;
let o = [
f64::from((r.next_float() - 0.5) * 8.0),
f64::from((r.next_float() - 0.5) * 4.0),
f64::from((r.next_float() - 0.5) * 8.0),
];
emitter = Some([at[0] + o[0], at[1] + 2.0 + o[1], at[2] + o[2]]);
}
*time += 1;
}
Phase::Attacking { .. } => {
growl = Some(0.8 + d.random.next_float() * 0.3);
}
_ => {}
}
let breath = ParticleOptions::simple(ParticleType::DragonBreath);
for (p, v) in puffs {
self.particles.add(&breath, p, v);
}
if let Some(p) = emitter {
self.particles.add(
&ParticleOptions::simple(ParticleType::ExplosionEmitter),
p,
[0.0; 3],
);
}
if let Some(pitch) = growl {
self.audio
.play("minecraft:entity.ender_dragon.growl", Some(at), 2.5, pitch);
}
}
/// The dragon's parts as a server's client attacks them: the server's
/// ids for `EnderDragonPart`s follow the dragon's.
pub(super) fn remote_dragon_part_hit(
@@ -122,14 +122,6 @@ impl Game<'_> {
base,
)
}
/// Strength / weakness on the attack damage attribute.
pub(super) fn effect_attack_bonus(&self) -> f32 {
let fx = &self.potions.effects;
fx.amplifier("strength")
.map_or(0.0, |a| 3.0 * (a + 1) as f32)
- fx.amplifier("weakness")
.map_or(0.0, |a| 4.0 * (a + 1) as f32)
}
/// `Player.attack`'s extra knockback: `getKnockback` (the weapon's knockback
/// enchantment, halved) plus 0.5 for a sprinting full-strength hit, and
/// whether the hit is that knockback attack.
+139 -26
View File
@@ -3,7 +3,9 @@
//! draws), entity damage with exposure and knockback, block interaction with
//! merged drops and TNT chain priming, and optional fire.
use super::Game;
use horizon_client::{Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack};
use horizon_client::{
Result, blocks::Fluid, collisions::SceneCollisions, items::ItemStack, text::Text,
};
use horizon_core::{
geometry::{Vec3, VoxelShape},
position::BlockPos,
@@ -136,6 +138,30 @@ fn hurt_and_push_mob(
m.needs_sync = true;
}
/// A blast's causing entity and its name (`Explosion.getIndirectSourceEntity`):
/// the mob that owns the exploding projectile while it is in the level
/// (`Projectile.getOwner` is null once it is removed), else the exploding
/// mob. `live` names a mob in the level, `exploded` the mobs whose blast
/// went off this tick and that already left it.
fn blast_cause(
owner: Option<u32>,
exploding: Option<u32>,
live: impl Fn(u32) -> Option<Text>,
exploded: &[(u32, Text)],
) -> Option<(u32, Text)> {
if let Some(owner) = owner {
return live(owner).map(|name| (owner, name));
}
let id = exploding?;
let name = live(id).or_else(|| {
exploded
.iter()
.rfind(|(m, _)| *m == id)
.map(|(_, n)| n.clone())
})?;
Some((id, name))
}
thread_local! {
/// Client-side randomness (explosion sound pitch), separate from the level random.
static CLIENT_RANDOM: std::cell::RefCell<LegacyRandom> = std::cell::RefCell::new(LegacyRandom::new(0x5eed));
@@ -145,6 +171,15 @@ pub(super) fn client_float() -> f32 {
}
impl Game<'_> {
/// `blast_cause` for the blast going off now.
pub(super) fn blast_attacker(&self) -> Option<(u32, Text)> {
blast_cause(
self.combat.explosion_owner,
self.combat.explosion_source,
|id| self.mob_display_name(id),
&self.combat.exploded,
)
}
/// `ServerExplosion.getSeenPercent` for an entity box.
pub(super) fn seen_percent(&self, center: [f64; 3], min: [f64; 3], max: [f64; 3]) -> f32 {
let xs = 1.0 / ((max[0] - min[0]) * 2.0 + 1.0);
@@ -249,16 +284,26 @@ impl Game<'_> {
} else {
0.0
};
// A primed TNT minecart's rails and the blocks they
// stand on: no resistance, and never blown up.
let spared = self.combat.rail_proof_blast
&& (self.rail(pos[0], pos[1], pos[2]).is_some()
|| self.rail(pos[0], pos[1] + 1, pos[2]).is_some());
match resists {
Some((at, resistance)) if at == pos => {
power -= (resistance + 0.3) * 0.3;
}
_ if !self.res.facts().is_air(state) || f.fluid != Fluid::None => {
power -= (f.explosion_resistance.max(fluid) + 0.3) * 0.3;
let resistance = if spared {
0.0
} else {
f.explosion_resistance.max(fluid)
};
power -= (resistance + 0.3) * 0.3;
}
_ => {}
}
if power > 0.0 {
if power > 0.0 && !spared {
set.put(Key(pos), ());
}
xp += xd * f64::from(0.3f32);
@@ -288,6 +333,7 @@ impl Game<'_> {
radius: f32,
damage_type: &str,
exploding: Option<u32>,
attacker: Option<&(u32, Text)>,
) -> Option<[f64; 3]> {
let diameter = f64::from(radius * 2.0);
// Blast protection: `EXPLOSION_KNOCKBACK_RESISTANCE` (0.15 per level and
@@ -341,6 +387,11 @@ impl Game<'_> {
let flying = self.player.as_ref().is_some_and(|p| p.flying);
let mut outcome = horizon_client::health::BlockOutcome::default();
let mut pushed = None;
// The death message names the blast's causing entity. It goes to this
// player's tracker, the host's or a guest's.
if let Some((id, name)) = attacker.filter(|_| !creative && !spectator && hurtable) {
self.note_named_attacker(damage_type, *id, name.clone());
}
if let Some(p) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(damage_type)
{
@@ -354,8 +405,7 @@ impl Game<'_> {
id,
amount,
blocking,
// The exploding mob is the causing entity.
exploding.is_some(),
attacker.is_some(),
)
{
outcome = o;
@@ -429,7 +479,9 @@ impl Game<'_> {
// The player, then every other player in the level (LAN guests),
// each swapped in as the player. In a guest's turn (their hit on an
// end crystal) the player is that guest.
let own = self.explosion_hurt_player(center, radius, damage_type, exploding);
let attacker = self.blast_attacker();
let own =
self.explosion_hurt_player(center, radius, damage_type, exploding, attacker.as_ref());
let mut pushed: Vec<_> = self.lan_acting.zip(own).into_iter().collect();
if let Some(mut host) = self.lan.take() {
let guests: Vec<u32> = host
@@ -441,7 +493,13 @@ impl Game<'_> {
.collect();
for conn in guests {
let push = self.as_guest(&mut host, conn, |g| {
g.explosion_hurt_player(center, radius, damage_type, exploding)
g.explosion_hurt_player(
center,
radius,
damage_type,
exploding,
attacker.as_ref(),
)
});
if let Some(Some(push)) = push {
pushed.push((conn, push));
@@ -486,10 +544,9 @@ impl Game<'_> {
kind: horizon_client::mobs::DamageKind::Explosion,
// A projectile's owner (`getIndirectSourceEntity`), else
// the exploding mob itself.
attacker: self
.combat
.explosion_owner
.or(exploding)
attacker: attacker
.as_ref()
.map(|&(id, _)| id)
.filter(|&o| o != m.id)
.map(horizon_client::mobs::Who::Mob),
from: Some(center),
@@ -592,14 +649,10 @@ impl Game<'_> {
self.explosion_breaks_decorations(center, diameter, interaction);
pushed
}
/// `blast_spares_decorations` for the blast going off now: the mob that
/// owns its projectile or explodes itself is the source's entity.
/// `blast_spares_decorations` for the blast going off now: its causing
/// mob (`blast_attacker`) is the source's entity.
pub(super) fn blast_spares_decorations(&self, interaction: BlockInteraction) -> bool {
let by_mob = self
.combat
.explosion_owner
.or(self.combat.explosion_source)
.is_some();
let by_mob = self.blast_attacker().is_some();
blast_spares_decorations(self.ui.rules.bool("mob_griefing"), interaction, by_mob)
}
/// `ServerExplosion.hurtEntities` for paintings, cushions and armour
@@ -681,14 +734,11 @@ impl Game<'_> {
interaction: BlockInteraction,
fire: bool,
) -> Result<()> {
self.server_explode_as(
center,
radius,
interaction,
fire,
"minecraft:explosion",
None,
)
let damage_type = super::creatures::damage_type(
horizon_client::mobs::DamageKind::Explosion,
self.blast_attacker().is_some(),
);
self.server_explode_as(center, radius, interaction, fire, damage_type, None)
}
/// `server_explode` with the damage type the player takes and a block
/// whose explosion resistance the damage calculator overrides.
@@ -1003,6 +1053,69 @@ mod tests {
assert!(blast_spares_decorations(false, Destroy, true));
}
/// The type a blast hurts with and the attacker its death message names
/// come from one cause (`DamageSources.explosion`): `player_explosion`
/// "blown up by" that mob, or `explosion` with no one when it has none.
fn blast_hurt(
owner: Option<u32>,
exploding: Option<u32>,
live: &[(u32, Text)],
exploded: &[(u32, Text)],
) -> (&'static str, Option<(u32, Text)>) {
let find = |id| live.iter().find(|(m, _)| *m == id).map(|(_, n)| n.clone());
let cause = blast_cause(owner, exploding, find, exploded);
let kind = horizon_client::mobs::DamageKind::Explosion;
(
super::super::creatures::damage_type(kind, cause.is_some()),
cause,
)
}
#[test]
fn a_caused_blast_names_its_cause_under_player_explosion() {
let creeper = Text::translate("entity.minecraft.creeper", &[]);
let ghast = Text::translate("entity.minecraft.ghast", &[]);
// A creeper's blast: it left the mob list, its event named it.
assert_eq!(
blast_hurt(None, Some(7), &[], &[(7, creeper.clone())]),
("minecraft:player_explosion", Some((7, creeper)))
);
// A ghast's fireball: its owner is still in the level.
assert_eq!(
blast_hurt(Some(3), None, &[(3, ghast.clone())], &[]),
("minecraft:player_explosion", Some((3, ghast.clone())))
);
// The ghast was removed before its fireball landed: no owner.
assert_eq!(
blast_hurt(Some(3), None, &[], &[]),
("minecraft:explosion", None)
);
// Nothing caused it.
assert_eq!(
blast_hurt(None, None, &[], &[]),
("minecraft:explosion", None)
);
// A projectile's owner wins over the exploding mob, and a removed
// owner gives no cause even when a mob is exploding.
let zombie = Text::translate("entity.minecraft.zombie", &[]);
let live = [(3, ghast.clone()), (5, zombie.clone())];
assert_eq!(
blast_hurt(Some(3), Some(5), &live, &[]),
("minecraft:player_explosion", Some((3, ghast)))
);
assert_eq!(
blast_hurt(Some(4), Some(5), &live, &[]),
("minecraft:explosion", None)
);
// A mob still in the level is named as it is now, before its name
// in the exploded list.
let named = Text::literal("Bob".to_owned());
assert_eq!(
blast_hurt(None, Some(5), &live, &[(5, named)]),
("minecraft:player_explosion", Some((5, zombie)))
);
}
#[test]
fn block_pos_hash_matches_vec3i() {
// Vec3i.hashCode: (y + z * 31) * 31 + x
+3 -3
View File
@@ -76,8 +76,8 @@ impl Game<'_> {
(cps, self.extras.mouse)
}
/// Auto-reconnect: a connection to `address` as `name` starts.
pub(super) fn extras_connecting(&mut self, address: &str, name: &str) {
self.extras.reconnect.connecting(address, name);
pub(super) fn extras_connecting(&mut self, address: &str, name: &str, lan: bool) {
self.extras.reconnect.connecting(address, name, lan);
}
/// The server this game connected to last.
pub(super) fn server_address(&self) -> Option<&str> {
@@ -124,7 +124,7 @@ impl Game<'_> {
/// (the launcher's session file is gone after the first read).
pub(super) fn extras_retry(&mut self) -> horizon_client::Result<()> {
if let Some(retry) = self.title.as_mut().and_then(|t| t.due_retry()) {
self.connect_server(&retry.address, &retry.name)?;
self.connect_server_with_options(&retry.address, &retry.name, None, retry.lan)?;
}
Ok(())
}
+25 -22
View File
@@ -10,9 +10,10 @@ use horizon_client::{
entity_nbt::RocketNbt,
items::ItemStack,
mesher::Vertex,
mobs::{Damage, DamageKind, Species, Who},
mobs::{Damage, Species, Who},
particles::{FireworkExplosion, ParticleOptions, ParticleType},
projectiles::ProjectileWorld,
text::Text,
};
use horizon_core::{
geometry::{Aabb, Vec3},
@@ -398,14 +399,14 @@ impl Game<'_> {
// other player in range and in sight takes the blast.
let boosted = r.guest.filter(|_| r.attached);
if r.attached && boosted.is_none() {
self.rocket_hurt_player(damage);
self.rocket_hurt_player(from, damage);
} else {
self.rocket_blast_player(from, damage);
}
if let Some(conn) = boosted
&& let Some(mut host) = self.lan.take()
{
self.as_guest(&mut host, conn, |g| g.rocket_hurt_player(damage));
self.as_guest(&mut host, conn, |g| g.rocket_hurt_player(from, damage));
self.lan = Some(host);
}
// Every other player in the level (LAN guests, the players of a
@@ -466,14 +467,7 @@ impl Game<'_> {
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
for &(id, amount) in hits {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.damage(
amount,
Damage {
kind: DamageKind::Explosion,
attacker,
from: Some(at),
},
);
m.damage(amount, Damage::fireworks(attacker, at));
}
}
}
@@ -577,21 +571,30 @@ impl Game<'_> {
})
};
if sees {
self.rocket_hurt_player(blast_damage(damage, from, feet));
self.rocket_hurt_player(from, blast_damage(damage, from, feet));
}
}
/// `hurtServer` with `damageSources().fireworks`: armor absorbs it and
/// wears (`hurtArmor`); creative and spectator players are immune.
fn rocket_hurt_player(&mut self, amount: f32) {
if self.creative || self.spectator() {
/// `hurtServer` with `damageSources().fireworks` from the rocket at
/// `from`: the rocket is the direct entity, so the player is knocked away
/// from it and the death message names it; creative and spectator
/// players are immune.
fn rocket_hurt_player(&mut self, from: Vec3, amount: f32) {
if self.creative
|| self.spectator()
|| self
.player
.as_ref()
.is_none_or(|p| p.health.dead_or_dying())
{
return;
}
if let Some(p) = self.player.as_mut()
&& !p.health.dead_or_dying()
&& let Some(id) = self.motion.health.damage.id("minecraft:fireworks")
{
let _ = p.hurt(&self.motion.health, self.world.health_rules, id, amount);
}
// No mob ever owns a rocket.
self.combat.projectile_owner = None;
self.combat.rocket_hit = true;
self.combat.projectile_direct =
Some(Text::translate("entity.minecraft.firework_rocket", &[]));
let at = [from.x, from.y, from.z];
self.hurt_player_from(amount, Some(at), "minecraft:fireworks", Some(None));
}
/// `FireworkEntityRenderer.submit`: the item (GROUND) facing the camera,
/// lit at the rocket's light probe.
+8 -2
View File
@@ -175,7 +175,7 @@ impl Game<'_> {
/// The frame under the crosshair within reach and its distance.
pub(super) fn frame_hit(&self) -> Option<(usize, f64)> {
let (eye, dir) = self.aim()?;
let reach: f64 = if self.creative { 5.0 } else { 3.0 };
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64)> = None;
for (i, f) in self.item_frames.iter().enumerate() {
let (lo, hi) = f.bounds();
@@ -201,7 +201,7 @@ impl Game<'_> {
}
/// Nearer than the targeted block?
pub(super) fn before_block(&self, distance: f64) -> bool {
self.pick().is_none_or(|h| {
self.pick_unfiltered().is_none_or(|h| {
let eye = self.aim().map(|(eye, _)| eye).unwrap_or_default();
(0..3)
.map(|a| (h.point[a] - eye[a]).powi(2))
@@ -283,6 +283,12 @@ impl Game<'_> {
}
/// `ItemFrame.dropItem`: the frame (when breaking) and the held item.
fn drop_frame_items(&mut self, f: &ItemFrame, with_frame: bool, by_player: bool) {
// The framed map forgets the frame, except when the drops are off and
// something caused the break (`removeFramedMap` runs only for a null
// `causedBy` then).
if self.ui.rules.bool("entity_drops") || !by_player {
self.remove_framed_map(f);
}
if !self.ui.rules.bool("entity_drops") || (by_player && self.creative) {
return;
}
+55 -2
View File
@@ -1,7 +1,8 @@
//! Game modes beyond survival and creative (`GameType`): adventure players
//! cannot build, spectators fly through blocks and only watch.
use super::{Game, Screen};
use horizon_client::command::args::GameMode;
use horizon_client::{collisions::SceneCollisions, command::args::GameMode};
use horizon_world::world_movement::in_range_of_ground;
pub(super) fn mode_id_of(mode: GameMode) -> i32 {
match mode {
@@ -19,6 +20,14 @@ pub(super) fn mode_from_id(id: i32) -> GameMode {
_ => GameMode::Survival,
}
}
/// `ChunkMap.skipPlayer`: a player is skipped when `player.isSpectator() &&
/// !level.getGameRules().getBoolean(SPECTATORS_GENERATE_CHUNKS)`. A skipped
/// player holds no distance-manager ticket and streams no chunks around
/// itself, but still receives the chunks other players load (chunk sending
/// is untouched).
pub(super) fn loads_chunks(spectators_generate_chunks: bool, spectator: bool) -> bool {
!spectator || spectators_generate_chunks
}
impl Game<'_> {
pub(super) fn spectator(&self) -> bool {
self.mode == GameMode::Spectator
@@ -36,8 +45,9 @@ impl Game<'_> {
/// `ServerPlayer.setGameMode` and `GameType.updatePlayerAbilities`.
pub(super) fn set_mode(&mut self, mode: GameMode) {
let was_spectator = self.spectator();
let changed = mode != self.mode;
// MultiPlayerGameMode.setLocalMode: the mode it leaves.
if mode != self.mode {
if changed {
self.previous_mode = Some(self.mode);
}
self.mode = mode;
@@ -67,6 +77,28 @@ impl Game<'_> {
p.flying = true;
}
}
// ServerPlayerGameMode.changeGameModeForPlayer: a player left flying
// by the change lands when the ground is within a block.
let flying = self
.player
.as_ref()
.filter(|p| p.flying)
.map(|p| p.bounds());
if changed
&& mode != GameMode::Spectator
&& let Some(bounds) = flying
{
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
if in_range_of_ground(&mut scene, bounds)
&& let Some(p) = self.player.as_mut()
{
p.flying = false;
}
}
if mode == GameMode::Spectator && self.screen == Screen::Inventory {
self.screen = Screen::Playing;
}
@@ -186,3 +218,24 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::loads_chunks;
/// `ChunkMap.skipPlayer`: only a spectator with `spectators_generate_chunks`
/// off stops loading chunks around itself.
#[test]
fn a_skipped_spectator_loads_no_chunks() {
assert!(!loads_chunks(false, true), "spectator, rule off");
assert!(loads_chunks(true, true), "spectator, rule on");
assert!(
loads_chunks(false, false),
"survival, creative or adventure"
);
assert!(
loads_chunks(true, false),
"survival, creative or adventure, rule on"
);
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ impl Game<'_> {
let name = names.into_iter().find(|(id, _)| *id == m.id);
name.and_then(|(_, name)| self.score.board.players_team(&name))
} else {
self.mob_team(m.id)
self.mob_team_of(m)
};
Some(team_color(team))
}
+17 -2
View File
@@ -104,7 +104,12 @@ impl Game<'_> {
/// `getAttributeValue` of an interaction range of the player in place (a
/// guest swapped in on a server): creative's modifiers are on it
/// (`update_player_attributes`), as are `/attribute`'s and the items'.
fn interaction_range(&mut self, attribute: &str, survival: f64, creative: f64) -> f64 {
pub(super) fn interaction_range(
&mut self,
attribute: &str,
survival: f64,
creative: f64,
) -> f64 {
let otherwise = if self.creative { creative } else { survival };
self.player
.as_mut()
@@ -333,7 +338,7 @@ impl Game<'_> {
|| before(self.redirectable_hit().map(|h| h.1))
}
/// `LocalPlayer.isHandsBusy`: paddling a boat.
fn hands_busy(&self) -> bool {
pub(super) fn hands_busy(&self) -> bool {
self.riding
.is_some_and(|id| self.boats.iter().any(|b| b.id == id))
&& [
@@ -569,6 +574,16 @@ impl Game<'_> {
pub(super) fn attack_item_info(&self) -> Option<&horizon_client::items::ItemInfo> {
self.items().get(self.attack_item.as_deref()?)
}
/// `Player.attack`: the attack damage attribute, with the held weapon's
/// and the effects' modifiers on it.
pub(super) fn attack_damage(&mut self) -> f32 {
self.player
.as_mut()
.and_then(|p| p.attribute("minecraft:attack_damage").ok())
.unwrap_or(1.0) as f32
}
/// `evaluateWhichHandsToRender`: which of the main and off hand show.
pub(super) fn hands_to_render(&self) -> [bool; 2] {
let held = |slot| {
+5
View File
@@ -22,6 +22,10 @@ impl Game<'_> {
}
match self.screen {
Screen::Chat | Screen::CommandBlock => true,
Screen::DebugOptions => self
.debug_options
.as_ref()
.is_some_and(|s| s.search_focused()),
Screen::WorldOptions => self.world_options_typing(),
Screen::Creative => self.creative_screen.tab().search,
Screen::Inventory => {
@@ -106,6 +110,7 @@ impl Game<'_> {
match self.screen {
Screen::Chat => self.chat_type(&text),
Screen::CommandBlock => self.command_block_type(&text),
Screen::DebugOptions => self.debug_options_type(&text),
Screen::WorldOptions => self.world_options_text(Some(&text)),
Screen::Creative => self.creative_search_edit(Some(&text)),
Screen::Inventory if self.mod_browser_typing() => {
+6 -1
View File
@@ -577,6 +577,8 @@ impl Game<'_> {
ready: &Sender<Ready>,
opening: Opening,
) -> Result<()> {
// MinecraftServer.loadLevel's start: nothing is loaded yet.
self.native_server_about_to_start(false);
self.native_lifecycle_open_started();
// `load_world`, with the world `serve` started preparing.
self.join_world_rest()?;
@@ -672,6 +674,8 @@ impl Game<'_> {
fn stop_integrated(&mut self) -> Result<()> {
// The close request: lifecycle observation stops.
self.native_lifecycle.begin_close();
// MinecraftServer.stopServer's start: everyone is still here.
self.native_server_stopping(false);
// IntegratedServer.stopServer: teardownPublishedState.
if let Some(s) = self.serving.take() {
s.flags.published.store(0, Ordering::Relaxed);
@@ -956,7 +960,8 @@ impl Game<'_> {
.find(|g| g.uuid == owner)
.map(|g| g.conn)
{
host.hub.set_game_mode(conn, mode);
host.hub
.set_game_mode(conn, mode, &self.world, Some(self.collision));
}
}
/// `IntegratedServer.setWorldAllowCommands`: the owner is an operator
@@ -66,7 +66,7 @@ impl Game<'_> {
/// and the hit distance along the view.
pub(super) fn interaction_hit(&self) -> Option<(i32, f64)> {
let (eye, dir) = self.aim()?;
let reach = if self.creative { 5.0 } else { 3.0 };
let reach = self.entity_reach;
self.interactions
.iter()
.filter_map(|(&id, i)| ray_box(eye, dir, reach, i.bounds()).map(|d| (id, d)))
@@ -22,6 +22,9 @@ pub(super) struct Env<'a, 'g> {
game: &'a Game<'g>,
}
impl ItemEnv for Env<'_, '_> {
fn enchantment_in_tag(&self, enchantment: &str, tag: &str) -> bool {
self.game.stations().enchantments.in_tag(tag, enchantment)
}
fn potion_effect_color(&self, contents: &Value) -> Option<i32> {
// `PotionContents.getColorOr`: the effect mix when any effect is visible.
let id = contents["potion"].as_str();
@@ -70,6 +70,8 @@ impl Game<'_> {
return false;
}
self.eject_record(pos);
// ServerPlayerGameMode.useItemOn: useWithoutItem consumed the click.
self.trigger_default_block_use(pos);
return true;
}
let Some(stack) = held else { return false };
+347 -157
View File
@@ -16,6 +16,7 @@ use horizon_client::{
},
mobs::{Mob, Species, Who, leash::Holder},
text::Text,
world::World,
};
use horizon_core::geometry::Vec3;
use horizon_protocol::{
@@ -23,32 +24,87 @@ use horizon_protocol::{
entity_data::{Value, serializer},
packets::play::{self, Clientbound as C},
};
use horizon_world::block_collision::BlockCollisionRegistry;
use std::{
collections::HashMap,
sync::{Arc, Mutex},
};
/// The registries and tags, loaded once per world (a world open or a
/// `/reload` forgets them, as `forget_sync_data` does).
static SYNC: Mutex<Option<std::result::Result<Arc<SyncData>, String>>> = Mutex::new(None);
/// What `sync_data` loaded: whether it was with data packs over the game's
/// data, and the registries and tags.
type Synced = (bool, std::result::Result<Arc<SyncData>, String>);
/// The registries and tags, loaded once per world (a world with packs open
/// or a `/reload` forgets them, as `forget_sync_data` does).
static SYNC: Mutex<Option<Synced>> = Mutex::new(None);
/// The registries and tags a server sends, loaded from `root` the first
/// time, with the world's data packs' recipes and advancements over them.
/// time, with the world's data packs' recipes, advancements and dialogs over
/// them. What was read with packs is read again for a world without them
/// (their dialogs would stay); a world with packs forgets what was read
/// before it at open (`finish_open_world`).
pub(super) fn sync_data(
root: &std::path::Path,
overlay: &horizon_world::loot::PackOverlay,
) -> std::result::Result<Arc<SyncData>, String> {
let packs = !overlay.files.is_empty();
let mut sync = SYNC.lock().unwrap_or_else(|e| e.into_inner());
sync.get_or_insert_with(|| SyncData::load(root, overlay).map(Arc::new))
if sync
.as_ref()
.is_some_and(|(with_packs, _)| *with_packs != packs)
{
*sync = None;
}
sync.get_or_insert_with(|| (packs, SyncData::load(root, overlay).map(Arc::new)))
.1
.clone()
}
/// Let go of what `sync_data` loaded: for a preload no world used
/// (game/world_loading.rs).
/// Let go of what `sync_data` loaded: a preload no world used
/// (game/world_loading.rs), what was read before a world with packs opened,
/// or before a `/reload` without a hub.
pub(super) fn forget_sync_data() {
*SYNC.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
/// `/reload` (MinecraftServer.reloadResources) under a running hub: the
/// tags, recipes and advancements read again with the world's packs, the
/// synchronized registries `kept` (the hub's) as they were, and that becomes
/// what `sync_data` gives.
pub(super) fn reload_sync_data(
root: &std::path::Path,
overlay: &horizon_world::loot::PackOverlay,
kept: &SyncData,
) -> std::result::Result<Arc<SyncData>, String> {
// Kept pack dialogs count as packs: a world opened without packs after
// a `/reload` that dropped them must read its own registries again.
let packs = !overlay.files.is_empty() || kept.has_pack_entries();
let sync = SyncData::load(root, overlay).map(|mut sync| {
sync.keep_registries(root, kept);
Arc::new(sync)
});
*SYNC.lock().unwrap_or_else(|e| e.into_inner()) = Some((packs, sync.clone()));
sync
}
impl Game<'_> {
/// The commands' `minecraft:dialog` ids as the clients get them in the
/// configuration phase (`sync`, the world's packs' dialogs among them):
/// `/dialog show` parses an id against these and sends its index.
pub(super) fn sync_dialog_ids(&mut self, sync: &SyncData) {
let Some(names) = sync.dynamic.registries.get("minecraft:dialog") else {
return;
};
let ids = names.iter().map(|(name, _)| name.clone()).collect();
self.ui
.registries
.by_registry
.entry("minecraft:dialog".to_owned())
.or_default()
.ids = ids;
}
}
/// The registries and tags a server sends without data packs over them, read
/// on a helper thread while a server's world prepares: most worlds have no
/// packs, so it is what `sync_data` would load once the world is open.
@@ -76,9 +132,10 @@ impl SyncPreload {
return;
}
if let Ok(read) = self.0.join() {
SYNC.lock()
.unwrap_or_else(|e| e.into_inner())
.get_or_insert_with(|| read.map(Arc::new));
let mut sync = SYNC.lock().unwrap_or_else(|e| e.into_inner());
if sync.as_ref().is_none_or(|(with_packs, _)| *with_packs) {
*sync = Some((false, read.map(Arc::new)));
}
}
}
}
@@ -427,7 +484,7 @@ fn mob_meta(
hub.effect_meta(
(m.extra.effects.iter())
.filter(|e| !m.extra.hidden_effects.contains(&e.0))
.map(|e| (e.0, false)),
.map(|e| (e.0, m.extra.ambient_effects.contains(&e.0))),
),
);
defaults.push((10, serializer::PARTICLES, Value::Particles(Vec::new())));
@@ -669,6 +726,13 @@ fn mob_meta(
));
}
}
// Phantom.ID_SIZE (after Mob's flags).
"minecraft:phantom" => {
if e.size > 0 {
meta.push((16, serializer::INT, Value::Int(e.size)));
}
defaults.push((16, serializer::INT, Value::Int(0)));
}
// AbstractCubeMob.ID_SIZE; SulfurCube also syncs FROM_BUCKET and
// MAX_FUSE (clients tick the fuse for the swell / white flash).
"minecraft:slime" | "minecraft:magma_cube" | "minecraft:sulfur_cube" => {
@@ -1072,7 +1136,11 @@ pub const HELP: &[&str] = &[
/// (setting a mode does nothing when it is already theirs). A world open to
/// LAN is an integrated server, which leaves out the players who have game
/// master permission; a dedicated server does not.
fn enforce_guest_game_mode(host: &mut LanHost) {
fn enforce_guest_game_mode(
host: &mut LanHost,
world: &World,
collision: &BlockCollisionRegistry<'_>,
) {
if !host.hub.config.force_game_mode {
return;
}
@@ -1086,7 +1154,7 @@ fn enforce_guest_game_mode(host: &mut LanHost) {
.map(|g| g.conn)
.collect();
for conn in stale {
host.hub.set_game_mode(conn, mode);
host.hub.set_game_mode(conn, mode, world, Some(collision));
}
}
@@ -1257,6 +1325,7 @@ impl Game<'_> {
host: Option<u128>,
) -> Result<()> {
let sync = sync_data(&self.options.resources, &self.pack_files())?;
self.sync_dialog_ids(&sync);
let biome_names: Vec<String> = self
.res
.biomes()
@@ -1286,6 +1355,11 @@ impl Game<'_> {
.get_or_init(|| Arc::new(horizon_client::save::SaveTables::new(&self.pack.assets)))
.clone(),
);
encoder.mod_update_tag = Some(|pos, entity| {
let mut tag = None;
Game::native_block_entity_update_tag(pos, entity, &mut tag);
tag
});
let lan = Lan::start_with(sync.clone(), net)?;
if let Some(uuid) = host {
lan.reserve(uuid);
@@ -1526,7 +1600,7 @@ impl Game<'_> {
host.hub.config.default_game_mode = mode;
// `setWorldGameType` and `DefaultGameModeCommands.setMode`: a
// world that forces its mode puts the guests online in it.
enforce_guest_game_mode(host);
enforce_guest_game_mode(host, &self.world, self.collision);
}
}
@@ -1544,7 +1618,7 @@ impl Game<'_> {
host.settings.force_game_mode = force;
host.hub.config.guest_commands = effective;
host.hub.config.force_game_mode = force;
enforce_guest_game_mode(host);
enforce_guest_game_mode(host, &self.world, self.collision);
host.hub.resend_commands();
}
}
@@ -1674,12 +1748,226 @@ impl Game<'_> {
self.lan_object_states(host_entity, hub, out);
}
/// Apply gameplay events produced while the hub handles guest packets.
///
/// Both the pre-block packet phase and the normal LAN tick receive the
/// same event stream. Keep their side effects in one place so joins,
/// departures, chat, and queued guest commands cannot drift apart.
fn lan_process_events(
&mut self,
mut host: LanHost,
events: impl IntoIterator<Item = HostEvent>,
state: Option<&HostState>,
entities: &[EntityState],
spawn: [f64; 3],
) -> LanHost {
for event in events {
match event {
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
self.lan_log_event(event);
}
HostEvent::PlayerChat { sender, text } => {
if let Some(g) = host.hub.guests.iter().find(|g| g.uuid == sender) {
if self.options.dedicated.is_some() {
super::dedicated::events::record(
"chat",
&g.name,
sender,
serde_json::json!({ "message": text }),
);
}
let conn = g.conn;
self.detect_rate_spam(&mut host, conn, false);
}
}
HostEvent::Joined(conn) => {
self.lan_guest_joined(&mut host, conn, state, entities, spawn);
if self.options.dedicated.is_some()
&& let Some(g) = host.hub.guest(conn)
{
// PlayerList.placeNewPlayer, then the broadcast join message.
super::dedicated::player_joined(g.uuid, &g.name);
super::dedicated::events::record(
"join",
&g.name,
g.uuid,
serde_json::json!({ "address": g.address.ip().to_string() }),
);
let [x, y, z] = g.position;
super::dedicated::log(
"INFO",
&format!(
"{}[/{}] logged in with entity id {} at ({}, {}, {})",
g.name,
g.address,
g.entity_id,
super::dedicated::java_double(x),
super::dedicated::java_double(y),
super::dedicated::java_double(z)
),
);
super::dedicated::log(
"INFO",
&format!("System chat: {} joined the game", g.name),
);
} else if self.player.is_some()
&& let Some(g) = host.hub.guest(conn)
&& g.placed
{
// The host's chat hears of the newcomer as the guests do.
let who = self.player_text(&g.name, g.uuid);
self.system_message(
Text::translate("multiplayer.player.joined", &[who])
.with_color(horizon_client::text::YELLOW),
);
}
}
HostEvent::Left(mut guest) => {
// The container they had open loses an opener.
if let Some(pos) = guest.menu.as_ref().and_then(|m| m.pos)
&& !guest.spectator()
{
// 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);
if guest.placed {
host.award_leave_game(guest.conn);
}
self.save_guest(&host, &guest);
host.models.remove(&guest.conn);
host.play.remove(&guest.conn);
self.guest_combat.remove(&guest.conn);
if self.options.dedicated.is_some() && guest.placed {
super::dedicated::events::record(
"leave",
&guest.name,
guest.uuid,
serde_json::json!({ "reason": guest.left_reason }),
);
super::dedicated::log(
"INFO",
&format!("{} lost connection: {}", guest.name, guest.left_reason),
);
super::dedicated::log(
"INFO",
&format!("System chat: {} left the game", guest.name),
);
} else if self.player.is_some() && guest.placed {
// ServerGamePacketListenerImpl.removePlayerFromWorld.
let who = self.player_text(&guest.name, guest.uuid);
self.system_message(
Text::translate("multiplayer.player.left", &[who])
.with_color(horizon_client::text::YELLOW),
);
}
}
HostEvent::Packet(conn, packet) => {
self.lan_guest_packet(&mut host, conn, *packet);
if !host.commands.is_empty() {
self.lan = Some(host);
self.run_guest_commands();
host = self.lan.take().expect("the hub is back after the commands");
}
}
}
}
host
}
/// Handle this level's player packets before scheduled block ticks
/// (`ServerGamePacketListenerImpl` ahead of `ServerLevel`'s block phase).
/// A sand place in the same tick as another sand's fall schedule still
/// sees the support (#4510). `tick_lan` then syncs the level; a paused
/// tick that never calls this still drains inside `tick_lan`.
pub(super) fn tick_lan_packets(&mut self) {
if self.level_pass.packets_drained {
return;
}
let Some(mut host) = self.lan.take() else {
return;
};
let holding = lan::Holding::start();
let state = self.lan_host_state(&host);
if state.is_none() && !host.hub.config.dedicated {
self.lan = Some(host);
self.level_pass.packets_drained = true;
return;
}
// Last tick's tracked entities: enough for place collision and joins
// before this tick moves anyone.
let entities = std::mem::take(&mut host.states);
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
let events = {
let clock_rate = self.network_clock_rate();
let mut ctx = TickCtx {
world: &mut self.world,
host: state.as_ref(),
entities: entities.as_slice(),
next_entity: &mut self.next_entity,
spawn,
difficulty: i32::from(self.ui.difficulty),
difficulty_locked: self.level.difficulty_locked,
clock_rate,
tick_rate: self.clock.rate(),
tick_frozen: self.clock.frozen(),
tick_steps: self.clock.step_state(),
collision: Some(self.collision),
border: &self.border,
streaming: Some(&self.streaming),
};
host.hub.movement_checks = (
self.ui.rules.bool("player_movement_check"),
self.ui.rules.bool("elytra_movement_check"),
);
let pass = self.level_pass;
let mut events = if pass.first {
host.hub.poll(&mut ctx)
} else {
Vec::new()
};
events.extend(host.hub.drain_packets(&mut ctx));
events
};
host = self.lan_process_events(host, events, state.as_ref(), entities.as_slice(), spawn);
if !host.quiet.is_empty() {
self.lan = Some(host);
self.run_quiet_commands();
host = self
.lan
.take()
.expect("the hub is back after the quiet commands");
}
if !host.hub.native_menu_events.is_empty() {
self.lan = Some(host);
self.run_native_menu_events();
host = self
.lan
.take()
.expect("the hub is back after the menu events");
}
host.states = entities;
self.level_pass.packets_drained = true;
drop(holding);
self.lan = Some(host);
}
/// Run the hub for this tick and apply what the guests did. While the
/// single player game is `paused` only the connections run
/// (`IntegratedServer.tickPaused`): `ServerGamePacketListenerImpl.tick`
/// skips `tickPlayer`, so no player's air, hunger or damage moves on.
pub(super) fn tick_lan(&mut self, paused: bool) {
let Some(mut host) = self.lan.take() else {
if self.level_pass.running.is_none() {
self.level_pass.packets_drained = false;
}
return;
};
// What the tick sends a guest goes to its connection at the end.
@@ -1786,12 +2074,17 @@ impl Game<'_> {
.set_immediate_respawn(self.ui.rules.bool("immediate_respawn"));
// A server with several levels runs a pass for each
// (`tick_server`): the network is read once, before the first.
// Player packets may already have been drained before
// scheduled block ticks (`tick_lan_packets`).
let pass = self.level_pass;
let mut events = if pass.first {
let mut events = if pass.first && !pass.packets_drained {
host.hub.poll(&mut ctx)
} else {
Vec::new()
};
if !pass.packets_drained {
events.extend(host.hub.drain_packets(&mut ctx));
}
events.extend(host.hub.tick_level(&mut ctx));
if pass.last {
host.hub.end_tick();
@@ -1804,109 +2097,7 @@ impl Game<'_> {
host.host_mode = state.game_mode;
host.hub.set_host_game_mode(state.uuid, state.game_mode);
}
for event in events {
match event {
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
self.lan_log_event(event);
}
HostEvent::PlayerChat { sender, .. } => {
if let Some(conn) = host
.hub
.guests
.iter()
.find(|g| g.uuid == sender)
.map(|g| g.conn)
{
self.detect_rate_spam(&mut host, conn, false);
}
}
HostEvent::Joined(conn) => {
self.lan_guest_joined(&mut host, conn, state.as_ref(), entities, spawn);
if self.options.dedicated.is_some()
&& let Some(g) = host.hub.guest(conn)
{
// 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",
&format!(
"{}[/{}] logged in with entity id {} at ({}, {}, {})",
g.name,
g.address,
g.entity_id,
super::dedicated::java_double(x),
super::dedicated::java_double(y),
super::dedicated::java_double(z)
),
);
super::dedicated::log(
"INFO",
&format!("System chat: {} joined the game", g.name),
);
} else if self.player.is_some()
&& let Some(g) = host.hub.guest(conn)
&& g.placed
{
// The host's chat hears of the newcomer as the guests do.
let who = self.player_text(&g.name, g.uuid);
self.system_message(
Text::translate("multiplayer.player.joined", &[who])
.with_color(horizon_client::text::YELLOW),
);
}
}
HostEvent::Left(mut guest) => {
// The container they had open loses an opener.
if let Some(pos) = guest.menu.as_ref().and_then(|m| m.pos)
&& !guest.spectator()
{
// 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);
if guest.placed {
host.award_leave_game(guest.conn);
}
self.save_guest(&host, &guest);
host.models.remove(&guest.conn);
host.play.remove(&guest.conn);
self.guest_combat.remove(&guest.conn);
if self.options.dedicated.is_some() && guest.placed {
super::dedicated::log(
"INFO",
&format!("{} lost connection: {}", guest.name, guest.left_reason),
);
super::dedicated::log(
"INFO",
&format!("System chat: {} left the game", guest.name),
);
} else if self.player.is_some() && guest.placed {
// ServerGamePacketListenerImpl.removePlayerFromWorld.
let who = self.player_text(&guest.name, guest.uuid);
self.system_message(
Text::translate("multiplayer.player.left", &[who])
.with_color(horizon_client::text::YELLOW),
);
}
}
HostEvent::Packet(conn, packet) => {
self.lan_guest_packet(&mut host, conn, *packet);
if !host.commands.is_empty() {
self.lan = Some(host);
self.run_guest_commands();
host = self.lan.take().expect("the hub is back after the commands");
}
}
}
}
host = self.lan_process_events(host, events, state.as_ref(), entities, spawn);
host.states = states;
}
if !host.quiet.is_empty() {
@@ -1933,10 +2124,24 @@ impl Game<'_> {
.take()
.expect("the hub is back after the menu events");
}
self.lan_flush_effects(&mut host);
if !paused {
self.tick_guests(&mut host);
}
self.lan = Some(host);
// A blast from a guest's attack, and a mace's wind burst.
self.flush_lan_explosions();
let _ = self.flush_wind_bursts();
// A sweep attack's reach to the other players from a guest's attack.
self.flush_sweeps();
// A reeled-in hook pulling a player.
self.flush_hook_pulls();
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
self.flush_lan_particles();
let mut host = self
.lan
.take()
.expect("the hub is back after the guest flushes");
self.lan_flush_effects(&mut host);
self.lan_living_flags(&mut host);
self.lan_stuck(&mut host, paused);
self.tick_server_travel(&mut host);
@@ -1951,16 +2156,12 @@ impl Game<'_> {
self.flush_native_net(&mut host);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
// A blast from a guest's attack, and a mace's wind burst.
self.flush_lan_explosions();
let _ = self.flush_wind_bursts();
// A sweep attack's reach to the other players from a guest's attack.
self.flush_sweeps();
// A reeled-in hook pulling a player.
self.flush_hook_pulls();
// Particles a guest's turn sent (sweep attack, damage indicator, a hook's splashes).
self.flush_lan_particles();
self.flush_native_sounds();
// `tick_server` resets the pass after all dimensions. The deprecated
// in-frame path has no outer pass, so make its packet gate per-tick.
if self.level_pass.running.is_none() {
self.level_pass.packets_drained = false;
}
}
fn lan_guest_joined(
@@ -2273,38 +2474,26 @@ impl Game<'_> {
}
seen.insert(m.id, (m.hurt_time, dead, swing));
}
// LivingEntity.hurtServer for a player: a hit that restarted the hurt
// time and was not blocked by a shield broadcasts the damage event,
// so the clients around (and the player's own) flash it red.
let host_player = self.player.as_ref().map(|p| {
(
host.host_entity,
[p.position.x, p.position.y, p.position.z],
p,
)
});
let guests = host.hub.guests.iter().filter(|g| host.hub.here(g));
let players: Vec<_> = host_player
.into_iter()
.chain(guests.filter_map(|g| g.motion.as_deref().map(|m| (g.entity_id, g.position, m))))
.map(|(id, at, p)| {
(
id,
at,
p.health.hurt_animation_time,
p.health.dead_or_dying(),
p.hurt_log.last(),
)
})
.collect();
for (id, at, hurt, dead, record) in players {
// LivingEntity.hurtServer for the in-frame LAN host's player (guests
// broadcast directly from HealthEvent::DamageBroadcast): a hit that
// restarted the hurt time and was not blocked by a shield broadcasts
// the damage event, so the clients around flash it red.
if let Some(p) = self.player.as_ref() {
let id = host.host_entity;
let at = [p.position.x, p.position.y, p.position.z];
let hurt = p.health.hurt_animation_time;
let dead = p.health.dead_or_dying();
let old = host
.mob_state
.get(&self.dim)
.and_then(|s| s.get(&(id as u32)));
if hurt > old.map_or(0, |o| o.0) && !dead {
// Starving keeps no record: any type flashes the same.
let kind = record.map_or("minecraft:generic", |r| r.source.as_str());
let kind = p
.health
.hurt
.last_source
.and_then(|s| self.motion.health.damage.definition(s))
.map_or("minecraft:generic", |d| d.name.as_str());
host.hub.damage_event(id, kind, None, None, at);
}
seen.insert(id as u32, (hurt, dead, None));
@@ -2343,11 +2532,12 @@ impl Game<'_> {
// covers what the player tracks (`ChunkTrackingView`, within its view
// distance + 1) and those chunks' lit neighbours.
let radius = host.hub.config.view_distance + 1;
let rule = self.ui.rules.bool("spectators_generate_chunks");
self.streaming.extra = host
.hub
.guests
.iter()
.filter(|g| host.hub.here(g))
.filter(|g| host.hub.here(g) && super::game_mode::loads_chunks(rule, g.spectator()))
.map(|g| (g.chunk(), radius))
.chain(
self.arrival_chunks(self.dim)
Loaded 100 of 408 files, more files were not shown because too many files have changed in this diff. Show more