Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 5672f41859 LAN hub: one S::Attack arm (main's clippy fails on an unreachable pattern)
#5123 added a second S::Attack arm (break behind a queued move) after the
anticheat's, so it never ran and clippy -D warnings fails on main. The
anticheat's arm now also breaks when a move is queued behind the attack.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:20:10 +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
Ludovic Marie 976e3f3858 Merge pull request #5094 from horizon-rust/fix/launcher-test-real-temp-dir
🤖 Agent: test-only fix for main's red macOS launcher job (since b49925118): the resource-pack tests run under the temp dir with its links resolved. pr_ready --quick green (port-pack 97, launcher 305 tests). CI green. Still needs main merged in: no conflict. The symlinked-home case in resourcepacks::directory is left to gravitacia and levzzz5154 (PR body).
2026-10-31 15:47:20 +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 Marie af55a73486 Merge pull request #4271 from horizon-rust/perf/dedicated-no-item-models
🤖 Agent: a dedicated server skips the item models. Main merged in (bcbf367fc, clean). pr_ready --quick green. CI green; parity passed on the second rerun. Earlier runs failed mob_ai, remote_fireball and lunge_motion once each, a different set each time, and all three pass on this head locally (mob_ai 237/237, remote_fireball 2/2, lunge_motion 3/3). rdbtCVS approved.
2026-10-31 15:45:20 +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
Ludovic Marie 125875b844 Merge pull request #5092 from mulareal/fix-integrated-player-save-5065
🤖 Agent: Verified in the real game: on main, a single-player world (server thread) comes back at spawn with an empty inventory after Save and Quit; with this PR merged into main, position and inventory are kept on the server thread and in-frame, and a dedicated server keeps a player's position and inventory over leave/rejoin and a server restart. The regression check is #5105 (lan_e2e reopen_player: fails on main, passes with this). Thanks @mulareal!
2026-10-31 15:22:00 +01: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
Ludovic Marie 68ac6f647e Merge pull request #5165 from horizon-rust/server-admin/config-backups
🤖 Agent: verified: pr_ready green, CI green (retargeted to main after #5163 merged), tools/server_admin_e2e.py passed with the API on server.toml's fixed [api] address and token (live difficulty change, engine.path refused, backup made, downloaded byte for byte and deleted).
2026-10-31 14:59:11 +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 Marie 57aa58c8f9 Merge pull request #5163 from horizon-rust/server-admin/api
🤖 Agent: verified: pr_ready green, CI green, tools/server_admin_e2e.py passed against a real dedicated server (players, op/deop, whitelist, ban/pardon by name and IP, kick, Prometheus). Base of the server-admin stack (#5165, #5166 follow).
2026-10-31 14:53:43 +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
Ludovic MarieandClaude Opus 5.5 d9c733419c horizon-server: config and backups in the control API, and a fixed address and token
GET /api/v1/config shows server.toml's settings (the API token hidden);
PATCH /api/v1/config changes them as `config set` does, live where vanilla
can, but never [engine], [panel], [api] or the RCON, query and management
keys. DELETE /api/v1/backups?name= and GET /api/v1/backups/download?name=
(streamed from the file). server.toml's new [api] section fixes the API's
address and token for a program that runs elsewhere, such as a moderation
bot; loopback or tailnet only unless allow_public. Panel changes of any
method need the X-Horizon header.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:37:44 +01:00
Ludovic MarieandClaude Opus 5.5 c50f97ec8f horizon-server: players, moderation and Prometheus metrics in the control API
GET /api/v1/players lists who is online (address, ping, op level) and vanilla's
lists; POST players/{kick,ban,pardon,ban-ip,pardon-ip,op,deop} and
whitelist[/add|/remove] run vanilla's console command on the running game and
answer whether the list changed and the console's lines; GET
metrics/prometheus serves the latest sample as Prometheus text.
tools/server_admin_e2e.py runs it all against a real server with two bots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:36:38 +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
levzzz 27cb8f96bf Merge pull request #3891 from levzzz5154/launcher/native-mods-where
Launcher: show where native mods are installed and what each instance's build plays
2026-10-31 18:26:08 +05: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
Ludovic Marie c7dcaf2b7d Merge pull request #4257 from horizon-rust/perf/dedicated-no-face-visibility
🤖 Agent: a dedicated server skips the face visibility (19 MB). Main merged in (a672a51eb): the skip moved into main's load_core visibility worker, and PackResources::load's dead draws parameter was dropped. pr_ready --quick green. CI green; parity passed on the rerun. The first run's tab_list (4/1000), lunge_motion and remote_fireball failures didn't reproduce: tab_list matches 1000/1000 locally on the same merged tree. rdbtCVS approved the pre-merge head (dismissed by the push).
2026-10-31 14:19:53 +01: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
Ludovic Marie bd541458cd Merge pull request #1777 from horizon-rust/feat/lithostitched-elements
🤖 Agent: Lithostitched limited/guaranteed jigsaw elements beside main's YUNG rules. rdbtCVS's change request (a fallback pool's guaranteed entries were never tried) is fixed and approved at c556f5b5b. Main merged in (clean). CI green, parity included. pr_ready: only main's 8 macOS resource-pack tests fail (#5094).
2026-10-31 13:57:53 +01: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
Ludovic Marie 01bbb4c654 Merge pull request #4276 from horizon-rust/perf/dedicated-no-client-tables
🤖 Agent: a dedicated server skips the block entity renderer and animate tables. Main merged in (d7c6e36cb): the skip moved into main's load_world_rest worker closures. pr_ready --quick green. CI green; parity passed on the rerun (head merged into main). The first run's world_spawn#4 was vanilla's documented spawn-height variance on seed -2080727421395798566; vanilla and Horizon both give y 72 locally, 3 runs out of 3. Reviews: DUzzL and dimaniojk LGTM.
2026-10-31 13:48:07 +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
Ludovic Marie 38a97ea64e Merge pull request #4279 from horizon-rust/perf/shared-loot-tables
🤖 Agent: shared loot tables per root and packs. rdbtCVS's change request (the cache key ignored root file changes) was fixed in fc4641bc8, and rdbtCVS approved it 2026-10-31. Main merged in (b546ffb87, clean). pr_ready --quick green; CI green, parity included (head merged into main c3905131b).
2026-10-31 13:44:17 +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
levzzz 2abd1ac013 Merge main into launcher/native-mods-where 2026-10-31 17:22:54 +05:00
levzzz 36f4bc00d6 Merge pull request #3870 from levzzz5154/launcher/native-builds-stay-put
Launcher: native rebuilds stay on the instance's Horizon, in its instance, fast
2026-10-31 17:21:22 +05: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
Ludovic Marie 3c68c05d0a Merge pull request #1171 from horizon-rust/feat/macos-x64
🤖 Agent: macOS on Intel (x86_64 game and launcher builds), approved by Ludovic. Main merged in (13fd2ee72, README conflict only). rdbtCVS's review is addressed in 7f99b2e8c: the install smoke test skips only for macos-x64 and only on a parsed list with no build; a failing launcher or script fails the step (5 cases checked locally). The Intel Rust-toolchain limit for native mods is documented. CI green (verify, tests, clippy, launcher). pr_ready: only main's macOS resource-pack test fails (#5094).
2026-10-31 12:47:21 +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
Ludovic MarieandClaude Opus 5.5 7f99b2e8cc macOS on Intel: only the Intel launcher may skip its install smoke test
rdbtCVS's review: the "no build yet" skip covered every platform and every
failure (a launcher that failed four times, a script error). Now only the
macos-x64 entry skips, and only on a version list that parsed and had no
build for it (exit 3); anything else fails the step as before. Plus the
known limit in docs/native-mods.md: no Rust toolchain for Intel Macs yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:27:00 +01: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
Ludovic Marie c556f5b5b8 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-31 12:19:25 +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
Ludovic Marie b546ffb877 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-31 12:13:44 +01:00
Ludovic MarieandClaude Opus 5.5 a6f30aa763 Resource-pack tests: run under the temp dir with its links resolved
resourcepacks::directory refuses a link anywhere in a destination's
ancestry. macOS's temp dir is under /var, a link to /private/var, so 7
horizon-port-pack tests and the launcher's
resource_only_profile_can_play_without_enabling_or_overwriting_packs
failed on every Mac, and main's launcher job fails on macOS since
b49925118.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:10:26 +01:00
Ludovic Marie bcbf367fc6 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-31 12:08:34 +01: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
mularealandAntigravity 65d4d06ec5 Fix single-player position and inventory saving on the integrated server (#5065)
Co-Authored-By: Antigravity <[email protected]>
2026-10-31 11:57:07 +01:00
Ludovic Marie d7c6e36cb9 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-31 11:56:07 +01:00
Ludovic Marie a672a51ebd Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-31 11:55:33 +01:00
Ludovic Marie 13fd2ee724 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	README.md
2026-10-31 11:54:10 +01:00
Ludovic Marie c3905131bf Merge pull request #5074 from horizon-rust/feat/anticheat-prediction-vanilla
🤖 Agent: proven. tools/anticheat_e2e.py --check prediction-vanilla: the vanilla 26.3 client behind 20-120 ms jitter never flagged in 6/6 runs (before these fixes 5/6 flagged); --check prediction (Horizon client) 3/3 clean, bot flagged and kicked; pr_ready green (1340 tests); server_knockback, dedicated_server, server_entities pass; 60 bots MSPT 4.08 off / 4.39 on. CI green (lunge_motion failed once, known flake, passes locally on this head and on rerun).
2026-10-31 11:53:04 +01:00
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
Ludovic MarieandClaude Opus 5.5 d7d0ead29d Anticheat prediction: the vanilla client on a jittery connection, never flagged
`tools/anticheat_e2e.py --check prediction-vanilla` runs the real vanilla 26.3 client
(tools/vanilla_client.py) against a server with the prediction on, through the 20-120 ms jitter
relay. VanillaMover (mods/horizon/src/test/java) wraps vanilla's Main unchanged and presses its key
mappings from stdin, so every move is vanilla's own movement code. It walks, sprint-jumps, strafes
and sneaks, with Speed given and cleared, over packed ice, swims, walks into cows, jumps at a block
set in front of it and glides with an elytra and a firework. It runs only when asked: it needs a
screen session.

It found four replay gaps, which a Horizon client hid because it runs the replay's own code:

- ClientPacketListener.handleUpdateAttributes replaces the client's modifiers with the server's,
  its sprint modifier included, and the client goes on sprinting as it was. The replay set the
  body's sprinting to the server's and then back to the client's, which put the modifier back: a
  sprint-jump after a teleport landed 0.03 blocks off. It now takes the attributes and keeps the
  client's sprinting.
- The changes a teleport's accept came after were dropped by the restart when the ticks before it
  hadn't seen them (an effect cleared: the replay kept Speed). Replay::finish applies them: the
  client had them all when it accepted the teleport.
- Under an excuse the replay can jump where the client couldn't (on a block it hadn't received),
  so the jump delay is unknown for the ticks after: a jump held then is played both ways
  (candidate 4).
- A teleport that comes while the replay is excused starts the replay excused too, so it takes
  the client's velocity until they agree.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 11:10:14 +01:00
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
Ludovic Marie 13ce6bcc64 Merge pull request #5041 from horizon-rust/feat/anticheat-prediction-e2e
🤖 Agent: prediction (d) verified: pr_ready green (1340 tests), anticheat_e2e all four checks pass on main+#5012, the prediction check 3/3 (real client behind 20-120 ms jitter never flagged, bot flagged and kicked); step/spider/jesus unit tests pass; tests and tools only.
2026-10-31 10:28:16 +01:00
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
Ludovic MarieandClaude Opus 5.5 807a818395 Anticheat prediction (d): e2e with a real client on a jittery connection, step/spider/jesus tests
`tools/anticheat_e2e.py --check prediction` starts a dedicated server with `[anticheat]` on and
connects a real Horizon client through a relay that holds every packet 20-120 ms, in order. The
client walks, sprint-jumps, strafes and sneaks, with Speed and Jump Boost given and cleared, over
packed ice and soul sand, swims, launches with riptide, walks into cows that are then killed, has
blocks changed in front of and under it, flies in creative, glides with an elytra and a firework,
and rides a boat: the check fails if it is ever flagged. A horizon-bots walker, which moves by its
own rules and sends no keys, must be flagged and sent back.

Unit tests: a step up a full block, a spider up a wall and a jesus on water each count from the
tick they start, and nothing before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 10:06:44 +01:00
Ludovic Marie 51c9565411 Merge pull request #5012 from horizon-rust/feat/anticheat-prediction-check
🤖 Agent: prediction (c) verified: pr_ready green (1353 tests), server_knockback/dedicated_server/server_entities pass, anticheat e2e off/timer/ground pass; a real client behind 20-120 ms jitter never flagged in 8/8 runs while a bot is flagged and sent back; bench MSPT off 4.52/4.49, on 4.63/4.94 (60 bots); off by default.
2026-10-31 10:06:30 +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
Ludovic MarieandClaude Opus 5.5 71d7921132 Anticheat prediction (c): flag moves off the replay, set back to where they agreed
A move that lands farther than `[anticheat.prediction] tolerance` (0.03 blocks) from the replay
flags, weighing its offset in tolerances (at most 5 a tick); the setback sends the player to where
its moves last agreed. What the replay can't play yet excuses the tick and the ones after until the
moves agree again (40 ticks at most): another entity or player near (farther for a fast player), a
block change or an entity removal near the player that the client hadn't seen, a block change the
server hasn't sent, abilities the client hadn't seen, a moving piston, a chunk the client doesn't
have, a riptide launch, a teleport that keeps motion. Under an excuse the replay takes the client's
velocity, so it agrees again within a few ticks.

The replay now also nudges the player out of a block it stands in (LocalPlayer
.moveTowardsClosestSpace, worked out from the blocks as the game does for its own player), keeps
its body across setbacks, and plays the ticks that came in a burst with a teleport's accept before
starting again there, so their jump delay goes on through it.

The bot harness checks and the bench keep the prediction from flagging the bots, which move by
their own rules.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 09:29:57 +01: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
rdbt 9d979caa48 Merge pull request #5019 from rdbtCVS/perf/preload-world-at-launch
Preload the world data of a launch that names a world
2026-10-31 07:45:52 +00:00
rdbtCVSandClaude Opus 5.5 09a4cd9baf Merge main into perf/preload-world-at-launch
Main split the resource load into load_core; the launch world's preload now
starts once the core tables (and so the biomes) are in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 07:40:27 +00:00
Dufru 34f24116bf Fix Brazilian Portuguese locale and add missing translations 2026-10-31 04:39:57 -03:00
rdbt c18ed2db67 Merge pull request #5020 from rdbtCVS/perf/server-thread-overlap 2026-10-31 07:37:17 +00:00
rdbt c41a8d0d96 Merge pull request #5018 from rdbtCVS/perf/parallel-world-rest 2026-10-31 07:36:54 +00:00
rdbt fc680ec318 Merge pull request #5015 from rdbtCVS/perf/parallel-core-resources 2026-10-31 07:36:51 +00:00
rdbt c535285302 Merge pull request #5013 from rdbtCVS/t3/adaptive-server-view-distance 2026-10-31 07:36:38 +00: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
rdbt 763528025d Merge pull request #4962 from MSedgeMC/fix/candle-snuff
Pass flint and steel over a lit candle cake, and snuff through one path
2026-10-31 07:15:45 +00:00
My Name b8b6196026 Melee damage reads the attack_damage attribute 2026-10-31 10:14:17 +03:00
rdbtCVSandClaude Opus 5.5 5bd13dad80 Send saved chunks past the view distance (max_view_distance)
A dedicated server's new `max_view_distance` (server.toml, off by default)
sends each player, past `view_distance`, the chunks the world has saved:
read on two reader threads, sent and dropped, never generated, loaded or
ticked. Bodies sent are kept five minutes (96 MB) for players walking back,
dropped when their column changes. A brake pulls the ring in by two chunks
while ticks average over 40 ms, never inside the view, and lets it out by
one every 5 s after 30 s under 25 ms; reads pause while the save's writer
is behind. The docs point at /pregen to fill the ring.

Fixes #5008

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 07:13:51 +00:00
rdbt 03fc2d3f2d Merge pull request #4990 from MSedgeMC/fix/villager-age-boundary
Run a mob's age boundary when it grows up, not only when it spawns
2026-10-31 07:11:07 +00: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
levzzz 1dfea0b84e Merge pull request #4669 from Qwerner88880/hooks-clean
Native mods: mod messages and event hook points (API next)
2026-10-31 12:05:00 +05:00
rdbtCVSandClaude Opus 5.5 eae1e21bd0 Load the world-rest tables in parallel
load_world_rest built its tables one after another on the world-rest-loader
thread (~550 ms warm). Only the recipes and the stations read another table
(the items), so the items chain, the block behaviours, the block entity
renderer, the animate and fog tables, the mob types and data, and the
transformers with the player models now load together in a thread scope,
within the worker budget (sequential when it is one thread). The rest
chain becomes its longest path: behaviours, or items -> recipes -> stations.

Closes #5000

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 07:02:33 +00:00
levzzzandClaude Sonnet 5.5 97b2e10df5 Merge hr/main; renumber mod messages and event hooks to API 40; not-joined error first, send allowance per server tick, sweep and spear kills
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 11:59:21 +05:00
rdbtCVSandClaude Opus 5.5 5bda60aa5d perf(server-thread): prepare the world beside the server's Game
Opening a single-player world built the server thread's own Game, then
prepared the world, then read the registries the server sends, one after
the other. None reads what another makes: the world now prepares on a
helper thread (WorldLoader) while the server thread builds its Game, and
the registries without data packs are read on another; a world without
packs adopts them, one with packs loads its own as before. The dedicated
server reads the registries beside its world's preparation too.

Closes #5001

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:56:46 +00:00
levzzz 3bffd9b52e Merge pull request #4677 from Qwerner88880/hooks-items
Native mods: hook points for item behaviour (API next)
2026-10-31 11:56:33 +05:00
rdbtCVSandClaude Opus 5.5 a648bef38c Load the core tables beside each other: the menu bundle, the block models and the block facts no longer wait in line
load_resources built the world bundle's core on one thread: the menu bundle,
then the registry, the block models and sprites, the block facts, the light
tables, the pack tables and the collision and motion tables, one after the
other. Most of them only read the registry. Now the menu bundle loads beside
load_core; past the registry, the block models (then the item models, which
share their parsed models), the face visibility, the collisions, the biomes
and the environment load beside the block facts, and the light tables beside
the motion tables once the facts are in. Only the mesh tables wait for all
of them. A pack failure in any of them still rolls back to the game's own
resources, and a single worker setting still loads them in turn.

Closes #4999

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:56:29 +00:00
rdbtCVSandClaude Opus 5.5 e3b213f8f9 Preload the world data of a launch that names a world
`--world` and `--quickPlaySingleplayer` launches paid the worldgen bundle,
the Overworld's decoration and the loot tables (~0.5-0.8 s) inside
`WorldRequest::prepare`: `preload_world_data` only runs on the world
screens. Such a launch now starts the same preload as soon as the block
registry and the biomes are loaded, for the world's own data packs and
preset, so the opening finds them in the cache. A world they don't fit
reads its own data when it opens, as before.

Closes #4998

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:56:01 +00: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
rdbt 530a1253e6 Merge pull request #4424 from apeirs/fix-merchant-space-reselect
Trading: Space and Enter press the focused offer again (hold Space to refill)
2026-10-31 06:52:29 +00:00
levzzzandClaude Sonnet 5.5 ae45f040b7 Merge hr/main; renumber item behaviour hooks to API 39
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 11:52:01 +05: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
levzzz 3d1b94f97c Merge pull request #4725 from Qwerner88880/hooks-signal
Native mods: hook points for redstone signals from a mod's block (API next)
2026-10-31 11:50:18 +05:00
levzzzandClaude Sonnet 5.5 57bee439bb Merge hr/main; renumber redstone signal hooks to API 38; native_update_signal_neighbors takes a dimension
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 11:44:40 +05:00
levzzz 367d519e3c Merge pull request #4799 from Qwerner88880/hooks-mobs
Native mods: helpers for spawning and moving mobs (API next)
2026-10-31 11:42:30 +05: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
levzzzandClaude Sonnet 5.5 13aecbe1e5 Merge hr/main; renumber mob helpers to API 37; dimension argument, verb-first names, /summon bounds, /tp dismount, readers gated, removed mobs unsaved
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 11:37:26 +05: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
rdbt 7aa0e28fae Merge pull request #4419 from DUzzL/fix/issue-4404-wind-burst-armor-stand
Run the mace weapon hit on armour stands
2026-10-31 06:35:04 +00:00
levzzz 202e37981f Merge pull request #4838 from Qwerner88880/hooks-recipes
Native mods: recipes of a mod's own types (API next)
2026-10-31 11:34:53 +05:00
rdbt 6ee48d0e47 Merge pull request #4961 from MSedgeMC/fix/knowledge-book-unlock
Make a knowledge book teach its recipes
2026-10-31 06:30:10 +00:00
levzzzandClaude Sonnet 5.5 836ea22364 Merge hr/main; renumber recipe helpers to API 36; guard outside a world, read bare ids, test loading
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 11:29:48 +05:00
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
Ludovic Marie 877d0dac10 Merge pull request #5003 from horizon-rust/feat/anticheat-prediction-history
🤖 Agent: verified: pr_ready green except #4662's known test (fails on main with .local); server_knockback, dedicated_server, server_entities pass; a real Horizon client replays 638/638 ticks with offset 0.0 (effects given and cleared); anticheat stays off by default; CI tests, clippy and verify pass.
2026-10-31 07:28:13 +01: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
levzzz 94b3b4aca5 Merge pull request #4893 from sirrayi/fix/mod-commands-chat
Chat: native mods' commands join the tree the chat colours and completes
2026-10-31 11:25:32 +05:00
levzzzandClaude Sonnet 5.5 73958d8397 Merge hr/main; renumber native_commands to API 35; leave a name the game has alone
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 11:19:52 +05:00
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
levzzz 37ad1f5c9e Merge pull request #4880 from reservedbytes/feat/native-scheduled-tick
feat(mods): native mods can change their blocks on scheduled ticks
2026-10-31 11:17:58 +05:00
rdbt 73db376775 Merge pull request #4994 from horizon-rust/ci/parallel-verify
CI: run verify's checks as three parallel jobs (~8 min → ~4 min)
2026-10-31 06:17:31 +00:00
Ludovic MarieandClaude Opus 5.5 84f633b56b Anticheat prediction (b2): replay what the server sent, on the tick the client took it
What the server sends a player that changes how its client moves goes
into the record with the server tick that sent it: a velocity
(SetEntityMotion for the player itself, as the client decodes its
LpVec3), an explosion's knockback (added), and attributes and effects
(UpdateAttributes, mob effects). Guest::send notes them while recording;
the tick's ping places them. The replay applies each before the first
client tick whose pong had seen that server tick, which is when the
client handled it. A client that ignores a push (anti-knockback) is off
from that tick on. The attributes and effects are copied when they
change, no longer every tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 07:17:26 +01:00
rdbt a3c5fe138e Merge pull request #4964 from MSedgeMC/fix/moving-piston-drops
Drop the block a moving piston carries when something destroys it
2026-10-31 06:15:38 +00:00
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
levzzzandClaude Sonnet 5.5 4db1495bfb Merge hr/main; renumber scheduled-tick hook to API 34
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 11:09:05 +05:00
levzzz ed5565713e Merge pull request #4872 from reservedbytes/feat/native-random-tick
feat(mods): native mods can change their blocks on random ticks
2026-10-31 11:06:56 +05:00
Ludovic Marie 90994feaca Merge pull request #4989 from horizon-rust/feat/anticheat-prediction-replay
🤖 Agent: prediction (b), the replay, off by default, flags nothing. Verified: a real Horizon client on a dedicated server, 262 ticks walking, sprint-jumping, strafing and sneaking all replayed to 1e-6; lan::prediction tests (honest exact, 1.3x speed and fly off); pr_ready --quick (only main's #4662 test fails); auto_jump, lunge_motion, mace, trident, server_knockback, dedicated_server, server_entities pass. Cost 5.6 us per player tick when on, 0 off.
2026-10-31 07:04:30 +01: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
rdbtCVSandClaude Opus 5.5 f2a99d6238 CI: run verify's checks as three parallel jobs
verify ran the public-tree check, the Python tests, clippy and the Rust tests one
after another (~8 min). They now run as the python, clippy and tests jobs at once,
and verify gathers them (always(), failing unless all three passed), so the
required check keeps its name.

A pull request's public-tree check reads only its own commits
(check_public_tree.py --range base..head) instead of every ref's whole history;
main's pushes still run --history.

Closes #4978

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:03:29 +00:00
levzzzandClaude Sonnet 5.5 e776e8e94a Merge hr/main; renumber random-tick hook to API 33; guard the Game property writers, test with_property
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 10:58:07 +05:00
levzzz 3d0512446d Merge pull request #4899 from reservedbytes/feat/native-shape-update
feat(mods): native mods can change their blocks on shape updates
2026-10-31 10:55:37 +05: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
rdbt a595a30164 Merge pull request #4981 from navinor/test/living-use-flags
Pin the living use flags to vanilla's 1/3/2 wire values
2026-10-31 05:53:07 +00:00
rdbt dffc94645e Merge pull request #4982 from navinor/fix/horizon-threads-useless-vec
Pass the short names test a slice, which clippy asks for
2026-10-31 05:53:01 +00:00
MSedgeMC 3edd56c5e4 Run a mob's age boundary when it grows up, not only when it spawns
AgeableMob.setAge calls ageBoundaryReached exactly on the crossing, and that
is where a goat's attack damage becomes 2, a hoglin's 6, a sulfur cube's
size 2. Horizon wrote the age field directly in both growth paths, so the
hook only ever ran at spawn: a baby goat that grew up kept the baby 1.0
damage forever.

The per-tick growth, the feeding age-up, a sheep eating grass and a sulfur
cube's own feeding now all go through set_age, so every species gets its
boundary work. The turtle keeps its hand-patched scute drop: that one needs
a level, the mob_drops gamerule and a loot table, which a Mob hook cannot
reach.
2026-10-31 11:21:18 +05:30
rdbtCVS b526d836a0 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	tools/lan_e2e.py
2026-10-31 05:49:48 +00:00
rdbt 0b82e9f872 Merge pull request #4941 from reservedbytes/fix/place-template-flags
fix(commands): /place template places as a structure template does
2026-10-31 05:48:14 +00:00
rdbt 3f722b80a8 Merge pull request #4822 from josegil1909/fix/4815-servers-i18n
feat(launcher): localize Servers dashboard, overview, and Tailscale panel
2026-10-31 05:48:10 +00:00
MSedgeMC f554e16b02 Draw the cake snuff smoke where vanilla draws it
A snuffed candle cake emitted its block event but the client drew the smoke
half a block too low: the offset table was indexed by the state's candles
property, which a cake does not have, so it fell into the one-candle row.
The client's own prediction also only matched names ending in candle, so on
a real client a cake's snuff drew nothing at all.

Both blocks now share one table with the cake's own offset, and the
prediction knows the cake's half-block hit rule.
2026-10-31 11:17:31 +05:30
MSedgeMC 92415234d9 Pass flint and steel over a lit candle cake, and snuff through one path
A candle cake's useItemOn passes for flint and steel and fire charges before
anything else, so the item lights a fire beside the cake. Horizon answered
the click with useWithoutItem, which eats the cake: a lit candle cake
vanished when you tried to light a fire next to it.

Both hand-snuff branches also duplicated AbstractCandleBlock.extinguish
instead of calling extinguish_candle, and only a plain candle drew the wick
smoke. They now go through the one helper, and a cake's snuff smokes too.
2026-10-31 11:17:18 +05:30
levzzzandClaude Sonnet 5.5 f96b795bf6 Merge hr/main; renumber shape-update hook to API 32; validate a mod's state id; cover the incoming state in the test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 10:46:43 +05:00
levzzz 2d6d006d39 Merge pull request #4665 from ludovicthenot/native-player-tick-hook
Native mods: a hook point for each player's server tick (API 22)
2026-10-31 10:43:33 +05:00
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
rdbt 6eca577874 Merge pull request #4973 from MSedgeMC/chore/ledger-stale-rows-2
Retarget another thirteen ledger rows that are already ported
2026-10-31 05:38:47 +00:00
rdbt c559d125b5 Merge pull request #4977 from sl4ppleware/fix/seat-gone-on-rejoin
No more sitting on a ghost cushion after rejoining (a robot noticed again)
2026-10-31 05:38:43 +00:00
Aaron WangandOpenCode 819cd2eebc Pass the short names test a slice, which clippy asks for
clippy 1.99 flags \&vec![0; 100]\ as a useless_vec. The test only borrows the buffer for the call, so \&[0; 100]\ is the same argument without the allocation. This is the only change; it unblocks the workspace clippy step for every PR.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-31 01:37:46 -04:00
Ludovic Marie 1e62ad7914 Merge pull request #4955 from horizon-rust/fix/main-green-2
🤖 Agent: fixes 3 of parity-main's failures on main: block_update and random_tick (the probe skips the new structure/test block events, as it skips command_block), mob_ai bee_sting (the probe counts MobEvent::StingPlayer as a hit), and save_roundtrip (post_effects stored even when empty, as vanilla does), plus #4662's motion test, which failed with the vanilla data. Verified: those harnesses pass locally and fail on main; CI parity 59/61, where the 2 failures were main's join line, since fixed by #4950; verify green; pr_ready --quick (its one failure is #4780's candle test, which fails on main too).
2026-10-31 06:37:37 +01:00
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
rdbtCVS 57d8a70a2c Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-10-31 05:34:49 +00:00
rdbt 0fb65197a7 Merge pull request #4811 from josegil1909/fix/4802-modpacks-i18n
feat(launcher): localize Modpacks tab and port coverage report
2026-10-31 05:33:17 +00:00
rdbt d4dff9534c Merge pull request #4114 from DUzzL/fix/issue-4104-elytra-body-jitter
Smooth the elytra body lean between ticks
2026-10-31 05:32:50 +00:00
rdbt a73db6ad95 Merge pull request #3983 from DUzzL/fix/issue-3976-bots-report-bytes-join
horizon-bots: report bytes in/out and join-time percentiles
2026-10-31 05:32:27 +00:00
levzzzandClaude Sonnet 5.5 6718c7dd9a Merge hr/main; renumber player-tick hook to API 31
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 10:32:21 +05:00
Aaron WangandOpenCode d331af9962 Pin the living use flags to vanilla's 1/3/2 wire values
LivingEntity.startUsingItem sets flag 1 for the use and flag 2 for the off hand, so an off-hand use is 3; stopUsingItem clears only flag 1, so its stop arrives as 2. Pin the sender and both readers (mob data and the local player's synced use) to those values with tests, and say which bit means what where the reader lives.

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-31 01:31:55 -04:00
rdbt 389a918c16 Merge pull request #4972 from TobiasLogic/shaderpacks/far-render-distance
Shader packs: far is the chunk render distance, not the distant terrain's reach
2026-10-31 05:31:26 +00:00
levzzz bdebd8103c Merge pull request #4656 from ludovicthenot/native-left-click-block-hook
Native mods: a hook point for left-clicking a block (API 21)
2026-10-31 10:30:42 +05:00
sl4ppleware 5e7b9a5075 Rejoining a server rides nothing until the server pairs a vehicle: no sitting on a broken cushion 2026-10-31 08:29:50 +03: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
MSedgeMC dd94337c2e Retarget another thirteen ledger rows that are already ported
The ledger is where AGENTS.md sends every contributor before parity work,
and its missing rows keep sending people at behaviour that exists: the iron
golem's creator rule and crack sound, the candle and candle-cake snuff, the
villager's trade reach, the piglin's target-distance rule, the allay's
save data. Each row here now names the function that ports it, verified
against the jar and the tree.

Three rows stay missing on purpose: the moving piston's drops, the
pufferfish's puff state and the enderman's carried block are waiting on
open PRs, so citing them would name functions main does not have yet.
2026-10-31 10:56:32 +05:30
Ludovic MarieandClaude Opus 5.5 7f969cb3a5 Anticheat prediction (b): play each client tick again and compare
Each client tick the record holds is played again with the client's own
movement code (MotionWorld::replayed_tick: LocalPlayer.aiStep with the
client's keys and the sprinting it said it had), on a copy of the
player, from where the last tick ended, starting at a teleport the
client accepted, and compared with where the client said it went. The
replay then goes on from the client's position. The keys and sprinting
are known exactly and never guessed; what the server can't know is
tried, every combination, the tick as heard first: the ground on the
first tick after a start, and the item use on the tick the server's
changed. It flags nothing yet: the check is the next PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 06:26:20 +01:00
Jose Gil 0c5074bba1 feat(launcher): localize Servers dashboard, overview, and Tailscale panel 2026-10-31 01:20:32 -04:00
levzzzandClaude Sonnet 5.5 fcaaed088b Merge hr/main; renumber left-click-block hook to API 30, narrow the cancel contract
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 10:20:20 +05:00
rdbt 4de90a13d4 Merge pull request #4971 from rdbtCVS/readme-trim-launcher-shots
README: shorter text, launcher screenshots from 0.2.30
2026-10-31 05:20:09 +00:00
levzzz cbda897a5e Merge pull request #4648 from ludovicthenot/native-interact-entity-hook
Native mods: a hook point for right-clicking an entity (API 20)
2026-10-31 10:18:37 +05:00
rdbtCVSandClaude Opus 5.5 86a40451e8 README: shorter text, launcher screenshots from 0.2.30
Trims each section to what a reader needs and links the docs for the
rest, about 35% fewer words. The generated gap block and the parity
table's format are unchanged (tests/test_parity*.py).

The Home and Settings screenshots were taken with launcher 0.2.30, which
moved the window to the new layout (instance tabs, Join a friend, accent
colours); the old ones showed 0.2.28.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:17: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
rdbt 017afee3a1 Merge pull request #3982 from DUzzL/fix/issue-3972-bench-load-driver
bench: one script for Horizon, vanilla and Pumpkin under the same bot load
2026-10-31 05:15:19 +00:00
rdbt b2f0b58fc9 Merge pull request #4047 from elythepreciouscat/fix/fps-cap-vsync-off
Linux: OpenGL presents uncapped with VSync off
2026-10-31 05:15:01 +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
rdbtCVS 7f884ad64b Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-10-31 05:10:34 +00:00
rdbt 568159fa1e Merge pull request #4800 from josegil1909/fix/4798-settings-ai-telemetry-i18n
feat(launcher): localize AI agent and Data collection settings sections
2026-10-31 05:09:27 +00:00
rdbt 6bbc5cb3b0 Merge pull request #4956 from Detimes/fix-4417-resource-preflight
horizon-server: check the prepared game files before starting the engine (#4417)
2026-10-31 05:08:35 +00:00
rdbt 6e263d79f6 Merge pull request #4950 from rdbtCVS/fix/own-join-line-parity
Server: the newcomer doesn't read its own join line, as in vanilla
2026-10-31 05:07:17 +00:00
levzzzandClaude Sonnet 5.5 2959076c75 Merge hr/main; renumber right-click-entity hook to API 29
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 10:06:35 +05:00
rdbt ff5d14d4c2 Merge pull request #4960 from MSedgeMC/fix/witch-magic-absorb
Pin the witch's magic resistance with a test
2026-10-31 05:05:35 +00:00
MSedgeMC c39db2247c Drop the block a moving piston carries when something destroys it
MovingPistonBlock.getDrops returns the loot of the state the block entity
is moving, or nothing when the block entity is gone. Horizon had no loot
for a moving piston at all, so /setblock ... destroy and /fill ... destroy
made a piston in flight swallow the block it carried.

A hand break still drops nothing, as vanilla's does: removeBlock clears the
block entity before the player's drops are drawn.
2026-10-31 10:35:05 +05:30
rdbt e3cba0ac6f Merge pull request #3806 from TraiNguyenVan/docs/readme-launcher-screenshots
README: fresh launcher screenshots, and the website's hero as the banner
2026-10-31 05:05:00 +00:00
Dufru 99efd0ccb4 i18n(launcher): add pt-br translation and wire language support 2026-10-31 02:04:38 -03:00
levzzz e91d4e3f01 Merge pull request #4869 from arismoko/native-tag-checks
Native mods: read block and item tags
2026-10-31 10:04:36 +05:00
rdbt eac39ece47 Merge pull request #4402 from levzzz5154/docs/native-hooks-neoforge
Docs: model native hook points on NeoForge 26.3 events (proposal)
2026-10-31 05:04:29 +00:00
rdbt b251abc575 Merge pull request #3984 from horizon-rust/docs/server-next
docs/server-next.md: the next server's design, measured against vanilla and Pumpkin
2026-10-31 05:04:02 +00:00
rdbt 09e315cb24 Merge pull request #4482 from horizon-rust/docs/permissive-licenses-only
CONTRIBUTING: another project's code only when its license allows it
2026-10-31 05:03:58 +00:00
TobiasLogicandClaude Opus 5.5 48709c8091 Shader packs: far is the chunk render distance, not the distant terrain's reach
With Distant Terrain on, Frame::distance is the distant terrain's reach (LOD Distance 128: 2048
blocks), and packs got it as far. Iris gives the chunk render distance, and packs fade terrain into
the sky by it (Photon's and Complementary's border fog), so with far terrain on the fog began near
the far terrain's edge and distant land showed unfogged to the horizon. far is now
Frame::detail_distance.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 12:03:07 +07:00
MSedgeMC f8b5fed2f0 Make a knowledge book teach its recipes
KnowledgeBookItem.use reads the recipe ids in the book's recipes component
and awards them, and vanilla consumes the book before it looks at them, so
even an invalid book is used up. Horizon had no handler at all: a knowledge
book in hand fell through the item chain and did nothing, so the item was
inert.

The award path is the one /recipe give and crafting already use, and a book
naming a recipe the server does not know fails as vanilla's Invalid recipe
log does, without learning the valid ids beside it.
2026-10-31 10:30:53 +05:30
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
rdbtCVSandClaude Opus 5.5 4141a127fb Merge main into fix/issue-4404-wind-burst-armor-stand
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:57:52 +00:00
levzzzandClaude Sonnet 5.5 1ba05096b8 Merge hr/main; take native hooks API 28
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:55:33 +05:00
Ludovic Marie 19021623be Merge pull request #4954 from horizon-rust/feat/player-motion-clone
🤖 Agent: PlayerMotion: Clone (prediction b0), no behaviour change. Verified: pr_ready --quick (the one failure is main's #4662 test), the new copy tests, harnesses attribute, attribute_health, attribute_updates, live_attribute_updates, live_attributes, attribute_map_queries pass.
2026-10-31 05:55:19 +01:00
levzzzandClaude Sonnet 5.5 d27ec41434 Merge hr/main; renumber right-click-entity hook to API 28
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:54:52 +05:00
rdbtCVS 5993df106f Merge remote-tracking branch 'origin/main' into pr-3983
# Conflicts:
#	crates/horizon-bots/src/main.rs
2026-10-31 04:54:36 +00:00
rdbtCVS b804e8a290 Merge remote-tracking branch 'origin/main' into fix/update-zip-security 2026-10-31 04:53:40 +00:00
levzzz 81b30898d7 Merge pull request #4840 from reservedbytes/feat/mod-mob-effects
feat(mods): content mods declare their own mob effects
2026-10-31 09:53:37 +05:00
levzzz 4fc225edb9 Merge pull request #4600 from JustKarkat/feat/native-health-hud-hook
Native mods: the player-health HUD rows as a hook point
2026-10-31 09:53:11 +05:00
MSedgeMC 28754451ea Pin the witch's magic resistance with a test
Witch.getDamageAfterMagicAbsorb takes the four #witch_resistant_to
damage types at 15% and nothing from a hit it caused itself. Horizon does
this in Mob::damage_with, after armour and protection, in vanilla's order,
and nothing asserted it: the port ledger still says missing, and a reader
looking in mobs/witch.rs would find only goals and drinking.

The tests cover the four tag kinds, a physical hit passing through
untouched, and a witch's own indirect magic doing nothing.
2026-10-31 10:23:05 +05:30
arismoko 142424b58f Native mods: read block and item tags 2026-10-30 23:52:42 -05:00
rdbtCVS 1ad4d8633b Merge remote-tracking branch 'origin/main' into pr-4114
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/mobs/render.rs
2026-10-31 04:48:05 +00:00
rdbtCVSandClaude Opus 5.5 600058493e bench: bytes per player and second over each bot's time online
The bandwidth divided every bot's bytes by the whole run, so a bot that
joined late was counted for seconds it wasn't on and the rate came out
low. Divide by each bot's own connected time (bot i joins i * join_every
after the first), leave failed bots out, and say in the docstring and the
table row that it averages from login on, join included.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:47:30 +00:00
rdbtCVS ed89678b47 Merge remote-tracking branch 'origin/main' into pr4923
# Conflicts:
#	docs/graphics.md
2026-10-31 04:45:24 +00:00
DetimesandClaude Sonnet 5.5 fdd6f507ec horizon-server: check the prepared game files before starting the engine (#4417)
A prepared folder that was copied only in part made the engine crash with
"os error 2" at every restart. `init` and `run` now check what the
horizon-resources.json manifest records: the derived files are there and
match its hashes, and the font and texture folders aren't empty, and name
what is wrong.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:45:22 +00:00
Ludovic Marie 7e170c6a64 Merge pull request #4949 from horizon-rust/fix/parity-main-bisect-per-harness
🤖 Agent: parity-main's bisect judges each failed harness on its own, so one harness that fails at the base (lunge_motion on 10-31) no longer hides the merges that broke the others. Verified: tests/test_parity_main.py (mixed and two-culprit cases), pr_ready --quick (487 Python tests), CI green.
2026-10-31 05:45:10 +01:00
rdbtCVSandClaude Opus 5.5 0291f63727 CONTRIBUTING: CC0-1.0 is a public-domain dedication, not a Creative Commons license
The off-limits list named "Creative Commons licenses" while CC0-1.0 is
allowed. Name the licenses meant (CC BY, CC BY-SA, CC BY-NC...) and say
CC0 is a dedication, not one of them. The template and
how-horizon-is-built.md now name BSD-2-Clause and BSD-3-Clause, as
CONTRIBUTING does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:44:56 +00:00
levzzzandClaude Sonnet 5.5 27c38eb27b Merge hr/main into feat/mod-mob-effects
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:44:50 +05: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
rdbtCVSandClaude Opus 5.5 2ed80412b1 docs/server-next.md: line total, join worst, quiet channel and tracker paths
The gameplay parts listed sum to about 420 000 lines, not 300 000.
Horizon's worst join was 249 ms (50 bots), as the table says.
The Horizon-brand payload is horizon:quiet_commands, and Entities::tick
and mob_meta live in lan/entity.rs and game/lan.rs (lan/tracking.rs is
the generated tracking table).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:43:33 +00:00
levzzzandClaude Sonnet 5.5 a49b9a6b88 Address review: simplify mob_effect_id, read effects through Builtin::parse, test command-registries, docs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:43:09 +05:00
levzzz 53e565516f Merge pull request #4925 from reservedbytes/feat/content-crafting-remainder
feat(mods): content items declare a crafting remainder
2026-10-31 09:43:05 +05: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
levzzzandClaude Sonnet 5.5 9a4eb4add8 Merge hr/main; renumber player-health HUD hook to API 27
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:41:30 +05:00
Ludovic MarieandClaude Opus 5.5 c58859aedf PlayerMotion: Clone, for the movement prediction's replays
The prediction plays each client tick again on a copy of the player, one
copy per candidate input. Everything in PlayerMotion derives Clone but
its attributes: their identity allocator was a Box<dyn FnMut>, now a
Box<dyn Identities> (any FnMut() -> i32 that is Clone), and
ObjectOpenMap's table (an Rc<RefCell>) is copied, not shared, so a
copy's changes never reach the original.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:40:59 +01:00
levzzz 0d327cc093 Merge pull request #4475 from reservedbytes/feat/native-config-dir
feat(mods): native mods get the game's config folder
2026-10-31 09:39:52 +05:00
reservedbytes 45094a9007 fix(combat): firework rockets hurt as fireworks 2026-10-31 03:38:30 -01:00
levzzzandClaude Sonnet 5.5 cffc83bb8b Merge hr/main into feat/content-crafting-remainder
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:33:46 +05: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 f253ba22d4 Merge pull request #4921 from reservedbytes/feat/content-block-item-components
feat(mods): block items patch their template item's data components
2026-10-31 09:31:51 +05:00
levzzz bf27153a24 Merge main into native-fast-profile 2026-10-31 09:31:25 +05:00
levzzzandClaude Sonnet 5.5 78858b1d42 Drop the translations no code asks for any more
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:30:59 +05:00
Ludovic MarieandClaude Opus 5.5 cb59fcd162 Main's parity: probes for the new block and sting events, post_effects always saved
parity-main has failed since a batch of merges on 10-31:

- block_update, random_tick: #4703 and #4724 added the structure_block,
  test_block and test_block_tick block events, which the probe printed as
  unknown (`X`). Their work lives on the block entity (game/test_blocks.rs,
  the structure block screen); a fresh structure block has no name to save
  or load. The probe skips them, as it does command_block.
- mob_ai bee_sting: a bee's sting on the player became
  MobEvent::StingPlayer (stingers stuck in the body); the probe didn't count
  it as a hit on the player, so the player's hurt sound was missing.
- save_roundtrip: ServerPlayer.addAdditionalSaveData stores `post_effects`
  even when it is empty; Horizon left the key out.

Also #4662's a_swimming_passenger_sits_up_even_when_standing_does_not_fit,
which fails whenever the vanilla data is present: the player stood in the
air at y 9.65 (the scene's floor away from the pyramid tops out at y 4), so
standing fit. The feet now go 0.35 into that floor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:30:39 +01:00
levzzzandClaude Sonnet 5.5 eda9860e1d Merge hr/main; renumber native config folder to API 26
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:30:19 +05:00
Jose Gil 5b492b240f feat(launcher): localize Modpacks tab and port coverage report 2026-10-31 00:29:57 -04:00
levzzz 0a28a10e93 Merge remote-tracking branch 'hr/main' into own/3891 2026-10-31 09:29:52 +05:00
levzzz 9e06cafb83 Merge #3855 into native-mods-where 2026-10-31 09:29:52 +05:00
levzzz 7110152122 Merge #3870 into native-mods-where 2026-10-31 09:29:38 +05:00
levzzz 6c78d82c5d Merge pull request #4574 from arismoko/native-server-sounds
Native mods: play vanilla sounds from server code
2026-10-31 09:29:20 +05:00
levzzzandClaude Sonnet 5.5 a82a28179d Merge main into native-mods-on-by-default; GitHub installs no longer set the removed switch; Up to date compares each mod's files
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:29:08 +05:00
Jose Gil 44d3e49db8 feat(launcher): localize AI agent and Data collection settings sections 2026-10-31 00:27:20 -04:00
levzzzandClaude Sonnet 5.5 a3ba9256a4 Native hooks proposal: bring up to date with main, cite NeoForge's public docs per claim, drop uncited detail
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:27:06 +05:00
levzzz a00475b910 Merge remote-tracking branch 'hr/main' into rv/4402 2026-10-31 09:26:26 +05:00
Ludovic MarieandClaude Opus 5.5 e5c713c00b parity-main: judge each failed harness on its own when bisecting
bisect ran the failed harnesses as one group and asked "does any of them
fail?". On 10-31 (run 41157154666) lunge_motion failed at the base while
the 8 others passed there, so the whole group "failed at the base too" and
no merge was named for 8 real regressions.

run_harnesses now returns the harnesses that failed (from parity.py's
last-run.json). bisect drops those that pass again at HEAD (flakes),
reports those that fail at the base too, and searches the merge that broke
the rest; harnesses broken by different merges each get theirs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:26:17 +01:00
levzzz 8b1758dc73 Merge pull request #4579 from gravitacia/codex/roadmap-resource-packs
Import resource packs with modpacks
2026-10-31 09:25:28 +05:00
rdbt 623e7b03dc Merge pull request #3988 from senjinthedragon/hdr-windows-auto
HDR: Auto on Windows, from the luminances the OS reports
2026-10-31 04:24:36 +00:00
levzzz a2bc79274b Merge pull request #4814 from josegil1909/fix/4812-mods-page-i18n
feat(launcher): localize Mods browser, filters, and GitHub mods panel
2026-10-31 09:22:09 +05:00
rdbtCVSandClaude Opus 5.5 8d9d4d94e3 Server: the newcomer doesn't read its own join line, as in vanilla
PlayerList.placeNewPlayer broadcasts multiplayer.player.joined before
players.add, so only the others read it. #4634 sent it to the newcomer
too, which made dedicated_server and secure_chat fail. Fixes #4943.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:21:35 +00:00
levzzzandClaude Sonnet 5.5 809dd639e2 Merge hr/main; renumber native sounds hook to API 25
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:19:50 +05:00
rdbt 970376b6c5 Merge pull request #4058 from ult1mq/codex/windows-allocator-options
Add an opt-in Windows Segment Heap build feature
2026-10-31 04:19:16 +00:00
levzzz ffb3d2d1ea Merge pull request #4817 from arismoko/native-set-block-4808
Native mods: set a server block (API next)
2026-10-31 09:18:46 +05:00
levzzz 0d1e3dfd41 Merge remote-tracking branch 'hr/main' into fix/pr-4921 2026-10-31 09:18:26 +05:00
levzzz e5dde40e72 Merge pull request #4700 from arismoko/cleanup-content-identity
Validate content registry identity data
2026-10-31 09:18:19 +05:00
levzzzandClaude Sonnet 5.5 ff5db37b0e Check block component values for max_stack_size and rarity
Block files have no top-level keys for them, so the same 1..=99 and rarity checks apply inside components.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:16:30 +05:00
levzzz 37fd66562f Merge remote-tracking branch 'hr/main' into fix/pr-4921 2026-10-31 09:16:12 +05:00
rdbt 07042859e1 Merge pull request #4939 from reservedbytes/fix/dragon-fire-and-wind-charge
fix(dragon): parts refuse fire and a perched dragon shrugs off wind charges
2026-10-31 04:15:48 +00:00
levzzzandClaude Sonnet 5.5 b499251187 Resource packs: a bad pack never blocks import or launch; hard-link fallback for downloads
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:15:43 +05:00
rdbt 6197116a9e Merge pull request #4940 from horizon-rust/server-asks/spectator-check
tools: end-to-end check that spectators stay hidden on a dedicated server
2026-10-31 04:15:43 +00:00
Ludovic Marie ad531e6374 Merge pull request #4903 from horizon-rust/fix/server-packet-small
🤖 Agent: rebased after the merger's conflict STOP. Verified on 16fd80bdc: pr_ready --quick (the one failure is main's #4662 test), tools/server_packet_rules_e2e.py and tools/server_container_click_e2e.py pass.
2026-10-31 05:15:31 +01:00
rdbtCVSandClaude Opus 5.5 dfb6d1f0fb Merge main into hdr-windows-auto: docs/graphics.md keeps main's HDR paragraph with the Windows Auto sentence
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:15:17 +00:00
levzzz 9f2a4433ec Merge remote-tracking branch 'hr/main' into fix/pr-4579 2026-10-31 09:13:56 +05:00
levzzz 7c9efae60a Merge pull request #3745 from UMCEKO/mods/port-no-phantom-items
Port pack: item models only an item's overrides use are not items
2026-10-31 09:13:50 +05:00
levzzz c829600ac6 Merge pull request #3744 from UMCEKO/mods/port-armor-equipment
Port pack: armour of a mod's own material draws its own textures
2026-10-31 09:13:19 +05:00
rdbt f4088409ee Merge pull request #4927 from Web-mc1/fix-pvp-death-message
Name the attacking and diying player in PvP death messages
2026-10-31 04:13:03 +00:00
rdbt 3063237a06 Merge pull request #4906 from TobiasLogic/shaderpacks/iris-shadow-camera
Shader packs: sunPathRotation and the shadow camera as Iris has them
2026-10-31 04:11:48 +00:00
rdbt e75d4bc1b6 Merge pull request #4938 from k3yh/fix/phantom-line-of-sight
Phantoms target only a player they can see (#4929)
2026-10-31 04:11:45 +00:00
levzzzandClaude Sonnet 5.5 ceca6301a6 Launcher: update the Mods page header translations for the trimmed text
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:11:26 +05:00
rdbt 1b62d8d1f2 Merge pull request #4920 from reservedbytes/fix/dragon-crystal-credit
fix(dragon): a destroyed crystal credits its player, else the nearest
2026-10-31 04:11:14 +00:00
rdbt 52e186749d Merge pull request #4934 from reservedbytes/fix/explosion-pushes-refused-mobs
fix(explosion): mobs are pushed even when the hit is refused
2026-10-31 04:11:10 +00:00
rdbt ba40840da7 Merge pull request #4931 from reservedbytes/fix/creeper-blast-credit
fix(combat): a creeper's blast is a player_explosion
2026-10-31 04:10:23 +00:00
levzzzandClaude Sonnet 5.5 c97b85dc0b Polish: fix mixed-script word in the GitHub mods panel text
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:10:22 +05:00
levzzz fd4a625f7e Merge remote-tracking branch 'hr/main' into fix/pr-4814 2026-10-31 09:10:09 +05:00
levzzzandClaude Sonnet 5.5 bceac87629 Native mods: renumber set-block hook to API 24, create block entities, drain level events, fix flags docs
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 09:09:46 +05:00
levzzz e005da2cc0 Merge pull request #4478 from rdbtCVS/launcher-hide-unusable-sources
Launcher: hide the Mods page's sources and tabs that aren't usable yet
2026-10-31 09:09:43 +05:00
arismoko 406648ebe2 Native mods: play vanilla sounds from server code 2026-10-30 23:09:35 -05:00
rdbt 477ae00887 Merge pull request #4470 from horizon-rust/chore/claim-limit-8
/claim: hold up to 8 open issues, not counting ones a PR already fixes
2026-10-31 04:09:33 +00:00
levzzz 3c481aa243 Merge pull request #4848 from horizon-rust/fix/mods-screen-native-hint
Mods page: native mods installed but not built say so
2026-10-31 09:09:02 +05:00
levzzz ddd00efcbb Merge pull request #4836 from horizon-rust/fix/native-mods-linux
Launcher: say what a Linux native build lacks before it starts
2026-10-31 09:08:23 +05:00
levzzz c6cf721a91 Merge remote-tracking branch 'hr/main' into fix/pr-4700 2026-10-31 09:08:20 +05:00
levzzz 85c0d5fe3c Merge pull request #4746 from arismoko/native-level-tick
Native mods: per-level tick hooks (API 23)
2026-10-31 09:08:10 +05:00
Ludovic MarieandClaude Opus 5.5 048dafddad tools: end-to-end check that spectators stay hidden on a dedicated server
Two real Horizon clients on a dedicated server: the survival player's
client must not be sent the spectator at all (no entity, so no name tag,
armour or crosshair target), right after the switch, after a teleport onto
the player, a death and respawn, a trip to the Nether and back, either
player joining again; it shows the player again in survival, and the
spectator still sees everyone (vanilla's ServerPlayer.broadcastToPlayer).

It fails on the commit before #4204 (the spectator was sent, only
invisible) and passes on main.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:05:27 +01: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
levzzzandClaude Opus 5.5 d33d377090 Merge main into launcher/native-builds-stay-put; review fixes
Conflict: rust_toolchain.rs `Toolchain::command` doc (main's no-console note, #4841).

Review fixes (arismoko 1-4, dimaniojk 2):
- low_priority on Windows sets CREATE_NO_WINDOW | BELOW_NORMAL_PRIORITY_CLASS: creation_flags
  replaces the whole value, so with main's process::command the console window came back.
- Linux I/O: best-effort level 7 instead of the idle class, which can stall a build while the
  game streams chunks.
- builds_run_at_the_lowest_priority reads the child's niceness and I/O priority back with
  getpriority/ioprio_get instead of `ionice || echo idle`.
- outside_configs doc says what it is: the filesystem root above the launcher's data.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 08:58:37 +05:00
reservedbytes f651954913 fix(commands): /place template places as a structure template does 2026-10-31 02:57:18 -01:00
Ludovic Marie 8f91f896a5 Merge pull request #4919 from horizon-rust/feat/anticheat-prediction-record
🤖 Agent: prediction (a), recording only, off by default. Verified: pr_ready (1288 tests), lan_bot test of the record and the pong rules, server_entities and server_player_damage harnesses pass. dedicated_server's one diff is main's #4634 join line (fix: #4937). 60-bot A/B: main 4.35 MSPT, off 4.41, on 4.33 (noise).
2026-10-31 04:49:13 +01: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
reservedbytes 27888dc18d fix(dragon): parts refuse fire and a perched dragon shrugs off wind charges 2026-10-31 02:48:04 -01:00
Ludovic MarieandClaude Opus 5.5 16fd80bdcf Server packet rules: world border, carried item, game mode, beacon, pick block
ServerGamePacketListenerImpl, where the server differed:
- handleAttack and handleInteract: a target outside the world border is
  ignored (WorldBorder.isWithinBounds(target.blockPosition())).
- handleSetCarriedItem: a slot past the hotbar logs "<name> tried to set
  an invalid carried item"; a new slot stops only a main-hand use.
- handleChangeGameMode without the permission logs "Player <name> tried
  to change game mode to <mode> without required permissions".
- handleSetBeaconPacket: effects the beacon can't give, or no payment,
  disconnect (multiplayer.disconnect.generic) with "Player <name> tried
  to set invalid beacon effects".
- handlePickItemFromBlock: only a loaded block.

horizon-bots gains --raw TICK:ID:HEX, and
tools/server_packet_rules_e2e.py checks the first four on a dedicated
server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 04:46:44 +01:00
k3yhandClaude Opus 5.5 b4da90182f Phantoms target only a player they can see (#4929)
Phantom's target, strategy and its target's keep checks call
canAttack(level, player, TargetingConditions.DEFAULT), whose test needs
line of sight (Sensing.hasLineOfSight). Horizon's checks only looked at
the player being alive and in survival, so a phantom took a player
boxed under a roof or glass and kept swooping at it. Check sight there
too; the sweep keeps vanilla's check without it.

The test world fixture takes a closure that places blocks, and the
fox test's player moves to a shared test_player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:44:47 -05: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
rdbt 87a9d03fd8 Merge pull request #4050 from ult1mq/codex/mesh-buffer-reuse
Reuse worker meshing scratch and bounded mesh capacity
2026-10-31 03:37:29 +00: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
rdbtCVSandClaude Opus 5.5 43e29d4556 Merge main into codex/mesh-buffer-reuse
SectionMesh::clear also resets the new tight bounds (#4244), and the padding
reuse test expects a missing neighbour to continue the centre's edge column
(ee7d443c8).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:28:02 +00:00
rdbt 480f6b3d1b Merge pull request #3993 from Vasysik/perf/sunrise-sector-selection
Reduce sunrise fan intersection work with projected sector selection
2026-10-31 03:25:53 +00:00
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
rdbt d4c60e0ac4 Merge pull request #4386 from elythepreciouscat/perf/light-chunk-scratch-and-traversal
Chunk lighting: reuse worker scratch buffers and optimize flood-fill traversal
2026-10-31 03:18:59 +00:00
rdbtCVSandClaude Opus 5.5 4af27b50a8 sunrise_fan: restore the comments' Möller and × characters
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:16:52 +00:00
levzzz bc310a28c8 Merge remote-tracking branch 'hr/main' into launcher/native-mod-crates 2026-10-31 08:16:50 +05:00
reservedbytes 0b83386c7e fix(explosion): mobs are pushed even when the hit is refused 2026-10-31 02:16:27 -01:00
rdbt a10b26b9ef Merge pull request #4244 from elythepreciouscat/perf/lce-tight-bounds-prioritized-meshing
Render: tight chunk section bounds for resident frustum culling
2026-10-31 03:16:20 +00:00
levzzz 8acefbec58 Merge pull request #4926 from levzzz5154/parity/BAN-notices
Show ban notices before the title screen (Gui.addInitialScreens)
2026-10-31 08:16:06 +05:00
rdbt da2acdad41 Merge pull request #4924 from horizon-rust/fix/launcher-de-json
Launcher: fix lang/de.json breaking the launcher job on main
2026-10-31 03:15:08 +00:00
reservedbytes d020b5f93c fix(combat): a creeper's blast is a player_explosion 2026-10-31 02:14:40 -01: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
rdbt 1641b3ecc0 Merge pull request #4008 from Joansitoh/fix/windows-pack-reload-speed
Resource packs: native overlay junctions and parallel staging on Windows (7 s → 2.3 s)
2026-10-31 03:11:56 +00: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
levzzzandClaude Sonnet 5.5 e8b180b01e Show the account's ban notices before the title screen
Gui.addInitialScreens: BanNoticeScreens for a multiplayer ban, a forced name
change and a banned skin, fetched off the main thread for an online session;
the Multiplayer button follows Minecraft.allowsMultiplayer.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 08:07:37 +05:00
rdbtCVSandClaude Opus 5.5 c84b15d426 Launcher: de.json parses again and has only the strings every language has
#4123 left a closing brace in the middle of lang/de.json, so the file no
longer parsed and the launcher showed English for German. It also added
99 strings no other language translates, which the i18n test rejects:
a string translated in one language must be in all of them. Those 99
leave de.json until the other languages get them too (the German text
stays in 669aa04e6); #4123's corrections to existing strings stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:07:16 +00:00
Web-mc1andClaude 23f917e309 Name the attacking player in PvP death messages
Co-Authored-By: Claude <[email protected]>
2026-10-31 16:04:08 +13:00
rdbt 8ba17d98d9 Merge pull request #3893 from josegil1909/feat/client-spanish-lang
feat(client): add Spanish language support for Horizon settings
2026-10-31 03:02:55 +00:00
rdbt e51c33efdd Merge pull request #4644 from WooCaash/fix/4558-item-bubble-column-jump
Move grounded items when a bubble column lifts them
2026-10-31 03:01:22 +00:00
rdbt 388bdfe072 Merge pull request #3960 from Joansitoh/fix/skins-after-pack-reload
Skins and maps upload again after a resource pack reload
2026-10-31 03:00:41 +00:00
rdbt e2b0b817d7 Merge pull request #4287 from Sticky-Bug/feat/monochrome-logo-option
options: enable Monochrome Logo option (darkMojangStudiosBackground)
2026-10-31 03:00:37 +00:00
rdbt 678f0ac6f4 Merge pull request #4188 from GIGABAIT93/fix/scaled-riders
Riders sit in their scale, and on text displays: NPC names over big heads
2026-10-31 03:00:11 +00:00
rdbt de76c32fe3 Merge pull request #4789 from WooCaash/fix/4408-chat-links
Fix chat open_url links from legacy clickEvent JSON
2026-10-31 03:00:07 +00:00
rdbt af2df073d7 Merge pull request #4908 from senjinthedragon/hdr-end-flash
HDR: the End sky and End flash keep the brightest mark
2026-10-31 02:59:48 +00:00
rdbt b7fef4337b Merge pull request #4914 from sirrayi/tools/port-ledger-blockpattern
Port ledger: the BlockPattern rows point at the file that ports it
2026-10-31 02:59:44 +00:00
levzzz 465a8fda1e Merge pull request #4918 from levzzz5154/parity/B4-oit-rest
Improved transparency: the rest of the entity render types with an OIT pipeline set
2026-10-31 07:58:04 +05:00
rdbt 69ba6c5645 Merge pull request #4506 from MSedgeMC/fix/allay-potion-match
Make an allay tell potions apart
2026-10-31 02:55:25 +00:00
rdbt 61883f815e Merge pull request #4502 from MSedgeMC/fix/brute-angry-sound
Fixes the `PiglinBruteAi.playActivitySound` and `maybePlayActivitySound` rows in the port ledger. (No issue.)
2026-10-31 02:55:21 +00:00
rdbt a25e830b28 Merge pull request #4123 from kerimgrande/main
add more german translations
2026-10-31 02:52:33 +00:00
rdbt 8bb6e0dc11 Merge pull request #4504 from MSedgeMC/fix/spawn-egg-components
Apply a spawn egg's components to the mob it spawns
2026-10-31 02:52:01 +00:00
levzzzandClaude Sonnet 5.5 4bbb1d3b42 Improved transparency: xp orbs, shulker shells, player heads, dragon eyes, HDR eye mark
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:50:17 +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
levzzz 3dc5b432fe Merge pull request #4907 from levzzz5154/parity/K4-exact-components
Hash exact component predicates in container and bundle_contents predicates
2026-10-31 07:41:12 +05:00
rdbtCVS b043f3977b Merge remote-tracking branch 'origin/main' into fix-4504
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/dispenser.rs
2026-10-31 02:40:58 +00:00
sirrayiandDeepSeek Harness cb8feeeff9 Port ledger: the BlockPattern rows point at the file that ports it
#4892 moved the golem patterns onto block_pattern.rs and deleted
golems.rs::find_pattern, so the rows naming golem.rs::Game::find_golem for
BlockPattern.matches and BlockPattern.translateAndRotate named a function that
no longer holds the port (#4913). They now point at block_pattern.rs, which is
where the wither already used it, and BlockPattern.find gets the row it never
had; the four golem pattern rows name their constants.

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

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 02:40:24 +00:00
levzzz d454cd53fb Merge pull request #4909 from levzzz5154/parity/B3-oit-entities
Improved transparency: entity render types with an OIT pipeline set
2026-10-31 07:38:47 +05:00
Łukasz KopańskiandCursor e5089d3fe4 Retrigger verify after label/note-only checks.
Co-Authored-By: Cursor <[email protected]>
2026-10-31 03:38:21 +01:00
Łukasz KopańskiandCursor dd68ab987a Drop the idle-item vertical gate; cover bubble lift with real ticks.
Gravity always leaves |vy| non-zero before the move check, so || vertical
ran move every tick for resting items and broke vanilla's 1-in-4 skip.
Idle ticks already push via BubbleColumnBlock.entityInside; keep that and
assert the stone velocity sequence plus a soul-sand column rise.

Co-Authored-By: Cursor <[email protected]>
2026-10-31 03:38:21 +01:00
Łukasz KopańskiandCursor 3bd2b8b820 rustfmt the bubble-column item idle path.
Co-Authored-By: Cursor <[email protected]>
2026-10-31 03:38:21 +01:00
Łukasz KopańskiandCursor d46cb2ca35 Move grounded items when a bubble column lifts them.
Idle ItemEntity ticks skipped move for 3/4 ticks even after the column
set vertical speed, so drops on soul sand/magma only hopped. Apply the
column inside tick (before drag) like vanilla, and still move when vy
is already set.

Co-Authored-By: Cursor <[email protected]>
2026-10-31 03:38:21 +01:00
rdbtCVS e3a24beec6 Merge remote-tracking branch 'origin/main' into fix-4506
# Conflicts:
#	crates/horizon-client/src/game/allays.rs
2026-10-31 02:38:05 +00:00
rdbtCVS 587b85d675 Merge remote-tracking branch 'origin/main' into fix-4502
# Conflicts:
#	crates/horizon-client/src/mobs/piglin.rs
2026-10-31 02:37:59 +00:00
rdbt deff92036a Merge pull request #4551 from foudretbr/fox-night-screech
Foxes screech at night when no player is near
2026-10-31 02:34:55 +00:00
rdbt 230d0d5911 Merge pull request #4643 from Joansitoh/fix/arrow-pickup-animation
Arrows fly into whoever picks them up on servers and the server thread
2026-10-31 02:34:51 +00:00
rdbt fcd9f1b401 Merge pull request #4661 from DenisHumen/fix/bundle-wheel-in-stations
Bundles: the wheel selects in a crafting table's and a station's menu
2026-10-31 02:34:47 +00:00
rdbt 68f554e7dc Merge pull request #4546 from MSedgeMC/chore/ledger-stale-rows
Retarget ledger rows that are already ported
2026-10-31 02:34:43 +00:00
rdbt ee637b2567 Merge pull request #4857 from mulareal/fix-death-sound-4570
death: play the player death sound only once on fatal damage
2026-10-31 02:34:39 +00:00
rdbt cc6bfe2bc3 Merge pull request #4696 from WooCaash/fix/4684-bucket-empty-sound
Play empty-bucket sounds on the predicting client
2026-10-31 02:34:35 +00:00
rdbt 57b2aa470b Merge pull request #4866 from Iam-Sanja/fix/dedicated-playerdata-overwrite
fix: prevent dedicated world saves from overwriting player data
2026-10-31 02:34:31 +00:00
rdbt c8ad6a8840 Merge pull request #4879 from xFedeT/fix/server-level-dat-fallback
A dedicated server takes level.dat_old when level.dat does not read
2026-10-31 02:34:27 +00:00
rdbt 57064f15d0 Merge pull request #4662 from WooCaash/fix/4509-cushion-swim-pose
Sit up on a cushion after swimming
2026-10-31 02:34:23 +00:00
rdbt 21f460c630 Merge pull request #4659 from WooCaash/fix/4548-sulfur-cube-fuse-anim
Sync SulfurCube MAX_FUSE for the client fuse swell
2026-10-31 02:34:19 +00:00
rdbt d05e95409d Merge pull request #4730 from WooCaash/fix/4714-server-day-night
Fix inverted day/night on multiplayer servers
2026-10-31 02:34:15 +00:00
rdbt f4852b0660 Merge pull request #4512 from xFedeT/fix/random-stand-parity
RandomStandGoal parity: named as vanilla's, herd scenarios for the equines
2026-10-31 02:34:09 +00:00
rdbt 9304a5387e Merge pull request #4876 from josegil1909/fix/4821-horse-footstep-sounds
fix(client): play horse footstep sounds while riding
2026-10-31 02:34:04 +00:00
rdbt 6b25a75238 Merge pull request #4895 from horizon-rust/fix/villager-show-trades-offers
Roll a villager's offers when it shows trades
2026-10-31 02:34:00 +00:00
levzzzandClaude Sonnet 5.5 36e376591a Merge hr/main into parity/K4-exact-components
Replaces the name-based COMPONENT_BOOLS heuristic with the per-component codec shapes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:31:26 +05:00
rdbt 926875ace4 Merge pull request #4710 from WooCaash/fix/4694-dedicated-peaceful-difficulty
Apply server.properties difficulty on dedicated startup
2026-10-31 02:30:07 +00:00
levzzzandClaude Sonnet 5.5 1b4ef1de92 Improved transparency: entity render types with an OIT pipeline set
Translucent, emissive, eyes and breeze wind entity quads, lightning and the
additive dragon geometry go through the OIT stages when the option is on.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:29:37 +05:00
levzzzandClaude Sonnet 5.5 91e1f8a8cd Hash exact component predicates nested in container and bundle_contents predicates
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:27:05 +05:00
rdbt 575c9b20df Merge pull request #4892 from sirrayi/fix/golem-block-pattern
Golems: express the golem patterns with the shared BlockPattern finder
2026-10-31 02:25:13 +00:00
rdbt e65680f925 Merge pull request #4761 from arismoko/tidy-native-particle-queue
Tidy the native particle queue
2026-10-31 02:24:52 +00:00
rdbt e24e645156 Merge pull request #4864 from sirrayi/fix/redstone-piston-retract
Redstone: a retracting piston no longer breaks the dust on top of it
2026-10-31 02:24:48 +00:00
rdbt d5ff486d31 Merge pull request #4660 from DenisHumen/fix/looting-mob-drops
Looting on the killer's weapon counts on a server's mobs
2026-10-31 02:24:43 +00:00
rdbt b477ff2689 Merge pull request #4885 from ErikKalikas/launcher-native-build-offline
Launcher: keep native mods' commit dates so a kept build works offline
2026-10-31 02:23:54 +00:00
rdbt 8a65204b3a Merge pull request #4884 from xFedeT/fix/hash-container-predicate
Hash a container or bundle_contents predicate whose item predicate has exact components
2026-10-31 02:23:50 +00:00
rdbt 8115a3a4b1 Merge pull request #4779 from WooCaash/fix/4343-controller-right-stick-look
Fix 8BitDo Ultimate 2 right-stick look on Linux
2026-10-31 02:23:46 +00:00
Ludovic MarieandClaude Opus 5.5 d26c8fba0a Anticheat prediction (a): record each client tick and the server ticks it saw
The first step of the 1:1 movement prediction (planned with rdbtCVS on
(the packets between two ServerboundClientTickEndPackets: keys held,
sprinting, rotation, the move and its flags), the last two seconds of
them. Each server tick ends with a ClientboundPingPacket whose id is the
tick; the client's pong, which comes before its next tick, says from
which client tick on that server tick's packets were in effect.

A `prediction` check in [anticheat] turns it on (with the anticheat). It
records only and flags nothing yet; the next PRs play each tick again
with vanilla's movement code and compare. Pongs don't count as actions
(the idle timeout), as in vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:23:22 +01:00
rdbt 65bc3d80b5 Merge pull request #4837 from mulareal/fix-slime-attack-sound-4569
mobs: play slime and magma cube attack sound only when hurtServer succeeds
2026-10-31 02:23:09 +00:00
SenjinandClaude Opus 5.5 5c5107b72d HDR: the End sky and End flash keep the brightest mark
#4060's tier in fs_sky_celestial gave geometry that sets no tier (tier 0) the
lowest mark instead of the brightest it had before. Found by DUzzL's review.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:22:47 +01:00
rdbtCVSandClaude Opus 5.5 0a6d8aa90a Dedicated startup: a hardcore world stays hard
MinecraftServer.setDifficulty forces HARD for a hardcore world, so
difficulty=peaceful in server.properties must not make one peaceful.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:22:36 +00:00
rdbt 497e13808f Merge pull request #4634 from cryingtoilet/fix/4536-own-join-message
Send a joining player its own join message (#4536)
2026-10-31 02:21:45 +00:00
rdbt 90ca4e0491 Merge pull request #4697 from horizon-rust/fix/floating-unloaded-chunks
Floating kick: a chunk not loaded yet is not air
2026-10-31 02:21:30 +00:00
rdbt 23d5461fe0 Merge pull request #4748 from sirrayi/lod-sampler-cache
LOD: skip density work for tiles wholly inside visited chunks
2026-10-31 02:21:26 +00:00
rdbt 65688ae0f0 Merge pull request #4744 from apeirs/fluid-spread-no-clones
Fluids: spread without cloning shapes or locking per step
2026-10-31 02:21:22 +00:00
rdbt 8b718c5d5e Merge pull request #4841 from horizon-rust/fix/native-build-no-console
Launcher: native builds open no console window on Windows
2026-10-31 02:20:47 +00:00
rdbt c5d7bae5fe Merge pull request #4608 from k3yh/fix/vulkan-acquire-fence-fifo-only
Windows Vulkan: wait for the acquire fence only with VSync (gfx-rs/wgpu#10714)
2026-10-31 02:20:43 +00:00
rdbt b296f7e2ea Merge pull request #4875 from reservedbytes/tooling/pr-ready-rust-version
fix(tools): pr_ready says when local rust is older than ci's
2026-10-31 02:20:39 +00:00
rdbt 0f4db0c569 Merge pull request #4641 from WooCaash/fix/4565-enderman-boat-sit
Sit an enderman that rides a boat
2026-10-31 02:20:36 +00:00
rdbt f541e240fb Merge pull request #4819 from Qwerner88880/fix-item-command-guest-slots
/item reaches a connected player's slots
2026-10-31 02:20:32 +00:00
levzzz 0ba58d9229 Merge pull request #4898 from levzzz5154/parity/ime-preedit
IME: show composition text under the caret and place the candidate window
2026-10-31 07:20:30 +05:00
rdbt 450d8c35dc Merge pull request #4692 from doodlyyousuf/fix/4421-boat-ice-slide
Boats slide on ice (getGroundFriction)
2026-10-31 02:20:28 +00:00
levzzz e280d96cf2 Merge pull request #4900 from levzzz5154/parity/T5-gametest-dims
Game tests run in any dimension
2026-10-31 07:20:20 +05:00
levzzz 241684332d Merge pull request #4897 from levzzz5154/parity/B2-oit-features
Improved transparency: route features, particles, weather, world border and flat draws
2026-10-31 07:19:59 +05:00
levzzz 59ec871592 Merge pull request #4901 from levzzz5154/parity/place-entities
/place structure and /place jigsaw: spawn the entities the pieces carry
2026-10-31 07:18:13 +05:00
reservedbytes b4a85e32e3 fix(dragon): a destroyed crystal credits its player, else the nearest 2026-10-31 01:17:47 -01:00
rdbt cbf9b8d5ab Merge pull request #4762 from josegil1909/fix/es-sponsor-chip-overflow
fix(launcher): shorten Spanish sponsor discount label to prevent chip overflow
2026-10-31 02:17:44 +00:00
rdbt 00643bca10 Merge pull request #4769 from josegil1909/fix/launcher-telemetry-notice-layout
fix(launcher): constrain telemetry notice width in settings to prevent overflow
2026-10-31 02:17:39 +00:00
rdbt 7ac8e3a30a Merge pull request #4606 from apeirs/auto-upload-time-cap
Chunk uploads: a 2 ms cap per frame when the player set no budget
2026-10-31 02:17:35 +00:00
rdbt 1fdcf6b385 Merge pull request #4630 from WooCaash/fix/4550-tnt-slime-bounce
Bounce primed TNT when a slime piston hits it
2026-10-31 02:17:31 +00:00
rdbt 6d669b0a59 Merge pull request #4695 from WooCaash/fix/4681-strip-sound-once
Play axe strip sounds only once for the actor
2026-10-31 02:17:27 +00:00
rdbt 93b9775439 Merge pull request #4709 from WooCaash/fix/4508-slime-honey-item-texture
Cull back faces on translucent slime/honey items
2026-10-31 02:16:57 +00:00
rdbt c723e0894c Merge pull request #4701 from WooCaash/fix/4688-cherry-grove-lod-tint
Keep cherry leaf pink in far LOD terrain
2026-10-31 02:16:53 +00:00
rdbt 6f1e0a2463 Merge pull request #4734 from WooCaash/fix/4190-dragon-crystal-rays
Clear end crystal revival beams when dragon respawn ends
2026-10-31 02:16:49 +00:00
rdbt 08191e2f40 Merge pull request #4640 from cryingtoilet/fix/4562-activator-rail-eject
Place a rider ejected by an activator rail beside the cart (#4562)
2026-10-31 02:16:45 +00:00
rdbt 439e0ee259 Merge pull request #4771 from reservedbytes/fix/starvation-recorded-once
fix(combat): starvation is recorded once
2026-10-31 02:16:41 +00:00
rdbt a18e7d5d1f Merge pull request #4874 from DmytroChup/fix-4777
Apply equipment attribute modifiers from the off hand
2026-10-31 02:16:37 +00:00
Ludovic Marie ec27a1e0b0 Merge pull request #4890 from horizon-rust/fix/decode-limits
🤖 Agent: unit tests: a 32768-byte payload (play and configuration) and a 32501-char suggestion are refused, the limits pass; 8 emoji pass a 16-unit limit and fail 15. 3 server harnesses pass; pr_ready --quick green.
2026-10-31 03:14:35 +01:00
Ludovic Marie 1d4b04ceb3 Merge pull request #4888 from horizon-rust/fix/container-click-slot-range
🤖 Agent: proved with tools/server_container_click_e2e.py on a dedicated server: a THROW click on a chest's slot 63 no longer throws inventory slot 9 (it did on main); lan_bot a_click_past_the_menu_is_ignored fails on main's containers.rs and passes here. 3 server harnesses and lan_e2e chest/containers/large_chest/shulker_menu/clicks pass; menu-transfer fails the same on main (its stale count takes the armour ids 500-504).
2026-10-31 03:14:32 +01:00
rdbtCVS 07c8bdbc0b Merge main 2026-10-31 02:14:21 +00:00
rdbtCVS adfda4bd26 Merge main 2026-10-31 02:14:20 +00:00
rdbtCVS d0076a5119 Merge main 2026-10-31 02:14:19 +00:00
rdbtCVS 502a3026aa Merge main 2026-10-31 02:14:18 +00:00
rdbtCVS 8c7779e17f Merge main 2026-10-31 02:14:17 +00:00
rdbtCVS 9d9fe2298e Merge main 2026-10-31 02:14:16 +00:00
reservedbytes 717b49cbc9 feat(mods): native mods can change their blocks on shape updates 2026-10-31 01:12:21 -01:00
levzzz 3839d80076 Merge remote-tracking branch 'hr/main' into parity/ime-preedit 2026-10-31 07:11:54 +05:00
levzzz 26752debe1 Merge remote-tracking branch 'hr/main' into parity/place-entities 2026-10-31 07:11:54 +05:00
reservedbytes 7dd10acb86 feat(mods): content items declare a crafting remainder 2026-10-31 01:11:48 -01:00
reservedbytes 2a8ae891ae feat(mods): block items patch their template item's data components 2026-10-31 01:11:47 -01:00
levzzz 65d68266e2 Merge remote-tracking branch 'hr/main' into parity/B2-oit-features 2026-10-31 07:11:46 +05:00
levzzz c418e970be Merge remote-tracking branch 'hr/main' into parity/T5-gametest-dims 2026-10-31 07:11:26 +05:00
rdbt 8930bb3faf Merge pull request #4902 from horizon-rust/fix/streaming-section-mask-build
Fix main's build: meshing candidates carry a SectionMask
2026-10-31 02:11:22 +00:00
TobiasLogicandClaude Opus 5.5 d01ff3b8a4 Shader packs: sunPathRotation and the shadow camera as Iris has them
sunPathRotation tilted the sun's path toward +Z; Iris tilts it toward -Z. The shadow camera now
matches Iris's matrices: the view undoes the tilt, turns about Z by the light's angle from the
zenith, then 90 degrees about X, with its origin at interval / 2 - camera % interval on each world
axis; the 100-block offset toward the light is in shadowProjection, whose orthographic depths run
from -near to far. Measured with probe packs in Minecraft 26.3 + Iris 1.11.7 (Iris's source not
read); the_shadow_camera_matches_iris checks the values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 09:09:44 +07:00
rdbtCVSandClaude Opus 5.5 cd9867da5c Fix main's build: meshing candidates carry a SectionMask
#3961 typed the candidates' sections as u32 while the level-shape work made
a column's dirty sections a SectionMask; SectionMask now orders so
nearest_first can sort them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:05:11 +00:00
levzzzandClaude Sonnet 5.5 1ee73d821d Spawn the entities /place structure and /place jigsaw's pieces carry
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:04:39 +05:00
levzzz 7680fee325 Merge remote-tracking branch 'hr/main' into parity/B2-oit-features 2026-10-31 07:04:12 +05:00
levzzzandClaude Sonnet 5.5 860dbf51e1 Fix main's build: streaming candidates hold a SectionMask
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:04:05 +05:00
levzzzandClaude Sonnet 5.5 3ef3272ee2 Game tests run in any dimension: per-dimension grids, level swapped in for placement, levels with tests keep ticking
Also fixes the streaming candidate list type (SectionMask) that broke main.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:03:33 +05:00
levzzzandClaude Sonnet 5.5 9cc33bdda8 Fix main's build: streaming candidates hold a SectionMask
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:02:31 +05:00
levzzz 2d621405c6 Merge remote-tracking branch 'hr/main' into parity/ime-preedit 2026-10-31 07:00:51 +05:00
levzzzandClaude Sonnet 5.5 a0b0a5ba50 Show the input method's composition under the caret and place its candidate window there
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:00:51 +05:00
levzzzandClaude Sonnet 5.5 5fbb18a139 Improved transparency: route features, particles, weather, border and flat draws
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 07:00:12 +05:00
levzzz 1fdd195790 Merge pull request #4527 from kacpherek/fix/screens-batch
Screens, HUD and chat parity: 29 fixes (toasts, chat trust, recipe book, bundles, world list, sounds)
2026-10-31 06:59:04 +05:00
levzzz 95fe89ca0d Merge pull request #4886 from levzzz5154/parity/T4-test-highlight
/test pos: the highlight packet and the client's block highlight
2026-10-31 06:57:48 +05:00
rdbtCVSandClaude Sonnet 5.5 cfb61d623a Roll a villager's offers when it shows trades
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:57:38 +00:00
levzzz 711f099ac8 Merge pull request #4342 from MSedgeMC/fix/4309-place-structure
Wire /place structure to the live level
2026-10-31 06:56:49 +05:00
sirrayiandDeepSeek Harness 4f43f49cf7 Redstone: a retracting piston no longer breaks the dust on top of it
PistonBaseBlock.triggerEvent writes the retracting base as a `moving_piston`
and still updates the neighbours around it, so the dust above the base is asked
whether it can survive. The exported support facts of a moving block carry no
block entity and call every face unsupported, while vanilla reads that block
through PistonMovingBlockEntity.getCollisionShape, whose shape holds the
extended base; the dust dropped and was removed (#4728).

Both dust support checks now read the block through Sim::support_state, the
indirection #/5782df1c0 added for the same class of mistake on a lever: the
survive check uses sturdy(below, Up) and updateShape's down arm passes the
position instead of the neighbour's raw state.

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

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 01:56:00 +00:00
rdbt ad7b45d3b6 Merge pull request #3961 from WhyFovius/perf/chunk-streaming-priority
Streaming: light and mesh jobs bounded in flight, nearest first
2026-10-31 01:54:27 +00:00
rdbt 35fbfb00dd Merge pull request #3987 from senjinthedragon/hdr-shortcut
HDR: a shortcut key to its settings, unbound by default
2026-10-31 01:54:21 +00:00
rdbt 9ca4d10841 Merge pull request #4177 from Domanffe/feat/game-ru-strings
Graphics screens: their text through the language table
2026-10-31 01:54:15 +00:00
rdbt 9e2c0dfde2 Merge pull request #4148 from Domanffe/fix/launcher-language-row
Launcher: the language row is a dropdown, not a segment per language
2026-10-31 01:54:11 +00:00
rdbt 7b52aa1f85 Merge pull request #4175 from DUzzL/fix/issue-4174-online-servers-default
Check Minecraft accounts for new launcher servers by default
2026-10-31 01:54:06 +00:00
rdbt 05a7663aab Merge pull request #4062 from senjinthedragon/hdr-blocks-2
HDR: light sources tiered from a look at each in game
2026-10-31 01:54:02 +00:00
rdbt 2f82051fb4 Merge pull request #4060 from senjinthedragon/hdr-moon-2
HDR: the moon shines at the low tier, not the sun's
2026-10-31 01:53:57 +00:00
rdbt f1b2d7b86f Merge pull request #4061 from senjinthedragon/hdr-eyes-2
HDR: mobs' glowing eyes shine like glowstone
2026-10-31 01:53:53 +00:00
rdbt 223a97787d Merge pull request #4415 from SlaVKsVolks/contribute/horizon-ledger-jar-20261030
Port ledger: support builds with JAR-only inputs
2026-10-31 01:53:48 +00:00
rdbt e5e27a7aac Merge pull request #4426 from SlaVKsVolks/contribute/horizon-benchmark-conditions-20261030
Benchmark: reject comparisons with incompatible conditions
2026-10-31 01:53:43 +00:00
Ludovic MarieandClaude Opus 5.5 a65989354d Packets past vanilla's decoder limits disconnect
What vanilla's codecs refuse, so its decoder disconnects the player:
- a custom payload over 32767 bytes (DiscardedPayload), in configuration
  and in play;
- a command suggestion request over 32500 characters (it was ignored);
- a string longer than its limit in Java's UTF-16 units: a character
  outside the BMP counts twice (Utf8String.read), where Horizon counted
  Unicode characters.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:51:11 +01:00
levzzzandClaude Sonnet 5.5 83c79c4d4f Add restore_held_keys to the capture test's fixture
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:49:42 +05:00
levzzz 5b372e2f35 Merge pull request #4883 from levzzz5154/parity/B2-oit
Improved transparency: water mask and cloud passes
2026-10-31 06:49:15 +05:00
levzzzandClaude Sonnet 5.5 e3eb634eba /test pos: ClientboundGameTestHighlightPosPacket and the client's block highlight
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:47:28 +05:00
levzzzandClaude Sonnet 5.5 c00e5801aa Merge hr/main into fix/screens-batch
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:46:50 +05:00
rdbt b1f7c78d54 Merge pull request #4306 from MSedgeMC/fix/sheared-undercoat
Keep the sheep undercoat when the sheep is sheared
2026-10-31 01:46:39 +00:00
rdbt f9676a54ce Merge pull request #4197 from kacpherek/fix/gameplay-world-scale
Scale attribute: the view-blocking check follows the player's scale
2026-10-31 01:46:31 +00:00
rdbt b9f5a45c62 Merge pull request #4268 from DUzzL/fix/issue-4254-scaffolding-sneak-descend
Let a sneaking player descend through scaffolding on the server
2026-10-31 01:46:22 +00:00
rdbt b357010174 Merge pull request #4137 from DUzzL/fix/issue-4127-armor-stand-passenger
Seat armour-stand name holograms on the player they ride
2026-10-31 01:46:14 +00:00
levzzz 9b611a200b Merge remote-tracking branch 'hr/main' into fix/pr-4342 2026-10-31 06:44:26 +05:00
levzzz 2fc88ea632 Merge pull request #4881 from levzzz5154/parity/T3-test-env
Game tests: test environments
2026-10-31 06:44:15 +05:00
levzzz 10a4ec4e2d Merge pull request #4347 from DUzzL/fix/issue-4338-ime-commit
Accept IME text composition in the game's text fields
2026-10-31 06:44:11 +05:00
levzzzandClaude Sonnet 5.5 5ddb4828b5 Fix clippy collapsible ifs; open the Optimize test's world editor before its data goes stale
Edit is disabled for a world that needs file fixing (LevelSummary.canEdit),
so the test now opens the edit screen first.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:43:10 +05:00
ErikKalikasandClaude Opus 5.5 9bb2c8818d Launcher: keep commit dates so a native build of split-pin mods works offline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 03:42:53 +02:00
levzzz 40f8bd80da Merge pull request #4588 from phexuss/fix/smithing-armor-stand-preview
Show the armour stand preview in the smithing table
2026-10-31 06:42:14 +05:00
levzzz e44f25cae1 Merge pull request #4581 from phexuss/fix/shoulder-parrots
Draw the parrots on the player's shoulders
2026-10-31 06:42:06 +05:00
sirrayi 65d2b5e8fe Merge remote-tracking branch 'origin/main' into lod-sampler-cache-v2 2026-10-31 01:42:04 +00:00
rdbt c620da0f19 Merge pull request #4265 from DenisHumen/fix/parrot-stands-when-hurt
A hit stands a sitting tame parrot up
2026-10-31 01:39:59 +00:00
rdbt e1093d6800 Merge pull request #4311 from Teiwazik/feat/pack-folder-watch
Resource pack screen: find packs added to the folder while it is open
2026-10-31 01:39:53 +00:00
rdbt 148b33f41d Merge pull request #4109 from Benflex1/fix/4080-inventory-effect-tooltip-order
Draw item tooltips over inventory effect panels
2026-10-31 01:39:47 +00:00
rdbt b80768cd4a Merge pull request #4184 from ASOwnerYT/fix/tamed-wolf-owner
Tamed pets no longer attack the owner who hits them
2026-10-31 01:39:40 +00:00
levzzz c8a8bf357b Merge hr/main into parity/B2-oit 2026-10-31 06:39:25 +05:00
levzzz d914ceeb53 Merge pull request #4850 from levzzz5154/parity/B1-oit
Improved transparency: order-independent terrain pass
2026-10-31 06:39:16 +05:00
Federico TaglianiandClaude Sonnet 5.5 b213e5346b Hash a container or bundle_contents predicate whose item predicate has exact components
The nested components travel as NBT, whose Codec.BOOL values are bytes. The
bool fields of the component codecs are named, so the predicate hashes as
vanilla's HashOps does; a byte under any other name still leaves it unhashed.
Hashes printed by vanilla 26.3 for four cases (glint override true and false,
damage and a custom name with bold, a food with can_always_eat).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:39:05 +01:00
Ludovic Marie 2509598c13 Merge pull request #4877 from horizon-rust/fix/client-loaded-gate
🤖 Agent: proved with tools/server_client_loaded_e2e.py on a dedicated server: a client that never sends PlayerLoaded stays put for 1.5 s and moves after the 60-tick timeout, a loaded one moves at once (fails on main). 7 server harnesses and lan_e2e respawn/spawned/portals/digging/deathmining pass; pr_ready --quick green.
2026-10-31 02:38:53 +01:00
rdbtCVSandClaude Opus 5.5 f0d20beff8 Merge main into fix/sheared-undercoat
Keep main's jeb_ wool colours; only SheepWoolLayer stops on a sheared
sheep, SheepWoolUndercoatLayer still draws.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:37:05 +00:00
levzzzandClaude Sonnet 5.5 934136e813 Merge hr/main into the /place structure branch
Main added set_block_entity and entities to LevelFeatureWorld; drop the
duplicate impl and initialise the new field. Keep both corpus groups.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:36:49 +05:00
levzzz b2bf03c749 Merge hr/main 2026-10-31 06:36:30 +05:00
levzzz 22b8af021c Merge pull request #4878 from levzzz5154/parity/T3-test-cmds
/test verify, locate, pos, create
2026-10-31 06:36:26 +05:00
rdbtCVSandClaude Opus 5.5 111081b1bc Merge main into fix/gameplay-world-scale
Carry the scale into main's near-plane view-blocking check
(LevelExtractor.getViewBlockingState: 0.1F * getScale()).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:33:28 +00:00
levzzz 1d383e73b3 Merge remote-tracking branch 'hr/main' into fix/pr-4347 2026-10-31 06:33:28 +05:00
levzzzandClaude Sonnet 5.5 bb674debd6 Merge hr/main; smithing preview holds every non-armour item in the left hand
SmithingScreen.updateArmorStandPreview's switch falls to the left hand for a
null slot and for every slot but head, chest, legs and feet (main hand, off
hand, body, saddle), not nothing.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:33:06 +05:00
levzzzandClaude Sonnet 5.5 32b24a9e8e Merge hr/main into fix/screens-batch
Drops the portal loading reason, the shallow-angle sprint check and the
incompatible-server ping, which main ports already; keeps the Reconfiguring
screen on main's level loading, and the held-key restore beside main's
offscreen grab.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:32:51 +05:00
levzzzandClaude Sonnet 5.5 ee837e88be Merge parity/B1-oit into parity/B2-oit
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:32:39 +05:00
rdbtCVSandClaude Opus 5.5 470fb25a89 Merge main into fix/issue-4254-scaffolding-sneak-descend
Resolve the conflict with main's changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:31:44 +00:00
rdbtCVSandClaude Opus 5.5 40fee24b86 Merge main into fix/issue-4127-armor-stand-passenger
Resolve the conflict with main's changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:31:41 +00:00
levzzzandClaude Sonnet 5.5 29c318b541 Translate the OIT shader with terrain.wgsl in the HLSL test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:30:48 +05:00
sirrayiandDeepSeek Harness a01203c3b2 Chat: native mods' commands join the tree the chat colours and completes
Dispatcher::with_mod_commands still does what #463 added, but since WebAssembly
code mods went away nothing but tests called it, so a native mod's command ran
while the chat box drew it as unknown, never completed it and never listed it
(#4613).

native_commands is the hook point that names them (API 23), and chat_tree_for
builds the tree once where its base changes -- at the start of a connection and
when the server sends its own -- so the chat and the completion provider see one
built tree instead of the mods being asked at each keystroke.

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

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 01:30:13 +00:00
levzzzandClaude Sonnet 5.5 a8f57b1997 Merge hr/main; sync shoulder parrots through DATA_SHOULDER_PARROT_LEFT/RIGHT
The layer read the local player's saved compounds, so other players' parrots
never drew and a server-thread window had none. The server now syncs the
OptionalInt variant (Player ids 19 and 20), clients read it for every player,
and Parrot.Variant.byId clamps out-of-range ids instead of using the first.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:30:09 +05:00
Ludovic MarieandClaude Opus 5.5 94e2666542 lan_bot: a click past a chest's menu is ignored
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:29:58 +01:00
Ludovic MarieandClaude Opus 5.5 2541cba005 Container clicks past the menu are ignored, as in vanilla
AbstractContainerMenu.isValidSlotIndex: a click on a slot past the open
menu does nothing. The hub mapped it to the inventory past its 36 slots
(a chest's slot 63 reached the main inventory's slot 9), so a click
could throw or take items outside the menu. The bundle selection
(setSelectedBundleItemIndex) has the same range now.

The packets' codecs: an unknown click type reads as PICKUP (ByIdMap,
OutOfBoundsStrategy.ZERO), and a bundle index below -1 disconnects
(ServerboundSelectBundleItemPacket's constructor).

horizon-bots gains --click, and tools/server_container_click_e2e.py
checks a click on a chest's slot 63 on a dedicated server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:29:58 +01:00
sirrayiandDeepSeek Harness 9c54d6b294 Golems: express the golem patterns with the shared BlockPattern finder
Vanilla has one matcher, BlockPattern.find, for the golem and wither structures;
Horizon had two, so orientation and matching-order fixes had to be made twice
(#4430, one such case was #3929).

The four golem patterns are BlockPattern constants beside the wither's now, and
find_pattern is gone. The search order, orientation set and cell mapping are
unchanged: the tests keep the orientations they had and add the matching order
and the base pattern's wildcard head.

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

Co-Authored-By: DeepSeek Harness <[email protected]>
2026-10-31 01:29:46 +00:00
levzzz 6eac540995 Merge pull request #4862 from levzzz5154/parity/K3-hash
Hash block predicates with nbt or partial predicates and books with filtered text
2026-10-31 06:29:01 +05:00
rdbtCVSandClaude Opus 5.5 93892c4049 Merge main into fix/parrot-stands-when-hurt
Keep both test sets in mobs/tame.rs (main's wolf shake tests, the sitting tests here).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:28:47 +00:00
levzzz 067b5877d2 Merge hr/main 2026-10-31 06:28:19 +05:00
levzzz 52890d2f8f Merge hr/main 2026-10-31 06:28:05 +05:00
reservedbytes 6521dffcee feat(mods): native mods can change their blocks on scheduled ticks 2026-10-31 00:28:03 -01:00
levzzz cbc12f96bf Merge pull request #4873 from levzzz5154/parity/T3-test-verify
Game tests: entity removal, force-loaded chunks
2026-10-31 06:28:01 +05:00
Federico TaglianiandClaude Sonnet 5.5 b1c5484243 Optimize world: level.dat_old stands in for a level.dat that does not read
OptimizeWorldScreen.create reads the level data through
getUnfixedDataTagWithFallback too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:27:20 +01:00
rdbtCVSandClaude Opus 5.5 59dc4a6f14 Merge main into feat/pack-folder-watch
Resolve the conflict with main's changes in game.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:26:56 +00:00
rdbtCVSandClaude Opus 5.5 f1ed2f36e6 Merge main into fix/4080-inventory-effect-tooltip-order
Resolve the conflict with main's changes in game.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:26:52 +00:00
reservedbytes 1028b8f19c fix(tools): pr_ready says when local rust is older than ci's 2026-10-31 00:26:29 -01:00
reservedbytes c832b61617 feat(mods): native mods can change their blocks on random ticks 2026-10-31 00:26:27 -01:00
Dmytro ChuprynaandClaude Opus 5.5 4cc2631bc0 Apply equipment attribute modifiers from the off hand
Include offhand in apply_equipment and treat EquipmentSlotGroup.HAND as both hands in equipment_modifier_applies, matching vanilla 26.3.

Fixes #4777

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:26:25 +01:00
Federico TaglianiandClaude Sonnet 5.5 4e8bdad871 The dedicated server takes level.dat_old when level.dat does not read
LevelStorageAccess.getUnfixedDataTagWithFallback, which the server's Main
calls, falls back to level.dat_old and restores it in place
(restoreLevelDataFromOld). Horizon refused to open such a world unless it
was opened from the world list, which a dedicated server does not have. The
client keeps asking on RecoverWorldDataScreen, as WorldOpenFlows does.
Port map: collectIssues, restoreLevelDataFromOld and readLevelSummary were
still listed as missing after #4219.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:25:28 +01:00
rdbtCVSandClaude Opus 5.5 f77d6da3bd Merge main into fix/tamed-wolf-owner
Keep both new mob_ai cases (wolf_shake/wolf_rain from main, wolf_owner_hurt here).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:24:36 +00:00
rdbt 46a3761a41 Merge pull request #4240 from kacpherek/fix/trouble-portals-carts
Portal trips keep their destination running, and minecarts follow the track steps a server sends
2026-10-31 01:23:28 +00:00
rdbt 8b4aa65bbd Merge pull request #1760 from horizon-rust/perf/structure-start-convoy
World generation: workers build free structure starts instead of queueing
2026-10-31 01:23:23 +00:00
Jose Gil 8676990b18 fix(client): play horse footstep sounds while riding 2026-10-30 21:23:17 -04:00
levzzz 6f899414a7 Merge hr/main into parity/B1-oit 2026-10-31 06:23:17 +05:00
Jose Gil e3bcd0fdba fix(client): return true in remote_start_held_use for goat horn 2026-10-30 21:23:14 -04:00
levzzz a52f904ff5 Merge hr/main 2026-10-31 06:22:24 +05:00
levzzz f4d91f2fbb Merge hr/main into parity/T3-test-verify 2026-10-31 06:22:01 +05:00
levzzzandClaude Sonnet 5.5 cf8cab121c Game tests: test environments (game rules, weather, clock time, difficulty, functions, all_of)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:22:01 +05:00
levzzz 7bcd15e22d Merge remote-tracking branch 'hr/main' into parity/K3-hash 2026-10-31 06:21:45 +05:00
levzzzandClaude Sonnet 5.5 c17233b793 /test verify, locate, pos and create
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:19:39 +05:00
Ludovic MarieandClaude Opus 5.5 c644282b7b lan_bot: the hub tests' guests have loaded their level
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:19:26 +01:00
Ludovic MarieandClaude Opus 5.5 704dff73bd Ignore a player's moves and actions until their client has loaded
ServerGamePacketListenerImpl.hasClientLoaded: until the client sends
ServerboundPlayerLoadedPacket, or 60 ticks pass (60 again after a
respawn), and while the player is dead, vanilla ignores their moves,
vehicle moves, block actions, item use, attacks, interactions, spectator
actions and player commands, and PlayerInput doesn't change crouching.
The server only skipped a rotation-only move before the first position,
and never re-armed the wait after a respawn.

horizon-bots gains --no-loaded, and tools/server_client_loaded_e2e.py
checks a dedicated server ignores such a client for its first 3 seconds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:19:26 +01:00
rdbt 2fb0d5b493 Merge pull request #4870 from horizon-rust/fix/main-goat-horn-return
Fix main's build: the goat horn's held use returns true
2026-10-31 01:18:57 +00:00
sirrayiandomp b8511b4888 LOD: test the all-cached sampler path
Extract the cache fast path and check it returns exactly each cell's
Column::reduced, including negative chunk coordinates and a partial miss.

Co-Authored-By: omp (DeepSeek V4.1 Flash) <[email protected]>
2026-10-31 01:15:11 +00:00
levzzzandClaude Sonnet 5.5 88fd421269 Game tests: remove entities in a cleared test area and on success, force-load the test's chunks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:14:10 +05:00
Cosmic ce1500dbbb Merge pull request #4835 from horizon-rust/fix/pack-path-audit
Resource packs: zip entries never leave the overlay folder
2026-10-30 21:13:30 -04:00
rdbt 3811132bd2 Merge pull request #4261 from Teiwazik/fix/overlay-vanilla-stamps
Pack overlay: stamp vanilla's files by their metadata on Windows
2026-10-31 01:11:17 +00:00
rdbt 4c6d49a02f Merge pull request #4030 from d3grando/fix-3061-pad
Controller toggle-sneak comes back after a screen closes
2026-10-31 01:11:12 +00:00
Ludovic MarieandClaude Opus 5.5 4f17e42f93 Fix main's build: the goat horn's held use returns true
#4085 made remote_start_held_use return whether the use starts, and the
goat horn's arm (from the goat horn PR, merged beside it) still ended in
`return;`. The horn starts its own use (remote_use_instrument), as the
trident and the shield do, so it returns true like them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 02:10:37 +01:00
levzzz ddd3d4d0e3 Merge pull request #4861 from levzzz5154/parity/E2-post-chain-shaders
Post chains run the resources' own GLSL, with texture and depth inputs
2026-10-31 06:07:59 +05:00
rdbt 12e5483917 Merge pull request #4218 from DenisHumen/fix/server-service-windows
Servers: "Run in the background and at login" works on Windows without admin rights
2026-10-31 01:04:07 +00:00
levzzzandClaude Sonnet 5.5 d7ff063035 Hash block predicates with nbt or partial predicates, and books with filtered text, as vanilla does
BlockPredicate.nbt (NbtPredicate flattened to its SNBT string), the partial
DataComponentPredicate map, and Filterable.filtered of writable and written
book pages and titles now reach HashedPatchMap.create. StringTagVisitor moves
to horizon-world so the protocol can use it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 06:03:34 +05:00
sirrayi c597f06d65 Merge remote-tracking branch 'origin/main' into lod-sampler-cache-v2 2026-10-31 01:03:00 +00:00
rdbt c904d2fa1c Merge pull request #4152 from DUzzL/fix/issue-4147-same-click-block-replace
Run a same-click attack before the use in single player
2026-10-31 01:02:45 +00:00
rdbt 094a083540 Merge pull request #4231 from DenisHumen/fix/creative-armor-swap
Creative armour swap gives the worn piece back to the hand
2026-10-31 01:02:41 +00:00
rdbt 5c188c619b Merge pull request #4154 from spidercraft/far-terrain-nether-respawn
Far terrain: the dimension the player is in, not the server's level in place
2026-10-31 01:02:37 +00:00
rdbt cce03e9367 Merge pull request #4085 from DenisHumen/fix/bow-no-arrows
Bows and crossbows: no use animation with nothing to shoot
2026-10-31 01:02:32 +00:00
rdbt c08119ea7a Merge pull request #4139 from DUzzL/fix/issue-4133-toggle-sprint-alt-tab
Keep latched toggle sprint and sneak across a short focus loss
2026-10-31 01:02:28 +00:00
levzzz 088390b4fe Merge remote-tracking branch 'hr/main' into parity/E2-post-chain-shaders 2026-10-31 06:00:47 +05: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
Ludovic Marie 4f5c8307e8 Merge pull request #4852 from horizon-rust/fix/creative-slot-validation
🤖 Agent: proved with tools/server_creative_slot_e2e.py on a dedicated server: slots 1..45 within the stack size are kept; slot 0, slot 46, a sword ×2 and pearls ×17 are ignored; a stack of 100 disconnects (the same run fails on main). pr_ready --quick green; dedicated_server, server_entities, server_player_damage pass.
2026-10-31 01:58:46 +01:00
levzzzandClaude Sonnet 5.5 b3516f66e2 Post chains run the resources' own GLSL: texture and depth inputs, translated with naga
PostChain/PostPass over any shader the active resource root provides
(vanilla's post shaders and packs' own), translated GLSL to naga at run
time and bound as vanilla binds them (SamplerInfo, Globals, the pass's
uniform blocks, a sampler per input). Targets keep GL's rows. The WGSL
stand-ins are the fallback when a pass does not translate.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:56:47 +05:00
rdbt db83571428 Merge pull request #4597 from xFedeT/fix/move-through-village-map
Port map: MoveThroughVillageGoal is ported for zombies
2026-10-31 00:54:38 +00:00
levzzz c58580ead2 Merge pull request #4853 from levzzz5154/parity/T2-test-retries
Game tests: retries and flaky tests
2026-10-31 05:54:15 +05:00
mularealandAntigravity 1aae74b8b1 death: play the player death sound only once on fatal damage
Fixes #4570

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 01:53:07 +01:00
levzzz 270431ec55 Merge pull request #4851 from levzzz5154/fix/win-disk-space-unsafe
Fix the Windows build: free disk space without unsafe code
2026-10-31 05:53:00 +05:00
rdbt 6c9ab657fd Merge pull request #4481 from xFedeT/fix/parrot-follow-mob-goal
Parrots follow other mobs and hover near them (FollowMobGoal)
2026-10-31 00:52:36 +00:00
rdbt 4851a7ab4d Merge pull request #4542 from xFedeT/fix/piglin-back-up
Crossbow piglins back away from a target within 5 blocks (BackUpIfTooClose)
2026-10-31 00:51:59 +00:00
levzzz 7570dcff24 Merge pull request #4843 from levzzz5154/parity/I-aniso
Texture Filtering (RGSS, Anisotropic) and Max Anisotropy on terrain
2026-10-31 05:47:13 +05:00
rdbt 9e2440253f Merge pull request #4357 from xFedeT/fix/spawn-egg-peaceful-tooltip
Spawn egg tooltip: the red "Disabled in Peaceful" line for a monster's egg
2026-10-31 00:46:55 +00:00
levzzz ee4108fdaf Merge remote-tracking branch 'hr/main' into parity/T2-test-retries 2026-10-31 05:46:07 +05:00
levzzzandClaude Sonnet 5.5 5d41303466 Game tests: retries and flaky tests
RetryOptions (/test run .. numberOfTimes untilFailed), ReportGameListener's attempt and success counters, GameTestRunner.rerunTest and runScheduledRerunTests, max_attempts / required_successes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:46:06 +05:00
levzzzandClaude Sonnet 5.5 0e515dbfd3 Improved transparency: water mask and cloud passes
LevelRenderer.executeOitWaterMask and CloudRenderer.renderOit.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:45:56 +05:00
levzzz 5747b52d0c Merge pull request #4845 from levzzz5154/parity/T2-error-marker
Test instance error markers: filled box and billboard text
2026-10-31 05:45:26 +05:00
levzzzandClaude Opus 5.5 bc105393f0 Fix the Windows build: read free disk space without unsafe code
#4732 called GetDiskFreeSpaceExW through an unsafe extern block in
save.rs, but the client lib is #![forbid(unsafe_code)], so every
Windows build failed. Linux CI never compiles the cfg(windows) branch.
fs4::available_space answers the same question safely on every
platform, so the same code now runs on Linux CI too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 05:44:09 +05:00
Federico Tagliani 0161c4d30b Merge remote-tracking branch 'origin/main' into fix/explorer-map-references 2026-10-31 01:43:21 +01:00
Ludovic MarieandClaude Opus 5.5 5fe97cd6d1 Mods page: native mods installed but not built say so
Options > Mods said "No mods: put .hmod files in the horizon-mods
folder" while the launcher had installed native mods (.hnmod) that this
game wasn't built with, so players copied the files into horizon-mods
(#4770). Each such mod now has its row, "(Not built)", whose tooltip
says to build the game with it in the launcher and play the instance
that build is in. A native build's own mods (its native-mods.json) and
mods already listed get no second row. The toggle is the mod.<id> line
the launcher's next build follows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:40:33 +01:00
levzzz e1c4d5dc88 Merge remote-tracking branch 'hr/main' into parity/T2-error-marker 2026-10-31 05:39:02 +05:00
levzzzandClaude Sonnet 5.5 49b4dbec3d Test instance error markers: filled translucent box and billboard text
TestInstanceRenderer.submitErrorMarker: Gizmos.cuboid with a fill (not a stroke) and Gizmos.billboardText at scale 0.16, always on top.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:39:01 +05:00
levzzzandClaude Sonnet 5.5 2e1da6db52 Texture Filtering (None, RGSS, Anisotropic) and Max Anisotropy on terrain
Port LevelRenderer's chunk layer sampler (linear, clamped, anisotropy when
Anisotropic) with texture_sampling.glsl's sampleNearest and sampleRGSS in
terrain.wgsl. None keeps the nearest sampler and plain sampling. The two Video
rows are enabled; Max Anisotropy greys out unless Anisotropic is chosen.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:38:43 +05:00
Jose Gil 1f2ebdbd91 feat(client): add Spanish language support for Horizon settings 2026-10-30 20:37:52 -04:00
Ludovic Marie 0820541751 Merge pull request #4796 from horizon-rust/fix/dedicated-op-ip-bans
🤖 Agent: proven on the Mac mini: server_ops_e2e (Horizon client as op, levels 2/3/4), dedicated_server, server_ops_feedback, chat_screen, command_parse, command_exec; pr_ready --quick green. Stacked on #4795: merge after it.
2026-10-31 01:37:41 +01:00
Iam-Sanja cd53a3c0dd fix: prevent dedicated world saves from overwriting player data 2026-10-31 00:34:54 +00:00
levzzzandClaude Sonnet 5.5 9fd99d5a4d Improved transparency: the order-independent terrain pass
GameRenderer.useImprovedTransparency gates LevelRenderer.executeOit for the translucent terrain: depth bounds, transmittance and accumulate stages against the opaque depth, executeDepthBoundsCull, and the composite. Off: unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:34:32 +05:00
Ludovic MarieandClaude Opus 5.5 4ecf521195 Launcher: native builds open no console window on Windows
cargo, horizon-mixins and rustc are console programs and the launcher
has no console to share, so a native build showed an empty cmd window
for as long as it ran (#4770). Toolchain::command now starts them
through process::command, with CREATE_NO_WINDOW like the launcher's
other background programs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:34:02 +01:00
Ludovic MarieandClaude Opus 5.5 adf54b0c68 Launcher: say what a Linux native build lacks before it starts
A native build on Linux compiles C (ring, hidapi) and builds against the
system's ALSA and libudev through pkg-config. Desktop distributions ship
none of their development files, and SteamOS's GCC has no C library
headers, so builds failed minutes in with a raw cargo error (#4770). The
check before downloading now looks for cc, the C library's headers,
pkg-config, alsa and libudev, and names what is missing with the
install command for the distribution in /etc/os-release.

A failed build's error also starts with cargo's first `error` lines,
which a build script's output pushed out of the last twelve.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:31:05 +01:00
mularealandAntigravity f554aa5a6c mobs: play slime and magma cube attack sound only when hurtServer succeeds
Fixes #4569

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 01:30:51 +01:00
Ludovic MarieandClaude Opus 5.5 11eecf5a6a Resource packs: zip entries never leave the overlay folder
A pack's zip entry named with `..` parts was joined to the overlay folder as
it was and written there, so a pack (a server's too) could put a file outside
it. Entries with empty, `.` or `..` parts, `:` or NUL are now left out, and
overlay directories are one plain folder, as vanilla's DIR_VALIDATOR has it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:29:01 +01:00
Ludovic MarieandClaude Opus 5.5 f472da81d1 Dedicated server: /ban-ip, /pardon-ip and /banlist for operators
#4680 let operators run /op, /deop, /ban, /pardon and /whitelist in game;
the IP bans and the ban list were still console-only, so an op typing them
got "Unknown or incomplete command" while the client's tree offered them.
BanIpCommands (an address or an online player's, its players kicked with
ip_banned), PardonIpCommand and BanListCommands (told to the source only),
at admins' level (3) as in vanilla, on the dedicated tree only.

tools/server_ops_e2e.py: a Horizon game as an op at ops.json level 2, 3 and
4 types /tp, /gamemode, /op, /deop, /whitelist, /ban-ip, /banlist,
/pardon-ip, /kick, /ban and /pardon on a dedicated server with bots as
targets; levels 3 and 4 run them all, level 2 only /tp and /gamemode.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:20:26 +01:00
huiandClaude Opus 5.5 d9517672ef Native mods: recipes of a mod's own types (API 23)
A recipe file whose type is outside minecraft stays loaded as its JSON, by
type: native_recipes_of_type reads them, and native_recipe_ingredient and
native_recipe_result read the usual fields.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:17:35 +01:00
Jose Gil 0b912926a3 feat(launcher): localize Mods browser, filters, and GitHub mods panel 2026-10-30 20:05:42 -04:00
huiandClaude Opus 5.5 d65cd8af19 /item reaches a connected player's slots
slots_named only had slots for the player in place, so from the console
every player slot was "not found". It now goes through for_player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 01:04:22 +01:00
sirrayiandomp 15dce73eb4 LOD: skip density work for tiles wholly inside visited chunks
VanillaLodSampler::grid evaluated the whole density lattice first and only
then checked its per-cell cache, so a tile covered by visited chunks paid for
every sample and discarded all of them. Check the cache first and return the
reduced columns directly when every cell is covered.

Co-Authored-By: omp (DeepSeek V4.1 Flash) <[email protected]>
2026-10-31 00:01:33 +00:00
arismoko f98890b386 Native mods: add server block-setting helper 2026-10-30 19:00:52 -05: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
Ludovic MarieandClaude Opus 5.5 9f4f39addc Creative-slot packets follow vanilla's rules on the server
handleSetCreativeModeSlot takes slots 1 to 45 only (slot 0, the crafting
result, and anything past the inventory menu are ignored) and a stack no
bigger than its own stack size. Its codec takes counts of 1 to 99: more
fails the packet and disconnects the player. The server took slot 0 and
any count. The drop path (slot < 0) now uses the same rule.

horizon-bots gains --give SLOT:ID:COUNT,... and
tools/server_creative_slot_e2e.py checks it on a dedicated server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:54:28 +01:00
reservedbytes 50cd60645b feat(mods): content mods declare their own mob effects 2026-10-30 22:53:59 -01: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
Federico TaglianiandClaude Sonnet 5.5 b57d440412 Spawn egg tooltip: any item with entity_data, and an id without its namespace
TypedEntityData.addToTooltip hangs on the entity_data component, not on spawn
eggs. SulfurCube is allowed in Peaceful in vanilla (no notAllowedInPeaceful), so
the egg correctly shows no line.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:50:28 +01:00
Federico TaglianiandClaude Sonnet 5.5 253b1f8780 Spawn egg tooltip: the red "Disabled in Peaceful" line in Peaceful
TypedEntityData.addToTooltip shows it under an egg whose entity type is not
allowed in Peaceful (a monster); Horizon showed only the op-only warning.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:49:25 +01:00
Federico TaglianiandClaude Sonnet 5.5 d671006122 FollowMob matches vanilla tick for tick: goal name, update interval, mob_ai scenarios
tools/test_mob_ai_parity.py gets parrot_cow_0..2 and parrot_cow_far: the
parrot and the cow match vanilla's FollowMobGoal for 300 ticks (position,
goals, look control, path and random draws).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:49:06 +01:00
Federico TaglianiandClaude Sonnet 5.5 bddb7c34be Parrots follow other mobs and hover near them (FollowMobGoal)
register_parrot lacked the priority 3 FollowMobGoal(1.0, 3.0, 7.0): a parrot
flies after the first visible mob of another kind within 7 blocks and keeps
about 3 blocks from it. lan_e2e check parrot_follows_mob.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:48:49 +01:00
Jan PresandOpenCode 23bceb746d Send a joining player its own join message
PlayerList.placeNewPlayer adds the player to the list and then calls
broadcastSystemMessage, so the newcomer reads "X joined the game" at
the top of its chat. Horizon broadcast the line with broadcast_except,
which skipped exactly that player; the message also went out before the
player was placed in the list.

Place the player first and send the line to every placed guest after the
initial packets, matching placeNewPlayer's order.

Fixes #4536

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-30 23:48:36 +00:00
Federico TaglianiandClaude Sonnet 5.5 0c66d4678d RandomStandGoal: named as vanilla's, mob_ai herd scenarios for the equines
The row said horses rear with the angry sound and donkeys and mules never
get the goal. They do (the ambient sound, baby variants included); what
was missing was a scenario. horse, donkey, mule, zombie_horse and
skeleton_horse herds (8 each, 1200 ticks) and single equines now match
vanilla; the goal's name was 'RandomStand', vanilla's list says
'RandomStandGoal'.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:48:25 +01:00
Federico TaglianiandClaude Sonnet 5.5 9ad0a3abf1 Crossbow piglins back away from a target within 5 blocks (BackUpIfTooClose)
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:47:45 +01:00
Federico TaglianiandClaude Sonnet 5.5 90e269dc36 Port map: MoveThroughVillageGoal is ported for zombies
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:47:20 +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
814a8592da Treat only integer SNBT bytes as style flags.
Co-Authored-By: Composer <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-10-31 00:43:28 +01:00
43f142115b Fix chat open_url links from legacy clickEvent JSON.
Tellraw/SNBT still send clickEvent with value and 1b style flags;
parse those so links underline and open ConfirmLink like vanilla.

Co-Authored-By: Composer <[email protected]>
Co-authored-by: Cursor <[email protected]>
2026-10-31 00:41:22 +01:00
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 0bfa991471 Fix 8BitDo Ultimate 2 right-stick look on Linux.
gilrs' bundled SDL DB only has a Windows row for this pad; on Linux/BT the
right stick sits on ABS_Z/ABS_RX, so Horizon never turned the camera. Inject
the missing SDL mappings and fall back by vendor/product when no row matches.

Fixes #4343

Co-Authored-By: Cursor <[email protected]>
2026-10-31 00:33:40 +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
huiandClaude Opus 5.5 ed7f8b3998 Merge main; redstone signal hooks are API 23
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:25:35 +01:00
huiandClaude Opus 5.5 429fda5680 Merge main; item behaviour hooks are API 23
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:24:21 +01:00
huiandClaude Opus 5.5 fe4b750827 Merge main; mod messages and event hooks are API 23
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:24:20 +01:00
Jose Gil f15483df83 fix(launcher): constrain telemetry notice width in settings to prevent overflow 2026-10-30 19:21:32 -04:00
Dufru 551be35cc3 i18n(launcher): add Brazilian Portuguese (pt-BR) translation 2026-10-30 20:16:27 -03:00
huiandClaude Opus 5.5 e35be2e5bc Native mods: helpers for spawning and moving mobs (API 23)
native_spawn_mob, native_mob, native_mobs, native_mob_teleport,
native_mob_push, native_mob_set_health, native_kill_mob and
native_remove_mob, in the new native_mobs.rs. No new hook points.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:15:50 +01:00
Jose Gil d72c67d81a fix(launcher): shorten Spanish sponsor discount label to prevent chip overflow 2026-10-30 19:15:06 -04:00
reservedbytes 2d0c6e984f feat(mods): native mods get the game's config folder 2026-10-30 22:07:54 -01:00
arismoko a5ff7b1b31 Native mods: add per-level tick hooks 2026-10-30 18:05:12 -05:00
arismoko 45f155e801 Tidy native particle queue 2026-10-30 18:00:35 -05:00
apeirsandClaude Opus 5.5 bcb2087d2f Fluids: spread without cloning shapes or locking per step
FlowingFluid.canPassThroughWall runs for every step of a spread's slope
search. It cloned up to four VoxelShapes per call (the shared block and
empty shapes among them: five atomic reference counts each, on cache lines
every generating thread also touches) and took the global occlusion cache's
mutex once or twice. The shapes are now borrowed and compared by identity
(is_block, a new is_empty_identity), and the cache is an RwLock. The
spread's SpreadContext caches are flat arrays around the origin instead of
two HashMaps. Same comparisons, same results; only the cost changes.

Fixes #4743

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:57:23 +01:00
ElyTheCatandAntigravity 337692aab3 fix(resident): share section_box helper, test tight bounds, and pad for shader waving
- Share section_box(min, bounds) helper between visible and in_frustum.
- Restrict Resident::insert to #[cfg(test)], removing #[allow(dead_code)].
- Exercise randomised tight bounds in column_walk_finds_exactly_the_sections_in_view.
- Add SectionMesh::compute_bounds unit test in mesher.rs.
- Expand BOUNDS_PAD to 0.5 blocks to accommodate vertex displacement from shader packs.

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 02:21:40 +03:30
Ludovic Marie c95194b676 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 23:49:21 +01: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
Łukasz KopańskiandCursor 9bab876eb4 Clear portal end crystal revival beams when respawn ends
Portal crystals on the exit portal were left out of reset_spike_crystals,
so their beam targets could stay set after a successful revive or abort.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 23:41:12 +01:00
reservedbytes 2f41aedb39 fix(combat): starvation is recorded once 2026-10-30 21:41:04 -01:00
martipops e8c951cb3a Merge remote-tracking branch 'upstream/main' into split/02-audio 2026-10-30 18:37:10 -04:00
Ludovic Marie 7192a53328 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 23:36:46 +01:00
Łukasz KopańskiandCursor 230ae976e1 Format SetTime clock picker for rustfmt
Co-Authored-By: Cursor <[email protected]>
2026-10-30 23:34:26 +01:00
Łukasz KopańskiandCursor e5f3507585 Fix server day/night by applying only the level's world clock
SetTime packets can update the End clock alone; falling back to the first
clock made overworld clients take game_time as day_time, so the sky looked
inverted while the server still refused beds during day.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 23:27:41 +01:00
Ludovic Marie 1f3ac390da Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 23:26:44 +01:00
Sticky-Bug a834bdb7cb Merge remote-tracking branch 'origin/main' into feat/monochrome-logo-option
# Conflicts:
#	crates/horizon-client-render/src/level_loading.rs
2026-10-31 03:44:56 +05:30
Ludovic Marie a01414e035 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 23:14:28 +01: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
ElyTheCatandAntigravity 09ecc06126 fix(gl): drop premature Immediate startup default and clarify EGL swap intervals
- Revert startup present_mode default in gfx.rs to preserve Mailbox -> Fifo
  ordering for Vulkan and DX12 before settings load.
- Add comments in egl.rs explaining the configure-time vs present-time swap
  interval fallback handling.
- Note regression guard purpose for the GLES [Fifo, Immediate] present mode test.

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 01:41:18 +03:30
huiandClaude Opus 5.5 fbb8f4e6d7 native_inventory_tick leaves the stack in its slot and runs when frozen
A helper called from the hook could fill the emptied slot, and putting the
stack back then lost what it gave. The hook now gets the slot, and
native_carried_stack reads and changes the stack in place. The host's call
moves out of `if running`: /tick freeze stops the world, not the player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:09:18 +01:00
huiandClaude Opus 5.5 53aa834866 Native mods: hook points for redstone signals from a mod's block (API 22)
native_block_signal (BlockState.getSignal and getDirectSignal),
native_is_signal_source (BlockState.isSignalSource) and the helper
native_update_signal_neighbors (Level.updateNeighborsAt).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:57:11 +01:00
ElyTheCatandAntigravity 19de197927 test(light): bound scratch queues and pin lighting against baseline algorithm
- Add clear_queues() on LightScratch to shrink queue and sky_queue when peak capacity exceeds 65,536 elements.
- Add old_light_chunk baseline in test module to preserve pre-optimization lighting algorithm.
- Add test light_chunk_matches_old_algorithm comparing new and old lighting outputs across random chunk regions, including Nether (sky_light=false) and missing neighbour configurations.
- Add test scratch_queues_shrink_above_capacity_cap verifying bounded scratch buffer retention.

Co-Authored-By: Antigravity <[email protected]>
2026-10-31 01:22:48 +03:30
umcekoandClaude Opus 5.5 ce208cac3a Port pack: an override's target is a look only if no item needs it
Targets matched by path alone, so an override to another namespace's item/foo
dropped the mod's own item/foo, and a registered item an override points to lost
its definition. Now only the mod's own namespace counts, the oracle's registered
items are never dropped, and models that only reach each other stay items.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:52:10 +03: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
umcekoandClaude Opus 5.5 98ecd84b54 Port pack: armour names only an equipment asset that exists
A material carry writes no asset for (layers outside models/armor/, a namespace
the mod doesn't own) kept its asset_id, so the armour drew nothing. It now keeps
the template's asset and reports a difference. Missing items.json in the prepared
resources is logged, as vanilla_blocks' failure is, instead of stopping the carry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:51:39 +03:00
umcekoandClaude Opus 5.5 99050f7ac2 Merge mods/port-armor-equipment into mods/port-no-phantom-items
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:51:39 +03:00
umcekoandClaude Opus 5.5 e73e31c709 Merge main into mods/port-armor-equipment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:50:45 +03:00
Ludovic Marie 890e625fc7 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 22:47:46 +01:00
foudretbrandClaude Opus 5.5 c094313726 Foxes screech at night when no player is near (#4533)
Fox.getAmbientSound: when it isn't bright outside, one time in ten, an
awake fox with no non-spectator player within 16 blocks of its box picks
entity.fox.screech. Fox.playAmbientSound plays it at volume 2; anything
else goes through Mob.playAmbientSound, which draws getAmbientSound again
and plays its pick through makeSound.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:46:07 +01:00
Łukasz KopańskiandCursor 703f9ec127 Apply server.properties difficulty after load_world.
forceDifficulty only set ui.difficulty; health_rules (despawn/spawn) and
level.dat still followed the world's saved difficulty, so peaceful in
server.properties left slimes alive until /difficulty peaceful.

Co-Authored-By: Cursor <composer>
2026-10-30 22:45:34 +01:00
GIGABAIT93andClaude Opus 5.5 cd52b16c8f Merge main (#4468 seats displays on anything)
main's comment over the display pass now says the vehicle's attachment
is in its scale, as riders::passenger_attachment gives it here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:43:43 +02:00
Łukasz KopańskiandCursor f935ff68a5 Cull back faces on translucent dropped items.
ITEM_TRANSLUCENT draws without depth writes, so both sides of a slime/honey
shell were stacking partial alpha and looking opaque. Glass stayed fine
because most of its texels discard at alpha 0.

Co-Authored-By: Cursor <composer>
2026-10-30 22:42:48 +01: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
Łukasz KopańskiandCursor d9d8f5e2e8 rustfmt LOD leaf tint match arm.
Co-Authored-By: Cursor <composer>
2026-10-30 22:36:24 +01:00
Łukasz KopańskiandCursor 5168e4db30 cargo fmt
Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:30:43 +01:00
Łukasz KopańskiandCursor 5e2e5b4291 Drop unrelated item_render WIP from the fmt commit.
Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:30:19 +01:00
Łukasz KopańskiandCursor 05d810136c cargo fmt
Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:29:54 +01:00
huiandClaude Opus 5.5 4b8f735c7b Say which mobs native_mob_spawned reports as not natural
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:28:15 +01:00
Łukasz KopańskiandCursor 8036e0de7f Keep cherry leaf pink in far LOD terrain.
LOD applied the biome foliage tint to every *_leaves block. Cherry (and
other pre-coloured leaves) already carry their colour in the texture, so
that tint turned cherry groves yellow at a distance (#4688).

Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:26:31 +01:00
Łukasz KopańskiandCursor 874c4d1cdb Play empty-bucket sounds on the predicting client.
BucketItem.emptyContents uses playSound(player): the client must hear the
empty (or nether evaporate) sound from its DryLevel prediction, and the
server must leave that guest out of the broadcast (#4684).

Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:24:22 +01:00
Łukasz KopańskiandCursor 88a19fab33 Play axe strip sounds only once for the actor.
The client already predicts strip/scrape/till in predict_item_on_block;
Level.playSound(player) must leave that guest out of the broadcast so the
same sound is not heard again from lan_broadcast_effects (#4681).

Co-Authored-By: Cursor <[email protected]>
2026-10-30 22:22:31 +01:00
Muhammad Asif 7fb5b6f214 Give boats ice friction from the hull's collision slab
AbstractBoat.getGroundFriction averages the friction of blocks whose
collision shape meets a thin box just under the hull. Horizon only
counted full_collision or legacy_solid cells, so ice (and other
non-full solids) left the boat InAir at 0.9 instead of OnLand.

Fixes #4421
2026-10-30 21:19:14 +00:00
Ludovic MarieandClaude Opus 5.5 0d0e9d4213 Floating kick: a chunk not loaded yet is not air around the player
#4622's noBlocksAround read every block through World::block, which gives
air for a chunk the server has not loaded. A survival player's first move
after joining a dedicated server can come before the spawn chunks are
generated: the move counted as floating and nothing cleared it, so the
player was kicked 80 ticks later ("Flying is not enabled on this server").
Vanilla loads the chunks it reads and sees the ground. cursor_death and
pearl_death failed about every other run on it (#4526's parity run).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:15:48 +01:00
huiandClaude Opus 5.5 4c88da0096 Merge main; item behaviour hooks are API 22
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:12:40 +01:00
huiandClaude Opus 5.5 6830f201d5 Merge main; mod messages and event hooks are API 22
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:12:39 +01:00
DenisHumenandClaude Opus 5.5 8b23993cfb lan_e2e looting: register the check, count picked-up drops
The check was missing from the dispatch table (KeyError), and the guest
picks the porkchops up before they are counted: count the ground and
both players' inventories.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:04:38 +02:00
arismoko 16cb6a1204 Validate content registry identity data 2026-10-30 16:03:46 -05:00
huiandClaude Opus 5.5 3c257d870c Native mods: hook points for item behaviour (API 21)
native_hurt_enemy (Item.hurtEnemy), native_inventory_tick
(Item.inventoryTick, every carried stack each tick; the mod may change it)
and native_on_equip (LivingEntity.onEquipItem).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:03:43 +01:00
Ludovic MarieandClaude Opus 5.5 907ae1bbb1 Merge origin/main; a fallback pool's guaranteed elements are tried too
Main's YUNG rules (#3bc38cebb: priority, min_required_depth, ignore_bounds,
the start not counted) stay as they are in `Rules`; Lithostitched's limited
and guaranteed elements keep their own `Limit` beside them, checked by both
vanilla and YUNG draws, and `Pick` carries either one's key.

rdbtCVS's review: the vanilla candidates listed only the target pool's
guaranteed entries and filtered every pool's out of the draw, so a fallback's
guaranteed elements were never tried, at the most depth either. Each pool now
lists its own guaranteed entries first when it is reached
(`a_fallbacks_guaranteed_entries_are_tried`). docs/mods.md says so.

The structure layout harness keeps main's yung-probe profiles beside
lithostitched-probe.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:56:12 +01:00
Ludovic Marie fd16400b91 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 21:51:28 +01:00
Ludovic MarieandClaude Opus 5.5 23f553e285 Merge origin/main: Intel Mac builds in game-build.yml and the Nightly
Main builds the game in game-build.yml and publishes it as the Nightly
(nightly-build.yml) since #3342; release.yml only decides. So the macos-x64
matrix entry, BIN and --target move to game-build.yml, the Nightly signs and
publishes horizon-macos-x64.zip, launcher.yml keeps main's smoke test with
the x64 entry and a guard while no build has an Intel asset, and
platform_assets_are_published reads game-build.yml and nightly-build.yml.
horizon-server's updater (newer than this branch) gets the Intel asset too.
website/ moved to horizon-site (#2286): its changes are dropped here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:48:04 +01:00
huiandClaude Opus 5.5 25950c64ad Native mods: mod messages and event hook points (API 21)
Hook points for a mod's server and client halves to talk
(native_net_from_client, native_net_from_server, with the send helpers in
native_net.rs, on the new horizon:mod_net payload), and for hearing players
and mobs: player_died, mob_spawned, mob_killed, item_dropped and
item_picked_up.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:47:22 +01:00
JustKarkatandZCode 31054f520e Merge main into the branch; the hook point is API 21
Main consumed hook APIs 14 to 20 (block breaking/placing, mod block
entities, containers, menus, screens and their buttons). The
player-health HUD rows hook takes API 21: the doc line, the version,
the catalog entry and the docs paragraph move with it. The hook's call
site in hud_layers merged unchanged.

Co-Authored-By: ZCode <[email protected]>
2026-10-30 15:46:00 -05:00
Ludovic Marie a62fa7375d Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 21:45:33 +01:00
Neferr fb4fcb04ec Native mods: a hook point for each player's server tick (API 22)
native_player_tick runs once a tick for every living player in the world, at the end of their ServerPlayer.tick (after digging, item use, cooldowns, statistics and advancements), inside their own turn so the helpers read that player. Fixes #4663.
2026-10-30 21:39:58 +01:00
DenisHumenandClaude Opus 5.5 23200373f6 Bundles: the wheel selects in a crafting table's and a station's menu
The client offered the wheel to a bundle in the player's inventory only
in the inventory and chest-like menus, and the server read the slot of
ServerboundSelectBundleItemPacket as the inventory menu's. In a crafting
table, an enchanting table or another station the bundle never scrolled
(#4436). Now the inventory part of any menu takes the wheel, and the
server maps a crafting table's or station's slot onto the guest's
inventory.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:39:09 +02:00
Łukasz KopańskiandCursor b6c585706f Sit up on a cushion after swimming.
A cushion seat clips the feet into the support block, so pose fitting
rejects standing and would keep a prior swim pose. Force standing for
passengers when that happens, and use the passenger pose path on the
server while a guest rides.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:38:40 +01:00
DenisHumenandClaude Opus 5.5 daa6d3b1ca Looting on the killer's weapon counts on a server's mobs
A mob's death runs in the server's tick, after the swing that killed it,
so drop_death_loot read the killer's weapon from the server's own player
(self.held()), who holds nothing. On the server thread every player is
a guest, so Looting never added to a mob's drops (#4496). The killer is
now the guest who struck the last blow, with their hands and armour as
EnchantmentHelper.getEnchantmentLevel reads them. lan_e2e gets a
`looting` check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:38:15 +02:00
Łukasz KopańskiandCursor 468dd3c24b Sync SulfurCube MAX_FUSE so clients show the fuse swell.
Guests never received entity data id 20, so a primed cube jumped
straight to the boom with no scale animation. Push/apply the fuse and
tick it down on the client for the renderer.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:35:23 +01:00
Neferr 0c9b29d20a Native mods: a hook point for left-clicking a block (API 21)
native_left_click_block hears the start of a player's dig (the block, the face hit) where ServerPlayerGameMode.handleBlockBreakAction takes it, after its reach, spawn protection and world border checks, before Block.attack or a creative break; a mod that cancels it keeps the block, which the ack puts back on the client. Fixes #4654.
2026-10-30 21:28:23 +01:00
Łukasz KopańskiandCursor 9b6a9a6b55 Sort anim.rs imports for rustfmt.
Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:19:50 +01:00
Neferr 1698ff41f8 Native mods: a hook point for right-clicking an entity (API 20)
native_interact_entity hears a player's right click on an entity (its id and type, and the stack in the hand) where Player.interactOn starts, once per hand, on the server for every player; a mod that claims it swings the hand instead of the entity's and the item's own interaction. Fixes #4647.
2026-10-30 21:17:22 +01: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
JoansitohandClaude Sonnet 5.5 7ee905eab2 Merge upstream/main into fix/skins-after-pack-reload
Adopt main's SkinSlots for skins: add SkinSlots::forget and call it, with
the map slots' forget, once the new atlas is installed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 21:14:09 +01:00
GIGABAIT93andClaude Opus 5.5 2977badbb8 sync_riders: name what it leaves out, not what it keeps
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:13:32 +02:00
Ludovic MarieandClaude Opus 5.5 bfeb1e69a8 Merge origin/main; server-next: S-5 is done (owners.md), Appendix A is a snapshot
docs/README.md keeps main's smp.md and owners.md lines beside server-next.md
(DUzzL's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:13:14 +01:00
Ludovic MarieandClaude Opus 5.5 fc4641bc8e Shared loot tables: a table changed on disk is read again
rdbtCVS's review: the key held the root's path and the packs' files, not the
root's own files, so a loot table edited at the same path while an Items or
LootData still held the map got the old one. The key now also hashes each
root table's path, size and modification time; a test edits a table while
the first map is held.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:11:45 +01:00
GIGABAIT93andClaude Opus 5.5 563ce7338e Merge main; scale the rider seat before the rearing offset
passenger_attachment scales main's base_attachment, then adds
equine::rear_offset unscaled (AbstractHorse.getPassengerAttachmentPoint).
seat() moves above ride()'s doc comment; position_riders seats a mob on a
text display on the first pass only; sync_riders says why display mounts
aren't its concern.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:10:20 +02:00
Łukasz KopańskiandCursor 0d9dcfd247 rustfmt the seated-enderman anim test.
Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:09:39 +01:00
JoansitohandClaude Sonnet 5.5 e10efca1bc horizon-threads: check the junction reparse buffer's length sums
The mount point buffer added name_bytes + 2 and + 12 in u16, which overflows
for paths near the 32k-character limit. Build it in a helper with checked
arithmetic that returns InvalidInput, so the caller falls back to copying,
and pin the layout with a Windows unit test.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 21:07:56 +01:00
JoansitohandClaude Sonnet 5.5 8ae79aaad4 Merge upstream/main into fix/windows-pack-reload-speed
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 21:07:28 +01:00
JoansitohandClaude Opus 5.5 3f38f88bee Arrows fly into whoever picks them up on servers and the server thread (#4486)
ClientPacketListener.handleTakeItemEntity adds an ItemPickupParticle for any
taken entity and plays the pickup sound where it was. A server's client only
did so for items and orbs: a taken arrow or trident vanished at once. It now
flies to its collector and sounds at the arrow, as on a LAN guest.

horizon-ctl's status counts the pickup particles by kind (`pickups`), and
lan_e2e's `arrow_pickup` check has each player pick up an arrow and both
clients draw it flying.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:04:30 +01:00
Łukasz KopańskiandCursor 88e36f9220 Retrigger verify after a flaky Mojang lookup in horizon-server lists.
Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:03:40 +01:00
Łukasz KopańskiandCursor 355b5f099e Sit an enderman that rides a boat.
humanoid_pub ignored isPassenger, so EndermanModel never got the
humanoid riding pose before its limb clamp.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 21:03:09 +01:00
Ludovic Marie f8f1679153 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 21:00:18 +01:00
Jan PresandOpenCode d049c7a175 Place a rider ejected by an activator rail beside the cart
AbstractMinecart.activateMinecart ejects its passengers on a powered
activator rail, and getDismountLocationForPassenger puts them on the
nearest block. Horizon cleared the ride but left the player at the seat
position, so the cart appeared to keep its rider.

tick_cart_riding now runs the same dismount placement when the tick
cleared the ride, for the window's player and for a server player
(each body goes through this function).

Fixes #4562

Co-Authored-By: OpenCode (DeepSeek V4.1 Flash) <[email protected]>
2026-10-30 19:58:11 +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
Łukasz KopańskiandCursor 0c1b92ce82 Fix TNT bounce unit test attributes after a bad insert.
Co-Authored-By: Cursor <[email protected]>
2026-10-30 20:49:22 +01:00
Łukasz KopańskiandCursor d2839ff264 Bounce primed TNT when a slime piston hits it.
Fixes #4550: moveCollidedEntities' slime causeBounce skipped PrimedTnt,
so TNT sitting on slime did not launch when a piston pushed the block.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 20:46:52 +01:00
DUzzL 0a568f21aa Add armor stand targets to the mace parity harness
Hits on a living armor stand now run through the same oracle and fixture
as mob hits: smash, Wind Burst, nearby knockback and weapon wear, with a
shared hit_armor_stand entry in the game. The stand's own wobble and break
effects are left out of the comparison.
2026-10-30 20:45:16 +01:00
apeirsandClaude Opus 5.5 24e36962aa Chunk uploads: a 2 ms cap per frame when the player set no budget
With Chunk Updates and Upload Budget at 0 (the defaults), update_world
uploaded every mesh finished that frame, so a burst of finished sections
landed in one frame. Without the player's budgets, a burst now goes through
the queued path and uploads stop once they took 2 ms of the frame (at least
one per frame), nearest sections first; a frame's batch of 8 or fewer still
uploads at once without queueing. The player's own budgets work as before.

Fixes #4605

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:19:47 +01:00
k3yhandClaude Opus 5.5 da4ded1d7c Windows Vulkan: wait for the acquire fence only with VSync (gfx-rs/wgpu#10714)
wgpu-hal 30 waits on Windows for a fence that vkAcquireNextImageKHR
signals, in every present mode. It was added for FIFO, where AMD and
NVIDIA drivers present through DXGI and stuttered without it
(gfx-rs/wgpu#8310). With VSync off (Immediate, Mailbox) the driver
signals that fence only once the GPU has finished the previous frame, so
get_current_texture held the CPU for the whole GPU frame and the GPU sat
idle while the next one was recorded: 72% busy with a shader pack on an
RX 7900 XT. Apply the upstream fix (gfx-rs/wgpu#10714, not in a release
yet), which creates the fence only for Fifo and FifoRelaxed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 14:19:27 -05:00
kerim 669aa04e69 Merge branch 'main' into main 2026-10-30 20:19:03 +01:00
kerim 6c023bdca0 fix de.json 2026-10-30 20:13:31 +01:00
JustKarkatandZCode 83e8e0ba1f Native mods: the player-health HUD rows as a hook point
API 14 adds native_health_hud: once a frame the HUD draws the health rows,
the hook hears the layout's inputs and the screen's GUI size, and a mod that
answers with draws replaces all the rows — armour, hearts, food, air and the
heights above the hotbar. Nothing answered draws Horizon's own, and the
call is before the atlas re-borrow so the frame is unchanged for every mod
that does not answer.

This is the hook the colored hearts HUD mod needs (the extras' one-row
tinted layout replaces the rows); a mixin cannot reach HealthHud::layout or
hud_layers, whose bodies hold closures horizon-mixins refuses to inject
into.

Co-Authored-By: ZCode <[email protected]>
2026-10-30 14:10:44 -05: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
umcekoandClaude Opus 5.5 9765a0341d Port pack: item models only an item's overrides use are not items
Without the oracle (the mod browser's check), every models/item file
became an item, so 1.21.1's armour trim looks, which the armour's model
switches to through `overrides`, came over as items of their own:
Simplest Emerald Gear declared 49 items where it registers 9. A model
another item model's overrides name is a variant of that item now, and
gets neither a declaration nor an item definition.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:57:04 +03:00
umcekoandClaude Opus 5.5 21ec14c0b2 Port pack: armour of a mod's own material draws its own textures
Ported armour took the iron template's equippable, so worn it drew as
iron. Each armour material of the mod's namespaces now becomes a 26.3
equipment asset from its 1.21.1 layers (the models/armor layer textures
copied to textures/entity/equipment/humanoid and humanoid_leggings), and
the items' equippable names it with the material's equip sound. The
harness checks that the textures Horizon resolves for worn armour are the
jar's own: Simplest Emerald Gear's item stats 72/72.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:57:04 +03: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
Claude d63d89bb86 Show the armour stand preview in the smithing table
SmithingScreen.updateArmorStandPreview puts the result on a stand without
its base plate and with its arms (armour in its slot, anything else in the
off hand), drawn by extractEntityInInventory beside the slots. The world's
armour stand drawing is split out per stand so the screen can reuse it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rx5M6NS763D2WVnb2HmcTH
2026-10-30 18:46:36 +00:00
gravitacia 574c600551 Use imported resource packs in launcher profiles
Preserve profile edits, allow resource-only play, and distinguish imported records from registry packs. Finish archive conflict checks and document the partial roadmap milestone.
2026-10-30 18:31:48 +00:00
gravitacia f7dc8dd714 Fetch indexed resource packs and report carry-over outcomes 2026-10-30 18:31:48 +00:00
gravitacia 6fa052b4e0 Carry embedded and instance resource packs into imports 2026-10-30 18:31:48 +00:00
Claude bec89a8927 Draw the parrots on the player's shoulders
A parrot that lands on its owner is kept in ShoulderEntityLeft/Right, but
nothing drew it, so it seemed to vanish. Port ParrotOnShoulderLayer and
AbstractClientPlayer.getParrotVariantOnShoulder: the parrot model in
ParrotModel.Pose.ON_SHOULDER with the player's walk animation, age and
head turn, 0.4 to the side and 1.5 (1.3 crouching) up the player's model,
on the player in third person and in the inventory preview.

Part of #3518.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rx5M6NS763D2WVnb2HmcTH
2026-10-30 18:26:58 +00:00
levzzz 46c09f1c70 Merge remote-tracking branch 'hr/main' into fix/pr-3891 2026-10-30 23:24:03 +05:00
levzzzandClaude Sonnet 5.5 ae9a990929 Merge launcher/native-mods-on-by-default into native-mods-where
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:24:02 +05:00
levzzzandClaude Sonnet 5.5 a4a90ad0a1 Merge launcher/native-builds-stay-put into native-mods-where
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:23:31 +05:00
levzzzandClaude Sonnet 5.5 e06a5ced37 Merge main into native-builds-stay-put (version-mismatch build option alongside base_for)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:22:37 +05:00
levzzzandClaude Sonnet 5.5 d2c4e9ac87 Merge main into native-mods-on-by-default; the version-mismatch panel moves to the Mods page's Native build row
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:20:29 +05:00
apeirsandClaude Opus 5.5 29b58bb0fc Trading: Space and Enter press the focused offer again (hold Space to refill)
AbstractButton.keyPressed presses the focused button on Enter, Space or
numpad Enter, key repeats included, and the offer button clicked last keeps
focus through slot clicks. Each press reruns MerchantScreen.postButtonClick
(tryMoveItems + SelectTrade), so holding Space refills the payment slots
after every trade. Horizon tracked the focus but no key pressed it, and
screens never saw key repeats. horizon-ctl's key command takes the same path.

lan_e2e: merchant-reselect trades three times from one offer click, on the
host and on a guest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:18:31 +01:00
DUzzL 4f76341bdd Launcher: read ATLauncher instances from observed instance.json files, not its source 2026-10-30 19:18:19 +01:00
levzzzandClaude Sonnet 5.5 e7fae3d587 Launcher i18n: drop removed native-mods strings, add Polish for the new ones
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:17:25 +05:00
levzzzandClaude Sonnet 5.5 2e52f01d71 Merge main into native-mods-where
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:16:11 +05: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
DUzzL 3fa621d254 Save the previous velocity for remote players and test the elytra yaw lerp 2026-10-30 19:07:23 +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
levzzzandClaude Sonnet 5.5 ddf1be8050 Launcher i18n: drop removed native-mods strings, add Polish for the new ones
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 22:56:29 +05:00
levzzzandClaude Sonnet 5.5 32e8e51cfc Merge main into native-mods-on-by-default
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 22:53:10 +05:00
levzzzandClaude Opus 5.5 5f0ac95135 Native hooks doc: NeoForge's public documentation only, firing points from vanilla
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:51:38 +05:00
levzzz ca2955ef0c Merge main 2026-10-30 22:51:24 +05:00
MSedgeMC 0d31254815 Retarget ledger rows that are already ported
The port ledger is where AGENTS.md sends every contributor before parity
work, and thirteen rows still said missing for behaviour that is on main:
the spectral arrow's post-hurt effects and trail, the axolotl's bucket
hand-back and rehydration, the allay's note block memory and vibration
listener, the pillager's raid banner, and three splash potion methods.

Each now points at the function that ports it, verified against the 26.3
jar and the tree. Three pillager rows were transcribed from a branch that
is unreachable in vanilla: a white banner is a BannerItem, so pickUpItem
sends it to Raider.pickUpItem before wantsItem is ever consulted.
2026-10-30 23:12:22 +05:30
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
Kacpherek d848b1e05d Put guests in a changed default game mode when it is forced
Only a join or the world-options force switch applied the forced mode, so
/defaultgamemode or the default-mode button left guests in the old mode.
IntegratedServer.setWorldGameType and DefaultGameModeCommands.setMode call
updateGameModeBasedOnPermission / enforceGameTypeForPlayers, which sets every
online guest without game master permission to the new default.
2026-10-30 18:15:59 +01:00
Kacpherek c727c26612 Show the smithing table's onboarding tooltips on empty slots
Only the upgrade error tooltip existed, and it was one unwrapped line.
SmithingScreen.extractOnboardingTooltips shows Add Smithing Template over the
empty template slot, and the template's base or additions slot description
over an empty base or addition slot; every one is split at 115 pixels
(font.split(component, 115)).
2026-10-30 18:15:59 +01:00
Kacpherek 9e6453c457 Mark servers of another protocol as incompatible in the list
Every server that answered the ping showed ping bars and a player count.
ServerSelectionList.OnlineServerEntry sets State.INCOMPATIBLE when the
status protocol differs from the client's: the red version name replaces the
player count, the icon is server_list/incompatible, its tooltip reads
multiplayer.status.incompatible, and the player list stays on the name.
2026-10-30 18:15:59 +01:00
Kacpherek af84c3a547 Make Home and End in a book page move to the line's edges
Home and End jumped to the start and end of the whole page. In vanilla's
MultilineTextField.keyPressed they seek getCursorLineView().beginIndex and
endIndex, the visual line holding the caret; only Ctrl+Home and Ctrl+End go
to the page's ends.
2026-10-30 18:15:59 +01:00
Kacpherek 04f73f422a Re-read the held keys when the game takes the pointer back
A movement key pressed or kept down behind a screen did not count once the
screen closed, so the walk stopped until the key was pressed again.
MouseHandler.grabMouse calls KeyMapping.setAll off macOS
(InputQuirks.RESTORE_KEY_STATE_AFTER_MOUSE_GRAB), which sets every mapping
from the keys physically down, except sneak and sprint while they toggle.
2026-10-30 18:15:59 +01:00
Kacpherek 7886e3f5d7 Close the sign editor when the player walks out of reach
The editor only closed when its sign was removed (and only on a server's
client). AbstractSignEditScreen.tick closes it whenever isValid fails, which
also covers SignBlockEntity.playerIsTooFarAwayToEdit: eyes further than the
block interaction range plus 4 blocks. Both paths now run the same check.
2026-10-30 18:15:59 +01:00
Kacpherek e8e251df67 Grey out empty Items and Mobs tabs on the Statistics screen
An Items or Mobs tab with no rows could still be selected and showed an
empty list. StatsScreen.setTabActiveStateAndTooltip makes such a tab
inactive (grey label, no click) and gives it the gui.stats.none_found
tooltip (StatsScreen.java).
2026-10-30 18:15:59 +01:00
Kacpherek 1fb244e0a5 Show Take Book on a lectern only to players who may build
An adventure or spectator player saw Take Book on every lectern. Vanilla's
LecternScreen.createMenuControls adds Done and Take Book only when
player.mayBuild() holds, and otherwise falls back to BookViewScreen's single
wide Done button (LecternScreen.java).
2026-10-30 18:15:59 +01:00
Kacpherek 0bc5e0de9c World list: disable and mark worlds that cannot be played
Play stayed enabled for a world locked by another instance or from an incompatible series, Edit and Re-Create stayed enabled for a world whose files need upgrading, and the hover icon was always the join arrow. LevelSummary (isDisabled, primaryActionActive, canEdit, canRecreate, primaryActionMessage) and WorldSelectionList.WorldListEntry (canInteract, extractContent) grey those buttons out, show Upgrade and Play, ignore a double click or icon click on a disabled world, draw the error or marked join sprite with its tooltip, and tooltip the buttons of a world that needs its files fixed. The info line names an incompatible version in red and shows a backup world's version in italics, red for a downgrade.
2026-10-30 18:15:59 +01:00
Kacpherek 6557c0604c Re-Create keeps the world's data packs and alerts on an unreadable world
Re-Create World took the name, seed and options but none of the world's data packs, and a world whose level.dat would not read did nothing when the button was pressed. WorldSelectionList.WorldListEntry.recreateWorld (WorldSelectionList.java) copies the world's datapacks into the new screen's pack folder (CreateWorldScreen.createTempDataPackDirFromExistingWorld) and keeps its enabled packs, and on failure shows an AlertScreen with selectWorld.recreate.error.title and .text over the list.
2026-10-30 18:15:59 +01:00
Kacpherek f3a05ae1a3 Reopen the sound output when its device is lost or the default changes
Unplugging headphones or switching the system output left Horizon silent until restart: the stream's error callback only logged. SoundEngine.shouldChangeDevice (SoundEngine.java) reloads the engine when the open device disconnects, when the system default changes with no device chosen in the options, or when the chosen device comes back. Audio::tick now watches the stream's DeviceNotAvailable and looks at the default output once a second, then stops all sounds and opens the output again, as SoundEngine.reload does.
2026-10-30 18:15:59 +01:00
Kacpherek 6f0c7b1441 Send language, chat colors and particles in the client information
Settings::client_information filled only chat visibility, skin layers, main hand and server listing; the language stayed en_us, chat colours on and particles at All whatever the options said. Options.buildPlayerInformation (Options.java) sends languageCode, chatColors and particles as set.
2026-10-30 18:15:59 +01:00
Kacpherek bb1007fae9 Show the Amplified help tooltip on the world type button
Create New World's World tab showed no tooltip for Amplified. Vanilla (CreateWorldScreen.WorldTab, WorldCreationUiState.WorldTypeEntry.isAmplified) gives the type button the generator.minecraft.amplified.info tooltip whenever the selected preset is Amplified.
2026-10-30 18:15:59 +01:00
Kacpherek d5a8d23be6 Creative screen: switch to the survival inventory when creative ends
The creative inventory stayed open after the player left creative mode
(/gamemode survival, or a server's game mode change), so items could
still be taken from it. CreativeModeInventoryScreen.containerTick opens
an InventoryScreen once the player no longer hasInfiniteMaterials.
2026-10-30 18:15:59 +01:00
Kacpherek b5dace7edd Recipe book: dragging across the search box selects text
A press in the recipe book's search box placed the caret, but moving the
mouse with the button held did nothing. RecipeBookComponent.mouseDragged
forwards the drag to the search box while it is focused, and
EditBox.onDrag moves the selection end to the pointer.
2026-10-30 18:15:59 +01:00
Kacpherek e39901dc6f Recipe book: a narrow window's open book disables the slots under it
With the book open in a window under 379 px wide it covers the menu, yet
the slots under it could still be hovered and clicked, and an outside
click dropped the carried stack. AbstractRecipeBookScreen.isHovering is
false while the book is visible and widthTooNarrow, and its mouseClicked
returns true for every click in that state instead of passing it to
AbstractContainerScreen.
2026-10-30 18:15:59 +01:00
Kacpherek 963f465dc6 Recipe book: close after placing a recipe in a narrow window
In a window narrower than 379 px the book is not offset next to the menu,
and Horizon kept it open over the menu after a recipe was placed.
RecipeBookComponent.mouseClicked calls setVisible(false) after a placed
recipe when isOffsetNextToMainGUI is false, which stores the closed state
and sends the settings. A refused click (tryPlaceRecipe false) leaves it
open.
2026-10-30 18:15:59 +01:00
Kacpherek 0ada161e69 Container screens: a bundle's tooltip shows while carrying a stack
Hovering a bundle with a stack on the cursor showed no tooltip at all.
AbstractContainerScreen.extractTooltip shows the hovered item's tooltip
with a carried stack when its tooltip image says
showTooltipWithItemInHand, which ClientBundleTooltip does. Applied to
the inventory, container, station, trading, beacon and creative screens.
2026-10-30 18:15:59 +01:00
Kacpherek ca5315dc0d Bundle tooltip: show the selected item's name above the grid
The bundle tooltip drew the grid and the bar but not the box with the
selected item's name above it. ClientBundleTooltip.extractSelectedItemTooltip
(ClientBundleTooltip.java) draws the stack's styled hover name in a
tooltip of its own, centred on the component less 12, 15 above its top,
placed by DefaultTooltipPositioner (+12, -12, kept on screen).
2026-10-30 18:15:59 +01:00
Kacpherek 536fe04942 Container screens: draw the slot highlight sprites
The hovered slot got a flat 50% white square over its item. Vanilla draws
the 24x24 container/slot_highlight_back sprite behind the item and
slot_highlight_front over it, at the slot minus 4 (AbstractContainerScreen
.extractSlotHighlightBack and extractSlotHighlightFront), and skips slots
whose isHighlightable is false (the crafter's NonInteractiveResultSlot).
Done for the inventory, container, station, trading, beacon and creative
screens; the quick-craft drag preview keeps its 0x80ffffff fill as in
AbstractContainerScreen.extractSlot.
2026-10-30 18:15:59 +01:00
Kacpherek 14b5b031d3 Music Toast: the Now Playing toast with its music notes
The Music Toast option did nothing: the music manager queued show and
hide requests that nothing read, and no toast existed. ToastManager
makes a NowPlayingToast under Pause and Pause and Toast
(initializeMusicToast, setMusicToastDisplayState), shows it when a song
starts and hides it when music stops (showNowPlayingToast,
hideNowPlayingToast); NowPlayingToast.update times it out after five
seconds of Notification Time, extractToast draws the nine-slice
background, the music notes tinted by ColorLerper MUSIC_NOTE
(tickMusicNotes) and the song's language key in light grey. It slides
in from the left over every screen but the pause screen, which draws it
itself while music is audible (PauseScreen.rendersNowPlayingToast).
2026-10-30 18:15:58 +01:00
Kacpherek 397e947fac Advancement toast: wrap a long title and fade the label into it
A long advancement title stayed on one line at the toast's right edge.
AdvancementToast.extractRenderState (AdvancementToast.java) splits the
title at 125 pixels; with more than one line it shows the frame label
centred and fading out until 1.5 s of full visibility, then the title
lines centred and fading in over 0.3 s.
2026-10-30 18:15:58 +01:00
Kacpherek db2c7b799d Chat: grey Not Secure bar, Modified tag and Only Show Secure Chat
Player chat lines never carried a tag, so the grey bar for an unsigned
message and the Modified bar and icon for a server-edited one were
missing, and Only Show Secure Chat did nothing. ChatTrustLevel.evaluate
(ChatTrustLevel.java) calls a message not secure when it has no
signature or is older than seven minutes, modified when the signed
content is not in the line or the replacement uses another font, and
createTag turns that into a GuiMessageTag. ChatListener.showMessageToPlayer
drops not secure lines with the option on, and the player's own message
on their own server is always secure. ChatComponent shows the tag text
over the bar and the icon, and the icon on the hovered message.
2026-10-30 18:15:58 +01:00
Kacpherek bc005e8ac2 Title splash: sometimes show the player's name as NAME IS YOU
The title screen only ever drew a list entry. SplashManager.getSplash
(SplashManager.java), outside the three special dates, rolls
nextInt(size) == 42 and then shows the logged-in user's name in
upper case followed by " IS YOU" instead of a list entry.
2026-10-30 18:15:58 +01:00
Kacpherek be75260bc0 Show the Reconfiguring screen while a server reconfigures the client
The client kept drawing the old world while a proxy or server sent it back to
configuration. ClientPacketListener.handleConfigurationStart flushes the held
chat and clearClientLevel opens ServerReconfigScreen: connect.reconfiguring
over the panorama with a Disconnect button that only becomes active after
600 ticks (DISCONNECT_TIME); the layout is a vertical LinearLayout with cells
padded by 10. handleLogin replaces it with the loading screen.
2026-10-30 18:15:58 +01:00
Kacpherek f5f968df5c Nudge the player out of a block it stands inside
A player whose body corner sat in a solid block (a block placed or pushed
onto the player) was left there. LocalPlayer.aiStep calls
moveTowardsClosestSpace at the four corners (0.35 of the width out) unless
the player is a spectator or a passenger: a corner in a suffocating block
sets a 0.1 speed on the axis of the nearest side that is free (suffocatesAt,
LocalPlayer.java).
2026-10-30 18:15:58 +01:00
Kacpherek fd4c3e01e9 Keep sprinting along a wall you push into at a shallow angle
The local player's minor_horizontal_collision was a stub returning false,
so any bump into a wall stopped the sprint. LocalPlayer.
isHorizontalCollisionMinor compares the move the wall allowed with the
direction of xxa/zza turned by the yaw, and counts it minor when the angle
is under 0.13962634 radians (8 degrees); Player.shouldStopRunSprinting
ignores a minor collision.
2026-10-30 18:15:58 +01:00
Kacpherek bd19371acf Delay far firework sounds by their distance
Firework bursts played their blast and twinkle at once, however far they
were. ClientLevel.playSound with distanceDelay, as FireworkParticles passes
it, plays a sound beyond 10 blocks of the camera later, by sqrt(distance) /
40 seconds (SoundManager.playDelayed); drips stay immediate.
2026-10-30 18:15:58 +01:00
Kacpherek 4f3a68c450 Show the portal loading screen on a server's dimension change
A remote respawn always drew the panorama loading screen. Vanilla's
ClientPacketListener.handleRespawn picks a LevelLoadingScreen.Reason with
determineLevelLoadingReason (not after a death; the Nether or the End on
either side gives NETHER_PORTAL or END_PORTAL) and
LevelLoadingScreen.extractBackground draws the portal sprite or the End
backdrop for those instead of the blurred panorama.
2026-10-30 18:15:58 +01:00
Ludovic MarieandClaude Opus 5.5 78c2b92c5e /claim: an issue a pull request already fixes no longer counts toward the limit
rdbtCVS, 2026-10-30: a review can take 48 hours, so counting issues whose fix is already in a PR held contributors
back. The open-claims search adds -linked:pr (issues linked to a pull request by a closing reference).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:15:16 +01:00
Ludovic Marie fcfe236b92 Merge main into chore/claim-limit-8 2026-10-30 18:14:58 +01:00
Ludovic Marie 52a0865aac Merge main into perf/dedicated-no-client-tables 2026-10-30 18:02:21 +01:00
Ludovic Marie 19eeab4a2a Merge main into perf/dedicated-no-item-models 2026-10-30 18:02:19 +01:00
Ludovic Marie 714f7a76da Merge main into perf/dedicated-no-face-visibility 2026-10-30 18:02:17 +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
Сидоренко Егор АлекчеевичandCodex ca27b2de97 Merge main and preserve the upstream mimalloc allocator
Resolve the allocator and lockfile conflicts against 8f376a471. Keep
main's horizon_threads allocator, startup configuration, and dependency
settings unchanged. Retain windows-mimalloc as a compatibility no-op and
keep the independent, opt-in Segment Heap resource experiment.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-30 19:50:35 +03:00
MSedgeMC 1d4c9fa4db Make an allay tell potions apart
AllayAi.allayConsidersItemEqual wants a stack with the same item and no
differing potion contents, so an allay carrying Swiftness ignores a Healing
potion on the ground. Horizon compared item ids only, so any allay picked up
every potion, and it kept only the id of what it carried, losing the
components on the way back out.
2026-10-30 22:14:04 +05:30
MSedgeMC 864f314abf Apply a spawn egg's components to the mob it spawns
EntityType.create finalizes the mob, then PostSpawnProcessor puts the
stack's implicit components on it, so an egg carrying chicken/variant=warm
spawns a warm chicken rather than the biome's. Horizon had the bucket half
of that hook and nothing for eggs: the stack never reached the mob.

The bucket loader becomes the shared item-to-mob hook, with arms for the
chicken, cow and pig variant and sound variant, the mooshroom variant and
the sheep colour, and both spawn paths call it after the mob is finalized.
2026-10-30 22:13:48 +05:30
MSedgeMC 971e8a90f0 Growl when a piglin brute starts a fight
PiglinBruteAi voices entity.piglin_brute.angry when the brute's activity
switches into the fight and then rolls the level random every tick at
0.0125, which comes to about one growl per eighty ticks while it fights.
Horizon had the activity machinery and the sound for piglins only, gated to
Piglin and Hoglin, so a brute's activity stayed idle and it never made a
sound.
2026-10-30 22:11:52 +05:30
rdbtCVSandClaude Opus 5.5 4cf3a14ba0 Launcher: keep the Resource packs tab, on Modrinth
Modrinth's resource packs work, so their tab stays, with its source switch
(Modrinth first). HORIZON_LAUNCHER_PAGE=resourcepacks opens it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:45:10 +00:00
Ludovic MarieandClaude Opus 5.5 234d5e1cd6 CONTRIBUTING: another project's code only when its license allows it
Ludovic, 2026-10-30, after arismoko's proposal in #contributors: reading and using another project's source is now
allowed, but only under MIT, MIT-0, BSD-2/3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense or CC0. Everything else
(GPL, LGPL like Iris, MPL, Polyform like Sodium, custom, closed, unlicensed) stays off limits, even to read. The
contributor checks the license at the commit and per file, names the project, commit and license in the PR, keeps
the notices and adds a THIRD_PARTY_NOTICES.md entry. The PR template gets an "Other projects' code" section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:43:39 +01:00
rdbtCVSandClaude Opus 5.5 0416fe600c Launcher: hide the Mods page's sources and tabs that aren't usable yet
The Mods and Shader packs tabs search Horizon's registry only: no Modrinth /
CurseForge switch. The Modpacks, Resource packs and Data packs tabs are hidden;
Modpacks still opens from the sidebar (its tab shows while open) with its source
switch. A Java instance's Mods tab still searches Modrinth, with its filters.

Closes #4477

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:35:21 +00:00
Ludovic Marie 69d6254b70 Merge remote-tracking branch 'origin/main' into perf/dedicated-no-client-tables 2026-10-30 16:32:25 +01:00
Ludovic Marie fc485bb996 Merge remote-tracking branch 'origin/main' into perf/dedicated-no-item-models 2026-10-30 16:32:25 +01:00
Ludovic Marie 9ea981a2f3 Merge remote-tracking branch 'origin/main' into perf/dedicated-no-face-visibility 2026-10-30 16:32:24 +01:00
Ludovic MarieandClaude Opus 5.5 a78bee4642 /claim: hold up to 8 open issues, as on Kira
GitHub's /claim refused a 6th issue while Kira's Discord /claim allows 8 (raised from 3 on 10-25 at rdbtCVS's ask: a
contributor running several agents needs more). A claim counts until its issue closes, even with an open PR, so 5 left
DUzzL's agent stuck with every issue already fixed in a PR. One limit, 8, in both places and in CONTRIBUTING.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:16:38 +01:00
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
SlaVKsVolksandOpenAI Codex GPT-6.1 Sol 320778a3d2 fix(bench): reject incomparable benchmark reports
Check the documented comparison conditions before printing tables, and report missing or conflicting settings without producing a misleading comparison.

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-30 11:12:46 -03:00
SlaVKsVolksandOpenAI Codex GPT-6.1 Sol b46c30af80 fix(port-ledger): support jar-only citation checks
Resolve declared and inherited fields from bytecode when source files are absent, while retaining source-based citation checks.

Co-Authored-By: OpenAI Codex GPT-6.1 Sol <[email protected]>
2026-10-30 10:53:44 -03: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
DUzzL ef4f8d3edc Run the weapon hit on an armor stand so a Wind Burst launches the attacker
Fixes #4404
2026-10-30 14:48:44 +01: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
levzzzandClaude Opus 5.5 ea678bc6e6 Docs: proposal to model native hook points on NeoForge 26.3 events
Names, firing points, sides and cancel contracts from NeoForge's 26.3.x events (no code
copied), a catalog field for the counterpart, how the hooks we have and the PRs in flight
map, a priority list, and the capability/menu shape for mod block entities.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:27:47 +05: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
ElyTheCatandAntigravity 95d06ac8cd perf(light): reuse worker scratch buffers and optimize flood-fill traversal
- Reuse states, sky, block, queue, and unpacking buffers via thread-local LightScratch.
- Fast-exit wavefront traversal when neighbor light is already >= level - 1.
- Hoist from_row occlusion table index and avoid Y-axis column checks in spread loop.
- Fast-path open sky ray stepping for empty air-to-air boundaries.
- Benchmark: 3267.62 µs/chunk -> 1371.04 µs/chunk (58.0% faster, 2.38x speedup).

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 16:18:09 +03:30
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
DUzzL e2dbf8a03f Enable the input method in World Options only while a text box has focus 2026-10-30 12:11:18 +01:00
DUzzL 21bdf4f9a3 Accept committed IME text in the focused text field
Allow the window's input method only while a text box has focus and route
WindowEvent::Ime(Commit) to the same sinks as typed text. Preedit drawing and
the candidate window position are not handled yet.
2026-10-30 12:02:53 +01:00
MSedgeMC dec8429c3d Wire /place structure to the live level
PlaceCommand.placeStructure generates a start for the position's chunk with
a constant-true biome predicate, checks that every chunk of the start's box
is loaded, and runs placeInChunk over that box in the live level. Horizon
implemented /place template, /place feature and /place jigsaw, and the
structure arm validated the id and then always failed with
command.failed, which is the "unexpected error" the report shows.

The arm now generates the start through the existing structure machinery
with every biome valid (the command's own predicate, not worldgen's), reuses
place_in_chunk over the live-level feature world, and reports vanilla's
success and failure messages. LevelFeatureWorld also stores a template's
block-entity data now, so a placed chest keeps its loot table instead of
losing it.
2026-10-30 16:29:35 +05:30
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
ElyTheCatandAntigravity 1bea267634 perf(resident): bypass bounds lookup for inside columns and precompute column coordinates
When a column is completely inside the view frustum (inside == true),
skip bounds lookup and intersection testing for all of its sections.
Additionally, precompute column-invariant world coordinates (ox, oz,
min_x, min_z, and xz_sq) outside the section loop.

This eliminates the diagonal view frustum culling regression, reducing
diagonal traversal time from 84.5 µs (fallback) down to 79.5 µs.

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 13:55:07 +03:30
ElyTheCatandAntigravity 1338b93815 test(resident): add tight bounds culling benchmark
Co-Authored-By: Antigravity <[email protected]>
2026-10-30 13:25:05 +03:30
Сидоренко Егор АлекчеевичandCodex 5f72e129b2 Merge main into mesh buffer reuse
Preserve layout-relative section coordinates and main's worker idle-memory
release while retaining bounded mesh buffers and worker scratch reuse.
Extend reuse regressions to custom vertical layouts.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-30 12:46:35 +03:00
Сидоренко Егор АлекчеевичandCodex 5a453aac0f Merge main into Windows allocator options
Resolve Cargo.lock against e84a47c06 while preserving the independent,
off-by-default Windows allocator features and main's native-mod cleanup.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-30 12:38:47 +03:00
TeiwazikandClaude Opus 5.5 5455bc98f4 Resource pack screen: find packs added to the folder while it is open
PackSelectionScreen watches resourcepacks/ and lists the packs again when its
contents change. Horizon listed them once, when the screen opened: a pack
copied in after needed the screen closed and opened again. The folder's
entries (name, size, write time) are now compared once a second while the
resource pack screen is open; on a change the screen is made again over the
new list, keeping the selection made on it so far. Create World's data packs,
on the same page, are left alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 10:29:11 +01:00
ElyTheCatandAntigravity 7c5de46635 Render: tight chunk section bounds for resident frustum culling
Compute the exact axis-aligned bounding box of a section's terrain mesh
vertices during meshing and use it during resident frustum culling.

Sections containing flat terrain (e.g. flat floors or water surfaces) or
geometry that does not touch the camera frustum are culled without drawing,
eliminating false positives from the fallback 16x16x16 bounding box.

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 12:58:14 +03:30
MSedgeMC dd0a1e4972 Keep the sheep undercoat when the sheep is sheared
Vanilla's two wool layers disagree about shearing: SheepWoolLayer returns
early on a sheared sheep, while SheepWoolUndercoatLayer has no sheared
test at all (its guard is invisibility, jeb_ or a non-white colour, and
adult). Horizon gated both pushes behind one !sheared arm, so a sheared
dyed sheep drew nothing at all.

The sheared guard now wraps only the wool push; the undercoat keeps its
existing conditions and is pushed first, as before. Unsheared output is
unchanged, and the test asserts (model, texture, colour) for the sheared
dyed, sheared white, baby and unsheared cases.
2026-10-30 14:48:48 +05:30
Nguyễn Văn Trãi a24fe033cd Merge branch 'main' into docs/readme-launcher-screenshots 2026-10-30 16:02:24 +07:00
TeiwazikandClaude Opus 5.5 525d7366eb Pack overlay: the read flag has no use on Unix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 09:44:43 +01:00
Sticky-Bug a2d7a9f6c5 options: enable Monochrome Logo option (darkMojangStudiosBackground)
Enable Opt::MonochromeLogo in options_ui so it can be toggled on the
Accessibility Settings screen.
In level_loading, define LoadingOverlay brand background colors
(LOGO_BACKGROUND_COLOR = 0xEF323D, LOGO_BACKGROUND_COLOR_DARK = 0x000000)
and draw_loading_overlay.
Use draw_loading_overlay in startup_menu and game reload overlay to fill
the frame with the brand background color selected by darkMojangStudiosBackground.
2026-10-30 14:05:05 +05:30
Ludovic Marie 50fd82435c Merge remote-tracking branch 'origin/main' into perf/shared-loot-tables 2026-10-30 09:19:17 +01:00
Ludovic MarieandClaude Opus 5.5 d3be73f9d0 Share one loot table map between Items and the loot data of the same root and packs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:07:10 +01:00
Ludovic MarieandClaude Opus 5.5 4d42e8b877 Dedicated server: no block entity renderer or animate tables, which only frames read
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:02:47 +01:00
Ludovic MarieandClaude Opus 5.5 d12fd7341c Dedicated server: parse no item models, which only the item renderer bakes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:58:54 +01:00
DUzzL 9ca7f51d05 Sneaking players descend through scaffolding on the server (#4254) 2026-10-30 08:55:14 +01:00
DenisHumenandClaude Opus 5.5 e17aabe322 A hit stands a sitting tame parrot up
Parrot.hurtServer calls setOrderedToSit(false) before
LivingEntity.hurtServer, as Wolf.hurtServer does. Horizon only did it
for wolves, and only after the damage landed, so a sitting parrot kept
sitting when hit, and a wolf hit inside its hurt frames kept sitting.
Both now stand up in accepts_damage, which runs where vanilla's override
does (after isInvulnerableTo, before the hurt frames).

Part of #3512 (row Parrot.hurtServer).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:52:54 +02:00
Ludovic MarieandClaude Opus 5.5 fb2fb00ec4 MeshTables: no occlusion faces from a server's empty visibility
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:42:32 +01:00
Ludovic MarieandClaude Opus 5.5 c24f9a1693 Dedicated server: no render face visibility, which only the mesher and block entity renderer read
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:35:22 +01:00
TeiwazikandClaude Opus 5.5 b91fb97b1c Pack overlay: stamp vanilla's files by their metadata on Windows
Without a change timestamp, Windows checked every file of a cached overlay by
its bytes, vanilla's included: tens of thousands of opens, each scanned by the
antivirus, took minutes on every resource reload. Vanilla's files now keep to
their size and write and creation times, as on Unix; the packs' own files are
still read. The marker version moves to 6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NbhTrA2NFEcA5CdtUTS1eH
2026-10-30 08:31:09 +01:00
Kacpherek 64b7e511b1 Play the minecart steps a server sends along the track
Horizon ignored ClientboundMoveMinecartPacket, which a server sends in place of move packets for minecarts on NewMinecartBehavior (the minecart_improvements experiment), so such a cart and a rider in it stayed where they were added until a teleport. Decode the packet's steps (MinecartStep: position, movement, yRot, xRot, weight) and play them as NewMinecartBehavior.lerpClientPositionAndRotation does: queued steps become the current ones over 3 ticks by their weights (getCurrentLerpStep), and the cart takes the position, movement and yaw they give at the end of each tick. A teleport puts such a cart there at once (its interpolation handler is NO_OP). The cart's pitch still comes from its rails.

Fixes #3796
2026-10-30 08:15:21 +01:00
Kacpherek d0c5f220bc Keep the destination of a portal trip running with a portal ticket
Horizon only lit the exit chunks long enough to land an entity that went through a portal, then nothing in that dimension ticked while no player was in it, so an item or mob stayed frozen there and never came back through. Vanilla places TicketType.PORTAL (300 ticks, radius 3, level 30 of both kinds, keeps the dimension active) on the exit chunk when the entity arrives (Entity.placePortalTicket via TeleportTransition.PLACE_PORTAL_TICKET and NetherPortalBlock.getExitPortal, EndPortalBlock.getPortalDestination). Add the ticket to the level's chunk tickets, tick the dimension while one is held and stream its area.

Fixes #3292
2026-10-30 08:11:41 +01:00
DenisHumenandClaude Opus 5.5 dc3d702e01 Creative armour swap gives the worn piece back to the hand
Equippable.swapWithEquipmentSlot: in creative a single item swaps with the
worn piece too (the hand gets copyAndClear of the worn stack, the slot a
copy), and from a larger stack the worn piece goes to the inventory. The
game kept the new piece in the hand and deleted the worn one, so the held
item never changed and the hand never played its swap animation.

Fixes #4150

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:08:00 +02:00
DenisHumenandClaude Opus 5.5 e3e2404a1f horizon-server: install the Windows logon task without admin rights
`service install` (the launcher's "Run in the background and at login"
switch) ran `schtasks /Create /SC ONLOGON`. That trigger fires at every
user's logon, and Windows lets only an administrator create it, so the
switch failed with "Access is denied" for a normal user.

Create the task from XML instead, with the logon trigger and the
principal naming the current user (LeastPrivilege, interactive token),
which needs no elevation. The task also drops schtasks' defaults that
would stop a server: the 72-hour run limit and not starting on battery,
and Task Scheduler restarts the supervisor a minute after it fails.

Fixes #3733

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:41:08 +02:00
d3grandoandClaude Opus 5.5 3e536cb541 Merge origin/main into fix-3061-pad
game/pad.rs: main dropped the unused mouse::Rotation import; this branch's keymapping::SNEAK import stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:15:22 +07:00
Kacpherek 85b5ff7294 Scale the view-blocking probe height with the player's scale
The block overlay (the dark stretched texture over the screen) probes eight points around the eye, 0.1 apart in height. Vanilla multiplies that by LivingEntity.getScale (LevelExtractor.getViewBlockingStateAndPos). Horizon used a fixed 0.1, so a player of scale 0.01, whose eye is 0.0162 above the feet, probed the ground block and got its overlay over the whole screen.

Part of #3809
2026-10-30 06:34:55 +01:00
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
GIGABAIT93andClaude Opus 5.5 531cb06614 Riders sit in their scale, and on text displays
An NPC plugin enlarges its fake players (minecraft:scale) and seats the
sitting ones on an invisible text display. Three gaps showed together:

- a vehicle's passenger attachment ignored its scale, so a name riding a
  1.5x NPC sat at 1.8 blocks, in its face (getDimensions scales the
  attachments with the box);
- players and mannequins had no vehicle attachment on a mob or a display
  (Avatar.DEFAULT_VEHICLE_ATTACHMENT, 0.6, scaled); only boats had it;
- a mob riding a text display kept the server's position for it instead
  of its seat (rideTick), so the plugin's teleport left it in the air.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 07:04:50 +02: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
Alexander KrylovandHermes Agent 58cf8aa10c Graphics screens: their text through the language table
The graphics screens' captions, values, headers, titles, cost tags, blocked
reasons and descriptions were English display text handed to the widgets, so
no key ever reached i18n. The text is the key now: labelled() translates a
caption and its value, the description is translated before it wraps, and the
headers, titles and buttons go through lang::get where they are drawn.
Numbers in values became templates the table can hold.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-30 07:28:14 +03:00
DUzzL 6f654f9180 Launcher: keep --check-accounts as a hidden no-op for old scripts 2026-10-30 04:54:15 +01:00
DUzzL b636b37e9a Launcher: new servers check Minecraft accounts by default
The create screen no longer offers the offline choice; the server's
settings page still turns account checks off, and the CLI takes --offline.
2026-10-30 04:46:04 +01: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
DUzzL e8c32f6610 Run single-player use clicks after attack clicks within a tick
A place and a break in one click now replace the targeted block as in
vanilla's handleKeybinds: attack first, then use against the hit result
picked before the break. Fixes #4147.
2026-10-30 03:13:48 +01:00
spiderandClaude Opus 5.5 d87ead957e Far terrain: the dimension the player is in, not the server's level in place
The window built its far terrain from whichever level the server thread
had in place when it asked. Dying in the Nether and respawning in the
Overworld could catch the Nether there, and Nether far terrain stood around
the Overworld until the world was reopened. Levels::lod_source_in gives the
player's dimension's, in place or parked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:12:23 +01:00
Alexander KrylovandHermes Agent 56402b95f3 Launcher: the language row is a dropdown, not a segment per language
Nine segments are wider than the row, which left its sentence a character
wide and wrapping one letter per line. The row shows the choice and a
menu now, and the sentence stays only while the language follows the
system's.

Co-Authored-By: Hermes Agent (Nous Research) <[email protected]>
2026-10-30 05:06:21 +03:00
asownerytandClaude Opus 5.5 7037d586ca Tamed pets no longer turn on the owner who hits them
Vanilla's TamableAnimal.canAttack refuses the pet's owner (isOwnedBy), so
HurtByTargetGoal never starts when the owner hits their wolf, cat or
parrot, and no target or anger follows. Horizon's player check
(Mob::spares_players) only knew the player-built iron golem, so a tamed
wolf hunted its owner after one hit (#4119). It now spares a
TamableAnimal's owner too, in every place that check is made.

NonTameRandomTargetGoal also checks isTame before its random interval
roll: the wolf's and cat's untamed-only target goals now gate before the
draw, so a tamed pet's random sequence follows vanilla.

mob_ai harness: a new `owned` flag (tamed by the scenario's player) and
the wolf_owner_hurt scenario.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 15:05:11 +13:00
DUzzL 3acfebb2fe Document the armor stand seat offset 2026-10-30 02:41:31 +01: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
DUzzL 77738c6356 Keep latched sneak and sprint across a short focus loss
Focus loss cleared the key set, which also held the keys synthesized from the sneak/sprint toggles. A quick alt-tab opens no pause screen, so nothing restored them. Re-sync both toggles after the clear. Fixes #4133.
2026-10-30 02:38:18 +01:00
TraiNguyenVanandQoder 300d048a1c README: the launcher's two pictures, retaken signed in on 0.2.28
Both are fresh 1280x800 captures of launcher 0.2.28, taken with a
Microsoft account selected so the chip reads "<name> / Microsoft account"
as it does for players. The name is blurred with a feathered gaussian, in
the chip and in the Settings account row, so no account name is readable
in either file.

The instance uses Horizon's own violet accent tile (#5b3fd6, the H) rather
than Minecraft's grass block, which the launcher only composites from the
player's own prepared textures. It has v0.2.97 prepared, so Resources reads
"Prepared for Minecraft 26.3"; the playing count and the newest Nightly
line are live values and will drift.

README alt text is unchanged; it describes the same two pages.

Co-Authored-By: Qoder <[email protected]>
2026-10-30 08:34:36 +07:00
DUzzL fc26a5f40a Seat armor stands that ride a mob or the player (frozen name tag)
A server's name tag hologram rides the player as an armor stand. The client
ignored SetPassengers for armor stands, so the stand stayed where it spawned
while the real name tag followed the player.
2026-10-30 02:31:19 +01:00
kerim 4aa8663777 Merge branch 'main' into main 2026-10-30 02:21:10 +01:00
kerim 6aff9307fe add more german translations 2026-10-30 02:08:34 +01:00
DUzzL d14b3e27ba Save the previous velocity for LAN guest bodies too 2026-10-30 01:59:19 +01:00
DUzzL 5c4eb67ba2 Interpolate elytra lean velocity from the previous tick 2026-10-30 01:50:50 +01:00
Benflex 7fe6f090e1 Draw item tooltips over inventory effect panels
The survival and creative inventories drew the active-effect panels
after the item tooltip, so the panel backgrounds covered the tooltip
text. Draw the panels with the rest of the screen and keep only the
hovered effect's tooltip for the end, after the item and slot tooltips.

Fixes #4080.
2026-10-30 01:43:11 +01:00
DenisHumenandClaude Opus 5.5 6297ea09f6 Bows and crossbows: no use animation with nothing to shoot
BowItem.use and CrossbowItem.use (uncharged) FAIL without a projectile
unless the player has infinite materials, and Minecraft.startUseItem only
calls itemUsed for a SUCCESS, trying the other hand otherwise. The client's
remote_use_item treated the bow as used anyway, so the hand dipped as if the
draw started and stopped.

remote_start_held_use now reports a failed bow or uncharged crossbow use
(a charged crossbow still succeeds, it shoots), and remote_use_item passes
the turn on.

Fixes #4079

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:48:20 +02:00
Ludovic Marie 12e23c20a8 Merge branch 'upd-3988' into upd2-3988 2026-10-30 00:27:42 +01:00
Ludovic MarieandClaude Opus 5.5 691f958bc4 Merge main: log_display serves Windows too; DisplayWatch built field by field
Main limited log_display to Linux, where only Wayland called it; the OS
poll calls it on every platform now. On macOS and Windows the struct has
only `polled`, so `..Self::default()` was clippy's needless_update.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:27:00 +01:00
SenjinandClaude Opus 5.5 8c079ed9d7 HDR Windows Auto: builds on Windows; the peak is never below Paper White
log_display was still Linux-only, so this didn't build on Windows (found and
fixed by martipops's test). Windows' SDR brightness slider can put white above
the reported peak (456 against a calibrated 380 on a Samsung QD-OLED): Auto's
peak is now at least its Paper White, so highlights stay at white instead of
dropping below it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:25:33 +01:00
Ludovic Marie b246a02f55 Merge remote-tracking branch 'origin/main' into upd-3988
# Conflicts:
#	docs/graphics.md
2026-10-30 00:22:13 +01:00
Сидоренко Егор АлекчеевичandCodex 3813fba81c Reuse worker meshing scratch and bounded mesh capacity
Keep primitive scratch per worker and recycle cleared CPU mesh buffers through a count/byte-bounded worker-only pool. Cover reset, missing neighbors, visibility, pool accounting and complete geometry across resource reloads.

Fixes #4041

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-30 02:06:08 +03:00
Сидоренко Егор АлекчеевичandCodex 93f42ead5b Add independent opt-in Windows allocator build features
Expose Segment Heap manifest integration and mimalloc separately, with unchanged defaults and reproduction/PE validation instructions.

Fixes #4042

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-10-30 02:02:22 +03:00
ElyTheCatandAntigravity 05f68f3fdc Linux: OpenGL presents uncapped with VSync off
On Linux with OpenGL (the GLES wgpu backend), wgpu-hal hard-coded its
surface capabilities present_modes to [Fifo] only (//TODO), never
offering Immediate. When VSync was turned off and Max Framerate was set
to Unlimited:
1. chosen_present_mode filtered supported modes and could only select
   Fifo since Immediate was missing from the adapter's capabilities.
2. In gfx.rs, pre_present_notify is called whenever present_mode is Fifo,
   which on Wayland requests a wl_surface.frame callback from the compositor,
   pacing RedrawRequested to the display refresh rate (60 FPS).
3. In egl.rs, eglSwapInterval(0) was never called, leaving EGL to default
   to swap interval 1 (60 FPS on X11 and Wayland).

- In wgpu-hal: report [Fifo, Immediate] present modes for GLES on all
  platforms, and call eglSwapInterval with 0 for Immediate and 1 for Fifo
  in configure and present.
- In gfx.rs: consider Immediate on startup surface configuration if
  supported, matching vsync_present_mode.
- In window.rs: add unit test for present mode choice with [Fifo, Immediate].

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 02:30:12 +03:30
SenjinandClaude Opus 5.5 ef61ac7c6c HDR: light sources tiered from a look at each in game
Beacon, end rod, furnace and redstone lamp shine bright; the froglights,
shroomlight and every copper bulb low; the respawn anchor incandescent at
every charge. Sea pickles, brown mushrooms, the dragon egg and the end
portal frame only glow faintly, as glow lichen, and stay as in SDR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:44:15 +01:00
SenjinandClaude Opus 5.5 0a72d58c25 HDR: mobs' glowing eyes shine like glowstone
The eyes render type's layers (spiders', endermen's and phantoms' eyes,
the breeze's, the creaking's, the copper golem's) carry the bright tier
in the overlay's high bits, as light-giving particles do, so the HDR mark
pass lifts them: they glow to frighten, so they shine above a torch. The
warden's fading layers and the dragon's additive eyes are left as they
are.

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:42:13 +01:00
SenjinandClaude Opus 5.5 decdf49937 HDR: the moon shines at the low tier, not the sun's
The sun and the moon marked the frame alike, so the moon sat in the
sun's tier, whose lift window only its brightest pixels reach: it hardly
lifted at all. Each body's quads now carry their tier in the overlay's
high bits (as particles do), and fs_sky_celestial writes that tier's
mark: the sun's as before, the moon's low.

Part of #1441.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:38:38 +01:00
d3grandoandClaude Opus 5.5 a8b5151ce5 Controller toggle-sneak comes back after a screen closes
The pad's toggle-sneak flipped sneaking with a hold-mode Shift press or
release, so KeyMapping.releaseAll dropped it when a screen opened and
restoreToggleStatesOnScreenClosed had nothing to restore. ToggleKeys now
has a pad toggle: it flips the sneak mapping as ToggleKeyMapping.setDown
does with its option on, and while it holds sneaking the mapping toggles
(released and remembered by a screen, restored when it closes, a shift
release behind the screen ignored), as vanilla's toggle crouch does.

Part of #3061.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 05:34:50 +07:00
Joansitoh acce84e884 Resource packs: create overlay junctions natively and load models and GUI atlas side by side
Each untouched directory of the Windows overlay was a `cmd /C mklink /J` process,
about 100 ms apiece: 34 of them were 3.4 s of a 4.3 s overlay for a 44-file server
pack. The junction is now an FSCTL_SET_REPARSE_POINT on an empty directory, in
horizon-threads (render forbids unsafe code). The world's models and sprites, the
GUI atlas and the panorama read the same root and nothing of each other, so the
reload now builds them on three threads.
2026-10-29 23:14:15 +01:00
levzzz 4c08d3e128 Merge remote-tracking branch 'hr/main' into launcher/native-mod-crates 2026-10-30 03:08:41 +05:00
levzzz e9b2bdef24 Merge remote-tracking branch 'hr/main' into launcher/native-builds-stay-put
# Conflicts:
#	launcher/src/native_mods.rs
2026-10-30 03:07:52 +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
Ludovic MarieandClaude Opus 5.5 18da9c988a docs/server-next.md: beat Pumpkin on idle memory and start too (< 32 MB, < 0.1 s), and how
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:50:14 +01:00
Ludovic MarieandClaude Opus 5.5 86ebc77e25 docs/server-next.md: the next server's design, measured against vanilla and Pumpkin
Horizon's dedicated server, vanilla 26.3 and Pumpkin 0.2.1 (black box) with
the same horizon-bots at 10/50/100 players; numeric targets; everything in
this repo, shared by the integrated server, LAN and --dedicated; parallel
ticking that keeps vanilla's order; the network front, tracker deltas,
off-thread saving; the Horizon fast path; online mode on by default; the
plugin API (with levzzz5154); the plan and the open PRs on the theme.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:46:24 +01:00
DUzzL 90823415c3 horizon-bots: report bytes in/out and join-time percentiles 2026-10-29 22:46:17 +01:00
DUzzL 2017653aff bench: one script that runs Horizon, vanilla or Pumpkin under the same horizon-bots load 2026-10-29 22:44:39 +01:00
SenjinandClaude Opus 5.5 4b004cf7d6 HDR: Auto on Windows, from the luminances the OS reports
Where no Wayland compositor answers, Auto takes what wgpu reads from the OS
(Surface::display_hdr_info): on Windows DXGI's MaxLuminance as the peak and the
SDR white level of the SDR content brightness slider as Paper White, both in
absolute nits, HDR output's scale there. No event says they changed, so they're
read again every 2 seconds while HDR output is on, and logged once per change.
Untested on Windows so far.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:40:32 +01:00
AmadeusandOpenAI Codex 79360c5867 Reduce sunrise fan intersection work with projected sector selection
Keep the full barycentric reference and seam fallbacks; add CPU and GPU equivalence checks plus shader-pack helper isolation coverage.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-30 00:37:45 +03:00
SenjinandClaude Opus 5.5 7b98a22fe1 HDR shortcut: Done goes back to the game, not the pause menu
The key opens Graphics: HDR straight from the game, so closing it returns
there and takes the mouse again (options_to_game, like
options_to_world_options for Chat Settings).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:35:10 +01:00
SenjinandClaude Opus 5.5 0a8ac2e89c HDR: a shortcut key to its settings, unbound by default
Graphics: HDR gets a Shortcut row, bound like the sort key on Extras: click it,
press a key or mouse button (Escape unbinds). The key lives in
horizon-graphics.txt as hdrKey, never in options.txt, which stays vanilla's.
Pressed in the game where the display offers HDR, it opens Graphics: HDR
straight away, and Done goes back to the game: tune paper white and peak while
looking at the world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 22:35:10 +01: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
unknownandOpenCode Agent 89b737d88a Streaming: light and mesh jobs bounded in flight, nearest first
The light and mesh stages of Streaming::update submitted every ready
column's jobs at once, in hash order. At a wide render distance the
queue - and lighting, and meshing, and each job's 3x3 region snapshot -
grew with the number of loaded columns, requeue re-ranked that whole
heap under the queue lock each time the player crossed a chunk border,
and the workers ground through jobs whose columns were long gone,
their results dropped only after the work was done (#3629: at render
distance 1000 the backlog survived lowering the distance, and chunks
around the player waited behind it).

Both stages now hand the workers only as much as they can chew, chosen
from everything that is ready each update, nearest first: what stays
dirty is asked for again, with the current centre, as capacity frees -
the way vanilla's chunk task queues hold bounded per-worker work and
follow the current ticket levels. Generation (workers * 3) and
decoration (workers * 2) already had such bounds.

Fixes #3629

Co-Authored-By: OpenCode Agent (GLM) <[email protected]>
2026-10-30 00:24:28 +03: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
Joansitoh f4c6f8dc29 Skins and maps upload again after a resource pack reload
Refs #3122, #3694
2026-10-29 22:24:22 +01: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
levzzzandClaude Opus 5.5 32cce66761 Launcher: call the Horizon update button "Rebuild on Horizon X"
A mod row's "Update to 2.0.0" sat just below "Update to Horizon X"; the two
did different things under the same word.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:32:10 +05:00
levzzzandClaude Opus 5.5 f2a3a76c07 Launcher: simpler Build condition for clippy 1.95
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:29:03 +05:00
levzzzandClaude Opus 5.5 8ee30558da Launcher: show where native mods are installed and what each instance's build plays
- Each installed native mod says "Installed in {instance}" and has Open folder
  (<instance>/horizon-native-mods/); "Installs into {instance}" shows even with
  one instance.
- The Native build row is the picked instance's own: a Not built / Needs
  rebuild / Up to date badge, the native-* build it plays and the Horizon it
  was built on (native-mods.json's base). A build's progress and error show
  only on the instance being built.
- Update to Horizon {version} when update_for finds a newer release or
  Nightly; its trust warning says it compiles from scratch and takes longer.
  Build never moves the Horizon version.
- The instance list shows a native build as "Native build · Nightly 10-28".
- Strings in all six languages; docs/native-mods.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:26:33 +05:00
levzzzandClaude Opus 5.5 d9a2f157a3 Merge launcher/native-mods-on-by-default (#3855) into launcher/native-mods-where
Stacks PR 3 on both #3870 and #3855. native_mods.rs takes #3855's side: the
experimental switch's NOTICE, turns_on and needs_notice go.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:14:11 +05:00
levzzzandClaude Opus 5.5 abeb5845dd Launcher: native builds use the fast profile, at low priority
- The workspace's `fast` profile (no LTO, 16 codegen units, incremental) where
  the pinned source has it, else `release`; native-mods.json records which.
- The game's build and horizon-mixins' install run at nice 19 with idle I/O
  (Linux), nice 19 (macOS), below-normal priority (Windows).
- CARGO_BUILD_JOBS passes through: it changes no flag.
- A native build's copied programs keep their permissions (a clone left
  horizon-ctl and horizon-server not executable).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:05:56 +05:00
levzzzandClaude Opus 5.5 aba29be6af Launcher: native mods are on by default; Build moves to the Mods page
The "Native mods (experimental)" switch leaves Settings: native mods are
always on. Old settings.json files still read (the `native_mods` key is
ignored); `native_mods_noticed` now records the one-time trust notice
before the first native install, which no longer asks to turn anything on.

The Mods page's installed list gets a "Native build" row for the selected
instance: the build's step or error, Build when the version it plays
doesn't hold the native mods turned on (compared with its
native-mods.json), else "Up to date". Build still opens the red trust
warning. An installed native mod now says which instance it went into.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:30:53 +05:00
levzzzandClaude Opus 5.5 647ec89bfc Launcher: native rebuilds stay on the instance's Horizon and in its instance
- A build starts from the version the instance plays; a native build's next one
  compiles its own recorded revision (native-mods.json) from its own game files.
  `native-build --update` moves to the newest installed release or Nightly, and
  `native_mods::update_for` says which one a window could offer.
- The build pins the instance the mods are installed in, the default one included;
  no more hidden "Native mods" instance (old ones keep working).
- Toolchain commands drop RUSTFLAGS, CARGO_PROFILE_*, CARGO_BUILD_*, CARGO_TARGET_*,
  CARGO_INCREMENTAL and friends, and start Cargo from the disk's root with
  --manifest-path, so the player's ~/.cargo/config.toml can't change the flags.
- prune keeps the three newest native builds no instance plays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 00:28:38 +05:00
TraiNguyenVanandQoder d169963556 README: the banner drops the lede, so it carries no harness count
The site's hero says 281 differential harnesses; the README's prose says
more than 300. Keeping that line in a screenshot puts the two side by
side, so the banner is now the kicker, the headline and the landscape
(1600x600) and the README owns the count.

Co-Authored-By: Qoder <[email protected]>
2026-10-30 01:25:41 +07: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
TraiNguyenVanandQoder 573f398a2a README: the Settings picture is named launcher-settings.webp
The 0.2.26 capture replaced the Patch notes page but kept its file name.
Nothing else referenced it, so the name now follows the page it shows.

Co-Authored-By: Qoder <[email protected]>
2026-10-30 01:21:09 +07:00
TraiNguyenVanandQoder 37de786282 README: the banner is the website's hero, captured from playhorizon.dev
The banner was a hand-written HTML page (docs/media/banner.html) imitating
the design system. It now shows the real site: playhorizon.dev's hero in
its dark theme, captured with Playwright. The navbar, the hero's buttons
and the launcher version line are hidden first, so nothing in the picture
goes stale, and the crop starts 56 px above the kicker and ends at the
hero's bottom edge (1600x654).

banner.html is removed and its capture documented in
docs/media/brand/README.md; the README alt text follows, and the CI path
test uses banner.webp as its content-only example.

Co-Authored-By: Qoder <[email protected]>
2026-10-30 01:11:50 +07:00
TraiNguyenVanandQoder d36f45e213 README: the launcher's Home and Settings pages, freshly captured
The two launcher pictures dated from the 0.1.5 UI: the Play page (now
Home) and Patch notes, which no longer shows what the launcher is for.
Both are replaced in place with 1280x800 captures of 0.2.26, and the
alt texts follow; nothing else in the README changes.

Taken in a scratch home with the offline name Steve, one instance and
v0.2.97 prepared, so no account or server data appears.

Co-Authored-By: Qoder <[email protected]>
2026-10-29 23:51:10 +07: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
Ludovic Marie 67591edf8f Merge remote-tracking branch 'origin/main' into perf/structure-start-convoy 2026-10-25 21:31:03 +01:00
Ludovic Marie e2ca936487 Merge main 2026-10-25 14:21:48 +01:00
Ludovic MarieandClaude Opus 5.5 cc2a2a8741 Lithostitched's limited and guaranteed elements and its processor-list modifiers
The game reads lithostitched:limited and lithostitched:guaranteed pool
elements as written (spec-lithostitched §3): a limited element places its
delegate at most `limit` times per start, a guaranteed one is tried first
at each jigsaw of its pool until `count` are placed, and neither goes below
a parent shallower than `min_depth` (IQ-10). Layouts stay vanilla's (L5);
with no such element vanilla's draws are unchanged. Saved pieces of these
types read as their delegate.

The carry keeps them (delegating still places its delegate), writes
add_processor_list_processors as a processor-list addition (§2.2, 1.8.1's
`processor_lists` key) and both modifier kinds with their priority; the
game merges additions by `horizon:priority`, then mod and file order.

Harnesses: the layout harness checks the probe pack's limits on Horizon's
own probe villages, and the blocks harness gets a lithostitched-probe
profile; the probe pack is built into a jar from the oracle's profile.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 09:56:59 +01:00
Ludovic MarieandClaude Opus 5.5 4eb85dbdf9 World generation: workers build free structure starts instead of queueing
ChunkGenerator.createReferences asks for the structure starts of the 17x17
chunks around each chunk. Every worker asked for them in the same order and
waited on the chunk's work lock while another worker built its starts, so at
a cold start (a new world, the first structures of a seed) the workers queued
one behind the other: in a fresh generator with 12 workers on the Mac mini,
39% of the generation time was spent blocked in that lock (sampled).

references() now builds the starts no other worker is building first, and
waits for the rest only then; the work locks' map is sharded by chunk, as
the start cache already is, because every worker now asks for the locks of
the whole neighbourhood. Starts and the order of the references are the same:
each chunk's starts are still built once and kept by the first builder.
Blocked time in the same run: 39% -> 1.4% of generation samples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 06:11:58 +01:00
Ludovic MarieandClaude Opus 5.5 1102fa42df macOS on Intel: x86_64 builds of the game and the launcher (#1161)
release.yml and launcher.yml build x86_64-apple-darwin on the Mac mini's
runner beside Apple Silicon: horizon-macos-x64.zip, and the launcher's
horizon-launcher-macos-x64.zip and .dmg, signed and published with the
rest, on the same unsigned path as arm64. Their checks and smoke tests run
the x86_64 programs under Rosetta 2. A launcher picks its assets from one
table (PLATFORMS) by OS and processor, and a test checks the workflows
publish every asset in it. package.sh names the assets per architecture
and refuses a binary built for the other one. The server installer, the
website's Download button (Chromium tells Intel Macs apart; other browsers
get an Intel Mac link) and the docs know the Intel build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 08:06:35 +02:00
681 changed files with 61456 additions and 6477 deletions

No files matched your search

+6
View File
@@ -21,6 +21,12 @@
<!-- The vanilla classes and methods this follows (tools/rea_vanilla.py), and the harness that checks it.
"Horizon-only" for a feature vanilla doesn't have; "none" for tooling or docs. -->
## Other projects' code
<!-- "none", or for each project you read or copied from: its link at the commit, its license (only MIT,
BSD-2-Clause, BSD-3-Clause, Apache-2.0, ISC, zlib, 0BSD, MIT-0, Unlicense or CC0-1.0), and what you took.
CONTRIBUTING.md "Only original code". -->
## Self-check
<!-- Paste the block `python3 tools/pr_ready.py` prints (`--base upstream/main` from a fork). -->
+70 -12
View File
@@ -1,7 +1,7 @@
name: checks
# verify: every pull request and every push to main, on GitHub's runners (the repository is public). It is the
# check main requires, so it never has a condition: a skipped job counts as passed, and every run of this workflow
# must report a real verify (a pull request that changes no Rust finishes it in seconds instead).
# check main requires, so it always runs: a skipped job counts as passed, and every run of this workflow must report
# a real verify. It gathers the python, clippy and tests jobs (a change to docs alone skips their Rust steps).
# parity: the vanilla harnesses a pull request's changes can affect, on the Mac mini's runner, which holds the
# maintainer's own Minecraft files (Horizon never downloads the game); .github/workflows/parity.yml. Only for a pull
# request that changes a harness, oracle, probe or the runner: the rest are checked on main by parity-main. A fork's pull
@@ -24,7 +24,10 @@ env:
HORIZON_TELEMETRY_URL: "off"
jobs:
verify:
# verify's checks run as three jobs at once (python, clippy, tests: ~4 min instead of ~8 one after another), and
# verify gathers them. A failed job makes its dependents skip, and a skipped required check counts as passed, so
# verify runs always() and fails unless all three passed.
python:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -34,6 +37,28 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.12'
# A pull request adds only its own commits to main, so it reads those; main's pushes read every ref's history.
- name: Keep game artifacts out of Git
env:
EVENT: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
python tools/check_public_tree.py
if [ "$EVENT" = pull_request ]; then
python tools/check_public_tree.py --range "$BASE_SHA..$HEAD_SHA"
else
python tools/check_public_tree.py --history
fi
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
clippy:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Does this pull request need Rust checks?
if: github.event_name == 'pull_request'
id: rust
@@ -41,22 +66,16 @@ jobs:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Keep game artifacts out of Git
run: |
python tools/check_public_tree.py
python tools/check_public_tree.py --history
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA and udev headers the client links against
if: steps.rust.outputs.required != 'false'
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
# Dependencies and the workspace's own check, saved from main's runs (pull requests read main's cache).
- uses: Swatinem/rust-cache@v2
if: steps.rust.outputs.required != 'false'
with:
shared-key: verify
shared-key: verify-clippy
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Check formatting and lint
if: steps.rust.outputs.required != 'false'
@@ -65,8 +84,35 @@ jobs:
cargo +stable clippy --workspace --all-targets -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
tests:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Does this pull request need Rust checks?
if: github.event_name == 'pull_request'
id: rust
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: python3 tools/ci/rust_changes.py "$BASE_SHA" "$HEAD_SHA" >> "$GITHUB_OUTPUT"
- name: Install Rust toolchain and the ALSA and udev headers the client links against
if: steps.rust.outputs.required != 'false'
run: |
sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
rustup toolchain install stable --profile minimal
# Dependencies and the workspace's own build, saved from main's runs (pull requests read main's cache).
- uses: Swatinem/rust-cache@v2
if: steps.rust.outputs.required != 'false'
with:
shared-key: verify
save-if: ${{ github.ref == 'refs/heads/main' }}
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy already
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
# cargo test, not nextest: nextest's process per test reloads each binary's shared tables for every test, and on
# these 4-core runners it was no faster (#4978).
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: |
@@ -75,6 +121,18 @@ jobs:
# prepared vanilla data, so without it they pass at once.
cargo +stable test --workspace --lib mod_content::tests:: -- --ignored
cargo +stable test --workspace --doc
verify:
if: always()
needs: [python, clippy, tests]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Did python, clippy and tests pass?
env:
RESULTS: ${{ join(needs.*.result, ' ') }}
run: |
echo "python, clippy, tests: $RESULTS"
for result in $RESULTS; do [ "$result" = success ] || exit 1; done
# The launcher is its own workspace (launcher/Cargo.toml), so verify's cargo runs never build it. A pull request that
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
+23 -10
View File
@@ -1,6 +1,7 @@
name: game build
# Builds the game for every platform from `ref`: the Java exporters, then Linux and the Linux server and Windows on
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json). Called by
# GitHub, macOS on the Mac mini's runner with a 1500-frame offscreen run of a new world (smoke-test.json): Apple
# Silicon, and Intel cross-built there for x86_64-apple-darwin and checked under Rosetta 2 (#1161). Called by
# release.yml (the releases) and nightly-build.yml (the opt-in Nightly), always from main's copy of this file: the
# caller's commit, never the one being built. It holds no secrets: signing the checksums is the callers' own job,
# which never runs what this one built. See docs/release-flow.md.
@@ -104,6 +105,13 @@ jobs:
mac: true
dir: horizon-macos-arm64
asset: horizon-macos-arm64.zip
# Intel Macs: built on the same runner for another target; its checks and smoke test run the x86_64
# programs under Rosetta 2.
- name: macos-x64
mac: true
target: x86_64-apple-darwin
dir: horizon-macos-x64
asset: horizon-macos-x64.zip
runs-on: ${{ matrix.mac && (inputs.trusted && vars.MAC_RUNNER || fromJSON('["self-hosted", "macOS", "horizon-nightly"]')) || matrix.os }}
# Notarizing the zip can take a few minutes.
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
@@ -114,6 +122,8 @@ jobs:
HORIZON_VERSION: ${{ inputs.version }}
# A nightly's date and commit ("2026-10-28 0c02067"), which the game shows; empty for a release.
HORIZON_NIGHTLY: ${{ inputs.nightly }}
# Where cargo puts the programs: target/release, or target/<target>/release for another target.
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
defaults:
run:
shell: bash
@@ -128,6 +138,8 @@ jobs:
# keeps its preparations outside the checkout.
clean: true
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
@@ -138,11 +150,11 @@ jobs:
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
run: cargo build --release --locked -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Every program the release folder takes was built
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
test -f "target/release/$binary${{ matrix.exe }}" || { echo "::error::target/release/$binary${{ matrix.exe }} is missing"; exit 1; }
test -f "$BIN/$binary${{ matrix.exe }}" || { echo "::error::$BIN/$binary${{ matrix.exe }} is missing"; exit 1; }
done
- name: The signing keychain, unlocked
if: runner.os == 'macOS' && vars.MACOS_SIGN_IDENTITY != '' && inputs.developer_id
@@ -157,15 +169,15 @@ jobs:
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
codesign --force --options runtime --timestamp --keychain "$MACOS_SIGN_KEYCHAIN" \
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "target/release/$binary"
codesign --verify --strict --verbose=2 "target/release/$binary"
--identifier "dev.horizon.${binary#horizon-}" --sign "$MACOS_SIGN_IDENTITY" "$BIN/$binary"
codesign --verify --strict --verbose=2 "$BIN/$binary"
done
- name: Only system libraries
# Windows: no non-system DLL (a "DLL not found" dialog on some players' machines). Linux: the standard
# loader and libraries every desktop distribution has. macOS: only the system's own libraries.
run: |
for binary in horizon-client horizon-ctl horizon-prepare horizon-server; do
file="target/release/$binary${{ matrix.exe }}"
file="$BIN/$binary${{ matrix.exe }}"
if [[ "$RUNNER_OS" == macOS ]]; then
others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
[[ -z "$others" ]] || { echo "::error::$binary links $others"; exit 1; }
@@ -176,7 +188,7 @@ jobs:
- name: Every program starts
run: |
set -euo pipefail
bin=target/release
bin=$BIN
"$bin/horizon-client${{ matrix.exe }}" --help | grep -q -- '--resources'
"$bin/horizon-ctl${{ matrix.exe }}" --help > /dev/null
"$bin/horizon-prepare${{ matrix.exe }}" --help | grep -q -- '--minecraft-dir'
@@ -202,7 +214,7 @@ jobs:
home="$RUNNER_TEMP/horizon-smoke"
rm -rf "$home" && mkdir -p "$home"
version=$(sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' research-lock.json | head -1)
client="$PWD/target/release/horizon-client"
client="$PWD/$BIN/horizon-client"
resources="$PWD/.local/horizon-client/$version"
(cd "$home" && HORIZON_HOME="$home" HORIZON_NO_AUDIO=1 "$client" --resources "$resources" --offscreen \
--world smoke --seed 1 --render-distance 4 --world-frames 1500 --timeout-seconds 180 \
@@ -217,8 +229,9 @@ jobs:
EOF
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
# One smoke-test.json per build, the Apple Silicon one's; the Intel build must pass its own run too.
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted'
if: runner.environment == 'self-hosted' && !matrix.target
with:
name: smoke-test.json
path: ${{ runner.temp }}/smoke-test.json
@@ -231,7 +244,7 @@ jobs:
- name: Package
run: |
set -euo pipefail
cargo run --locked -p xtask -- package "target/release/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cargo run --locked -p xtask -- package "$BIN/horizon-client${{ matrix.exe }}" "dist/${{ matrix.dir }}"
cd dist
rm -f "${{ matrix.asset }}"
case "${{ matrix.asset }}" in
+36 -9
View File
@@ -8,7 +8,8 @@ name: launcher
# feature. The version and the tag are given to the build (HORIZON_LAUNCHER_VERSION, HORIZON_LAUNCHER_TAG): no bump
# commits.
# Never marked latest. Pushes to launcher branches only build. RELEASES_PAUSED=1 stops releases.
# macOS: with the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# macOS: Apple Silicon, and Intel cross-built for x86_64-apple-darwin on the same runner, its tests and smoke test
# under Rosetta 2 (#1161). With the repository variable MACOS_SIGN_IDENTITY set, Horizon.app and the .dmg are signed with
# Developer ID, notarized and stapled, from the runner's signing keychain (packaging/macos/README.md);
# unset (forks), they are signed ad hoc.
# gh workflow run launcher --ref <branch> # dry run: builds everything, publishes nothing
@@ -105,6 +106,13 @@ jobs:
mac: true
asset: horizon-launcher-macos-arm64.zip
binary: horizon-launcher
mac_arch: arm64
- name: macos-x64
mac: true
target: x86_64-apple-darwin
asset: horizon-launcher-macos-x64.zip
binary: horizon-launcher
mac_arch: x64
- name: windows
os: windows-latest
asset: horizon-launcher-windows-x64.zip
@@ -117,6 +125,8 @@ jobs:
timeout-minutes: ${{ matrix.mac && 60 || 40 }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# Where cargo puts the launcher: target/release, or target/<target>/release for another target.
BIN: ${{ matrix.target && format('target/{0}/release', matrix.target) || 'target/release' }}
# Horizon's Azure app for Microsoft sign-in (docs/microsoft-account.md): a repository
# variable, not a secret. Unset, the launcher says sign-in is not configured.
HORIZON_MSA_CLIENT_ID: ${{ vars.HORIZON_MSA_CLIENT_ID }}
@@ -140,6 +150,8 @@ jobs:
# parity jobs, and nothing they leave may end up in a launcher release (target/ is rebuilt from scratch).
clean: true
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
if: runner.environment == 'github-hosted'
with:
@@ -160,17 +172,17 @@ jobs:
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
fi
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
- run: cargo test --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo build --release --locked ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
- name: The version is the release's
run: |
out=$(target/release/${{ matrix.binary }} --version)
out=$("$BIN/${{ matrix.binary }}" --version)
echo "$out"
expected="${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}"
[[ "$out" == "horizon-launcher $expected" ]] || { echo "::error::expected horizon-launcher $expected"; exit 1; }
- name: Only system libraries
run: |
file=target/release/${{ matrix.binary }}
file="$BIN/${{ matrix.binary }}"
case "$RUNNER_OS" in
Windows) python ../tools/check_imports.py "$file" ;;
macOS) others=$(otool -L "$file" | tail -n +2 | awk '{print $1}' | grep -v -E '^(/usr/lib/|/System/Library/)' || true)
@@ -185,13 +197,28 @@ jobs:
export HORIZON_LAUNCHER_HOME="${RUNNER_TEMP//\\//}/horizon-launcher-smoke"
export HORIZON_HOME="$HORIZON_LAUNCHER_HOME/game"
rm -rf "$HORIZON_LAUNCHER_HOME"
launcher="$PWD/target/release/${{ matrix.binary }}"
launcher="$PWD/$BIN/${{ matrix.binary }}"
# GitHub's API rate-limits shared runner addresses now and then: try a few times.
retry() { for i in 1 2 3 4; do "$@" && return 0; sleep $((i * 15)); done; return 1; }
retry "$launcher" versions
"$launcher" links --json
# The Intel Mac launcher can come out before any game build has its asset (#1161): then there is nothing
# to install yet. Only that entry skips, and only on a version list that parsed and had no build here
# (exit 3); a launcher or a script that fails still fails the step.
if [[ "${{ matrix.name }}" == macos-x64 ]]; then
versions=$(retry "$launcher" --json versions)
no_build='import json, sys; sys.exit(3 if not any(r["available_here"] for r in json.load(sys.stdin)) else 0)'
status=0
python3 -c "$no_build" <<<"$versions" || status=$?
if [[ $status == 3 ]]; then
echo "::warning::No game build has an asset for ${{ matrix.name }} yet: install not tested"
rm -rf "$HORIZON_LAUNCHER_HOME"
exit 0
fi
[[ $status == 0 ]] || exit $status
fi
retry "$launcher" install latest
"$launcher" versions --installed
"$launcher" links --json
# `install latest` is the Nightly since #3342 (versions/nightly-<date>-<commit>), a vX.Y.Z before.
clients=("$HORIZON_LAUNCHER_HOME"/versions/*/horizon-client*)
client="${clients[0]}"
@@ -224,11 +251,11 @@ jobs:
# Developer ID): packaging/macos.
python3 -m venv "$RUNNER_TEMP/dmg" && "$RUNNER_TEMP/dmg/bin/pip" install --quiet dmgbuild==1.6.5
version=${HORIZON_LAUNCHER_VERSION:-$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)}
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh target/release/${{ matrix.binary }} "$version" out
DMGBUILD="$RUNNER_TEMP/dmg/bin/dmgbuild" ../packaging/macos/package.sh "$BIN/${{ matrix.binary }}" "$version" out ${{ matrix.mac_arch }}
rm -rf out/Horizon.app
else
mkdir -p dist
cp target/release/${{ matrix.binary }} dist/
cp "$BIN/${{ matrix.binary }}" dist/
if [[ "${{ matrix.asset }}" == *.tar.gz ]]; then
(cd dist && tar czf ../out/${{ matrix.asset }} ${{ matrix.binary }})
else
+4 -2
View File
@@ -172,7 +172,8 @@ jobs:
EPHEMERAL: ${{ needs.pick.outputs.dry_run == 'true' && 'yes' || '' }}
run: |
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz dist/horizon-source.tar.gz
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v4
with:
name: signature
@@ -235,7 +236,8 @@ jobs:
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS (horizon-macos-arm64.zip)" \
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
"dist/horizon-source.tar.gz#Source of this Nightly, commit ${SHA:0:7} (horizon-source.tar.gz; kept for good as the tag $SOURCE_TAG)" \
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
+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
+28 -10
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.
@@ -47,14 +47,31 @@ them (`git config core.hooksPath .githooks` runs it before every commit).
The pre-push hook checks the full history reachable from the branches and tags being pushed. Run
`python3 tools/check_public_tree.py --history` to audit the history of all local refs.
## Only original code
## Only original code, or code whose license allows it
Horizon's code is written from Minecraft 26.3 itself and from public documentation, never from another mod, loader
or renderer. Don't read, copy, translate or paraphrase the source of Iris, OptiFine, Sodium or any other project,
and don't send code an AI tool wrote from it. Their user documentation and file formats are fine to read, and their
packs and mods are fine to run and observe. Shader packs were removed on 24 October 2026 (#1561) because their code
had been written from another project's source; they come back only as a clean-room rewrite from a written
description of the pack format.
Horizon is MIT. Its code is written from Minecraft 26.3 itself and from public documentation. Since 30 October 2026
another project's source may also be read and used, **only when that project's license allows it**:
- **Allowed:** MIT, MIT-0, BSD-2-Clause, BSD-3-Clause, 0BSD, ISC, zlib, Apache-2.0, Unlicense, and the CC0-1.0
public-domain dedication. That code may be read, copied or adapted.
- **Everything else is off limits, even to read:** GPL, LGPL (Iris), AGPL, MPL, EPL, Polyform (Sodium), the Creative
Commons licenses (CC BY, CC BY-SA, CC BY-NC and the others; CC0 is a public-domain dedication, not one of them),
custom or "source available" licenses, all rights reserved (OptiFine), and any project with no license or an
unclear one. Don't read, copy, translate or paraphrase their source, and don't send code an AI tool
wrote from it. Being able to download a mod or a pack does not give you the right to read its files.
- **Check the license yourself.** Read the LICENSE file at the commit you use and the header of each file you take:
some repositories mix licenses, and a file under another license is off limits. A license that changed: the one
at your commit counts. In doubt, don't: ask in #contributors first.
- **Say it in the pull request:** the project, a link to the commit, its license, and what you read or copied. A
pull request that used another project's source without saying so is closed.
- **Copied or adapted code** keeps its copyright line and license notice at the top of the file, and gets an entry
in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md). Apache-2.0 code also keeps its NOTICE text and says what
was changed.
User documentation and file formats are fine to read, and packs and mods are fine to run and observe, whatever
their license. Shader packs were removed on 24 October 2026 (#1561) because their code had been written from
another project's source; they come back only as a clean-room rewrite from a written description of the pack
format.
## Before asking for review
@@ -82,7 +99,8 @@ claimed on Discord with Kira's `/claim`): pick another, or ask its holder in the
enough), then claim it. `/unclaim` gives it back.
A claim with no open pull request linking the issue (`Fixes #123` in the description, or a draft) is released after
5 days, with a comment: open a draft early. You can hold 5 open issues at a time. Agents claim the same way.
5 days, with a comment: open a draft early. You can hold 8 open issues at a time, not counting the ones your
pull requests already fix. Agents claim the same way.
When your pull request changes the same functions as another open one, or closes the same issue, a bot comment names
it (edited on every push, never repeated): talk to its author in the thread before you both finish the same work.
@@ -118,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
+25 -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"
@@ -830,6 +810,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs4"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e72ed92b67c146290f88e9c89d60ca163ea417a446f61ffd7b72df3e7f1dfd5"
dependencies = [
"rustix 1.1.5",
"windows-sys 0.61.2",
]
[[package]]
name = "futures-core"
version = "0.3.34"
@@ -904,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]]
@@ -1117,6 +1109,7 @@ dependencies = [
"bytemuck",
"chrono",
"flate2",
"fs4",
"gilrs",
"glam",
"horizon-anticheat",
@@ -3464,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"
@@ -4661,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",
]
+4 -1
View File
@@ -53,7 +53,10 @@ large_enum_variant = "allow"
# UHD 620/630) crashes on it with an access violation (#746). It also fixes the
# Metal backend's lookup of the CoreGraphics HDR colour space names, which handed
# CoreGraphics a symbol's address as the string and crashed HDR output on macOS.
# Drop this and the folder once a wgpu release has both fixes.
# And gfx-rs/wgpu#10714: on Windows, Vulkan waits for the acquire fence only in
# FIFO (VSync), where it was meant to pace frames; with VSync off it made every
# frame wait for the GPU to finish the one before (#4604).
# Drop this and the folder once a wgpu release has these fixes.
[patch.crates-io]
wgpu-hal = { path = "third_party/wgpu-hal" }
+36 -55
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="docs/media/banner.webp" alt="Horizon: Minecraft: Java Edition 26.3, rewritten in Rust and checked against vanilla by more than 300 differential harnesses" width="100%">
<img src="docs/media/banner.webp" alt="The Horizon website: the game you know, rebuilt in Rust" width="100%">
</p>
<p align="center">
@@ -24,34 +24,32 @@
# Horizon
Horizon is Minecraft: Java Edition 26.3 rewritten in Rust: a native game that reads the same worlds, follows the same rules and generates the same terrain as the Java game, and is checked against it by more than 300 differential harnesses. You play it through a small launcher, on the files from your own copy of Minecraft. It is free, open source and unofficial.
Minecraft: Java Edition 26.3, rewritten in Rust. Same worlds, same rules, same terrain, checked against the Java game by more than 300 differential harnesses. Free, open source and unofficial.
> Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own legally obtained copy of Minecraft: Java Edition 26.3.
> Not an official Minecraft product, nor approved by or associated with Mojang or Microsoft. Horizon contains no Minecraft code, assets or data: you need your own copy of Java Edition 26.3.
## Download
Horizon is installed through **the Horizon launcher**. Get it from [playhorizon.dev](https://playhorizon.dev) (the Download button picks your system) or from [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true), for Linux x86-64, macOS on Apple Silicon or Windows x64. The launcher installs the game and keeps it up to date with each new build (Horizon ships on one channel, the Nightly), prepares its files from your copy of Minecraft (your own install, or downloaded from Mojang for your account), and updates itself.
Get **the Horizon launcher** from [playhorizon.dev](https://playhorizon.dev) or [the launcher releases](https://github.com/horizon-rust/horizon/releases?q=launcher-v&expanded=true) (Linux x86-64, macOS on Apple Silicon, macOS on Intel from the next Nightly, Windows x64). It installs and updates the game, prepares its files from your copy of Minecraft, and updates itself.
**You need** a Microsoft account that owns Minecraft: Java Edition (the launcher signs in with it, as other launchers do; see [Microsoft account](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 on Linux, Vulkan or DirectX 12 on Windows, or any Apple Silicon Mac. Nothing else: with no Minecraft on your computer, Play downloads 26.3 and the Java it needs from Mojang for your account, as the official launcher does, then prepares it once. An install you already have (the official launcher, Prism, MultiMC and other launchers all work) is used instead. Step by step, with macOS's first-open prompt and the first launch: [Install and first launch](https://playhorizon.dev/docs/install).
**You need** a Microsoft account that owns Java Edition ([why](docs/microsoft-account.md)) and a GPU with Vulkan or OpenGL 3.3 (Linux), Vulkan or DirectX 12 (Windows), or a Mac (Apple Silicon, or Intel from the next Nightly). An existing Minecraft install (official launcher, Prism, MultiMC...) is reused; without one, Play downloads 26.3 from Mojang for your account. Step by step: [Install and first launch](https://playhorizon.dev/docs/install).
<table>
<tr>
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Play page: a version ready to play, its summary, Client and Server, the version picker and Play"></td>
<td width="50%"><img src="docs/media/launcher-patch-notes.webp" alt="The launcher's Patch notes page"></td>
<td width="50%"><img src="docs/media/launcher-play.webp" alt="The launcher's Home page: the selected instance ready to play, the version picker, Join a friend and the instance's tabs"></td>
<td width="50%"><img src="docs/media/launcher-settings.webp" alt="The launcher's Settings page: Microsoft account, offline name, updates, appearance and accent colour"></td>
</tr>
</table>
## What works today
The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** vanilla world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on vanilla saves. Worlds from 1.8 to 26.2 are upgraded. [Status](#project-status).
- **Extras:** new menus, graphics presets, distant terrain up to 1024 chunks, controllers, zoom, minimap and more, off by default where they change the vanilla look. [Settings](https://playhorizon.dev/docs/settings).
- **Packs:** resource packs, and data packs with functions, predicates, item modifiers and world generation.
- **Multiplayer:** invite friends with a code, Open to LAN, join servers (online mode included). [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** content mods (blocks, items, data) and native Rust mods, built by the launcher with nothing to install. Java mod jars do not run. [Mods](https://playhorizon.dev/docs/mods).
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates, item modifiers and world generation, taller levels included (vanilla's biomes for now).
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining servers, online-mode ones with the Microsoft account the launcher signs in with. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** content mods add blocks, items and data, and native mods change the game's own Rust code, built by the launcher on your copy, with no Rust to install. Horizon's own (Zoom, FPS Counter, Keystrokes, Minimap, Recipes and Coordinates HUD) are native mods. Java mod jars do not run in Horizon. See [Mods and data packs](https://playhorizon.dev/docs/mods).
Screenshots, captured in the Rust client itself with vanilla assets and no mods or shaders:
In the Rust client, with vanilla assets and no mods or shaders:
<table>
<tr>
@@ -69,15 +67,15 @@ Screenshots, captured in the Rust client itself with vanilla assets and no mods
## Host a server
The launcher's **Servers** page creates, starts, backs up and updates a Horizon server with buttons, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and runs without Java. Guide: [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
The launcher's **Servers** page creates, starts, backs up and updates a server, and invites friends over Tailscale. On a VPS, `horizon-server` installs with one command and needs no Java. [Host a server](https://playhorizon.dev/docs/hosting) ([reference](docs/hosting.md)).
## Modpacks
The launcher's **Modpacks** page imports a Modrinth or CurseForge pack, shows which of its mods Horizon can carry over, and plays the ones it can as their own profile. Today that means content mods made of data and assets; mods with Java code are not ported yet. See [Modpacks](https://playhorizon.dev/docs/modpacks).
The launcher imports Modrinth and CurseForge packs and plays the mods Horizon can carry over: today, content mods made of data and assets, not Java code. [Modpacks](https://playhorizon.dev/docs/modpacks).
## Agent control
AI agents and scripts can play Horizon. `horizon-ctl`, shipped beside the game in every release folder, is a command line that reads the game as JSON and acts like a player; `horizon-ctl mcp` serves the same commands as an MCP server (one tool per command, screenshots returned as images).
AI agents and scripts can play Horizon. `horizon-ctl`, shipped with the game, reads its state as JSON and acts like a player; `horizon-ctl mcp` serves the same commands over MCP.
```sh
horizon-ctl launch --world Survival --seed 5 --wait # finds the version the launcher installed
@@ -88,16 +86,14 @@ horizon-ctl break -1 65 -3 # mines it; replies with
horizon-ctl open-inventory && horizon-ctl click-slot 0 # menus: slots, buttons by label, text fields
```
Commands block until the action is done and reply with what happened; errors are JSON with distinct exit codes, `horizon-ctl help` lists every command and `horizon-ctl COMMAND --help` explains one with examples. It talks to the game over a Unix socket on Linux and macOS and over a token-protected localhost port on Windows. See [Agent control in the native client guide](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
Commands block until done and reply with the result; `horizon-ctl help` lists them all. [Agent control](docs/native-client.md#agent-control-horizon-ctl-and-mcp).
## Build from source
For developers. Requirements: Rust 1.90 or newer, Python 3.10 or newer, JDK 25, and on Linux the ALSA and udev development headers (`libasound2-dev libudev-dev` on Debian and Ubuntu). Development and the vanilla harnesses run on Linux.
Needs Rust 1.90+, Python 3.10+, JDK 25, and on Linux `libasound2-dev libudev-dev`. `horizon-prepare` builds the game's resources from **your own copy** of Java Edition 26.3 (checked against `research-lock.json`) and never downloads it:
Horizon contains no game files, and `horizon-prepare` never downloads them (the launcher downloads them from Mojang only for a signed-in account that owns the game). It builds its resources from **your own copy** of Minecraft: Java Edition 26.3, verified against the checksums in `research-lock.json`:
1. **Client:** install Java Edition with the official launcher and play 26.3 once. Horizon reads `versions/26.3/`, `libraries/` and `assets/` from the launcher's game directory (`~/.minecraft` on Linux). If yours lives elsewhere, pass `--minecraft-dir` or set `HORIZON_MINECRAFT_DIR`.
2. **Server jar:** the 26.3 server jar, from the `downloads.server.url` in your launcher's `versions/26.3/26.3.json` (or [minecraft.net/download/server](https://www.minecraft.net/en-us/download/server) while 26.3 is the current release). Pass it with `--server-jar` or set `HORIZON_SERVER_JAR`. It is only needed the first time.
1. **Client:** play 26.3 once in the official launcher. Horizon reads `~/.minecraft` (or `--minecraft-dir` / `HORIZON_MINECRAFT_DIR`).
2. **Server jar:** the 26.3 server jar (`downloads.server.url` in `versions/26.3/26.3.json`), passed once with `--server-jar` or `HORIZON_SERVER_JAR`.
```sh
git clone https://github.com/horizon-rust/horizon.git
@@ -107,62 +103,49 @@ cargo run --release -p horizon-prepare -- sounds # optional: sounds from your
cargo run --release -p horizon-client
```
Everything prepared lands in the gitignored `.local/`. It is derived from the game: never commit it or share it.
The client starts on the title screen. Command-line options (`--world`, `--seed`, `--creative`, `--render-distance`, `--dedicated`), controls, scripting through `horizon-ctl` and the architecture are described in [the native client guide](docs/native-client.md). The rest of the developer documentation is indexed in [docs/README.md](docs/README.md), including [the parity workflow](docs/parity-workflow.md).
Prepared files land in the gitignored `.local/`: never commit or share them. Options, controls and architecture: [the native client guide](docs/native-client.md); all developer docs: [docs/README.md](docs/README.md).
## Roadmap
What is being done now, what comes next and where you can help, in plain words and in technical detail: [ROADMAP.md](ROADMAP.md). [playhorizon.dev/hub](https://playhorizon.dev/hub) shows it with the work board, the journal of what shipped and how to help.
What is in progress, what comes next and where to help: [ROADMAP.md](ROADMAP.md), or [playhorizon.dev/hub](https://playhorizon.dev/hub) with the work board and what shipped.
## Contributing
Issues and pull requests are welcome: see [CONTRIBUTING.md](CONTRIBUTING.md) for how we work (vanilla first, small pull requests, labels), or [the contributing guide](https://playhorizon.dev/docs/contributing) on the site. Questions and ideas: [Discord](https://discord.gg/HcTeMZxe5E). Before opening a pull request, run the self-check and paste the block it prints:
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
python3 tools/pr_ready.py
```
It runs rustfmt, clippy with warnings denied, the tests of the crates you touched, the Python tests, the public-tree check, and the vanilla harnesses your change can affect. CI runs the same checks on GitHub's Linux runners; the vanilla harnesses need the game, which Horizon never downloads, so they run on a maintainer's computer holding their own copy. Never commit game files, decompiled source or prepared resources: `tools/check_public_tree.py` rejects them, and tracked Java is allowed only under the project's own `dev/horizonlab` namespace.
It runs rustfmt, clippy, the touched crates' tests, the Python tests, the public-tree check and the vanilla harnesses your change affects. Never commit game files, decompiled source or prepared resources.
## Project status
**Player-visible gaps: 833 open** of the 833 the [port ledger](tools/port_ledger.py) found, reading every vanilla 26.3 method against Horizon's code: vanilla behaviour a player would notice that Horizon doesn't have yet. By area: Screens, HUD and client packets [197](https://github.com/horizon-rust/horizon/issues/3518); Entity and block rendering, models [129](https://github.com/horizon-rust/horizon/issues/3519); Animals [110](https://github.com/horizon-rust/horizon/issues/3512); Server and players on the server [106](https://github.com/horizon-rust/horizon/issues/3520); Blocks, redstone and fluids [86](https://github.com/horizon-rust/horizon/issues/3516); Entities, projectiles, vehicles and decorations [64](https://github.com/horizon-rust/horizon/issues/3514); Monsters and bosses [53](https://github.com/horizon-rust/horizon/issues/3513); Mob AI, villagers and raids [35](https://github.com/horizon-rust/horizon/issues/3521); World, world generation and saves [32](https://github.com/horizon-rust/horizon/issues/3517); Items, enchantments and inventories [21](https://github.com/horizon-rust/horizon/issues/3515). This number goes down as gaps close (`tools/parity_gaps.py`); the harness results below say that what is tested matches.
The table below is the project's own audit of each subsystem. Every subsystem now matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not proof that nothing else differs. The remaining known differences are listed below.
The table is the project's own audit: every subsystem matches vanilla in every harness that compares it. 100% means 100% of what the harnesses compare, not that nothing else differs.
### Java parity: 100%
Each subsystem scores 0 (absent), 0.25 (started, not playable), 0.5 (playable,
major vanilla features missing), 0.75 (playable, mostly complete) or 1 (complete
and verified against vanilla by differential tests). The percentage is the
average; `tests/test_parity.py` keeps it in sync with this table. Last audited
2026-10-13 against the harness results: all 281 harnesses pass on Linux, each
rerun whenever one of its inputs changed, and the Mac mini now runs every one of
them too (Rust uses Java's own float maths on each platform).
The harnesses measure behaviour. A second measure is method by method: the [port ledger](tools/port_ledger.py)
lists every method of vanilla 26.3 next to the Rust functions that port it, with a verdict for each one
(ported, folded into another function, missing, or needing no port), and the untested ports. Run
`python3 tools/port_ledger.py build`, then `coverage`, with the vanilla data in `.local/`.
Scores: 0 (absent), 0.25 (started), 0.5 (playable, major features missing), 0.75 (mostly complete) or 1 (complete and verified by differential tests); the percentage is their average. Last audited 2026-10-13: all 281 harnesses pass on Linux and macOS. The [port ledger](tools/port_ledger.py) adds a method-by-method view of what is ported.
| Subsystem | Score | Main gaps |
| --- | ---: | --- |
| Window, input and game loop | 1 | None: the game loop, options, camera, auto-jump, toggle sprint/sneak, local input and the frame limiter match vanilla |
| Assets and resources | 1 | None: item models match vanilla over 196,290 stacks and the client draws every item through them; the resource pack screen matches vanilla's drawing over 3,000 sessions of clicks, scrolling and searching, its incompatibility confirmation included |
| Network protocol and multiplayer | 1 | None: joins vanilla servers; the dedicated server (`--dedicated`) matches vanilla's files, status ping, login, configuration, play, chunks, chat, recipes, advancements and command tree, a client built from vanilla's packet code joins it, Horizon joins a vanilla server, and LAN discovery matches over 462 cases (offline mode only) |
| Window, input and game loop | 1 | None: game loop, options, camera, auto-jump, toggle sprint/sneak and frame limiter match |
| Assets and resources | 1 | None: item models match over 196,290 stacks; the resource pack screen over 3,000 sessions |
| Network protocol and multiplayer | 1 | None: joins vanilla servers; the dedicated server matches vanilla's files, login, configuration, play and command tree; LAN discovery over 462 cases (offline mode only) |
| Chunk storage and palettes | 1 | None: client and saves share paletted containers, identical to vanilla over 266 scenarios |
| Section meshing | 1 | None: 112 scenes and over a million quads identical to vanilla |
| Rendering | 1 | None: entity models, block entity renderers (407 cases, End portals, gateways and the loom's banner preview included) and their animations (296 cases, End gateway beams included), the HUD overlays (2,200 states), the recipe book (600 states), the player list, subtitles and the world border match vanilla |
| Rendering | 1 | None: entity models, block entity renderers (407 cases) and animations (296), HUD overlays (2,200 states), the recipe book (600), player list, subtitles and world border match |
| Lighting | 1 | None: saved and relit light matches vanilla, slabs and stairs included |
| World generation | 1 | None: 2,352 whole chunks over 225 structure sites, in all three dimensions, identical to vanilla |
| Region files and saves | 1 | None: saves round-trip with vanilla, and worlds from 1.8 to 26.2 upgrade as vanilla's DataFixers do, superflat worlds included; old options.txt and hotbar.nbt files are upgraded too |
| Entities and physics | 1 | None: attributes, dimensions, death loot, spawn rules, natural spawning (15,500 cases), raids and patrols (1,900 cases), spawned mobs' gear, enchantments and state (`finalizeSpawn`, 24,000 cases) and mob AI (92 scenarios: villager breeding and gossip, the warden, ghastlings and copper golems included) match vanilla |
| Interaction and inventory | 1 | None: menu clicks match over 8,000 click sequences, the loom and the crafter included; recipe book placement (6,000 cases), its buttons (1,200), the saved book (1,500) and the loom and crafter screens (1,600) match vanilla |
| Menus, HUD and chat | 1 | None: every command runs as vanilla (758 commands over 39,685 parsed inputs), scoreboards and teams match (81,000 operations), the player list matches over 872,000 draw calls with heads drawn from players' skins (authlib's textures, SkinManager's lookup, legacy skin conversion), and the chat matches focused and scrolled; the resource pack screen (selection over 4,000 cases, drawing and its incompatibility confirmation over 3,000 sessions) and the chat input with its command suggestions, usage lines, Tab completion and history (3,000 sessions on vanilla's client command tree) match vanilla |
| Region files and saves | 1 | None: saves round-trip with vanilla; worlds from 1.8 to 26.2 upgrade as vanilla's DataFixers do |
| Entities and physics | 1 | None: attributes, spawn rules, natural spawning (15,500 cases), raids and patrols (1,900), `finalizeSpawn` (24,000) and mob AI (92 scenarios) match |
| Interaction and inventory | 1 | None: menu clicks over 8,000 sequences; recipe book placement (6,000), buttons (1,200), saved book (1,500), loom and crafter screens (1,600) |
| Menus, HUD and chat | 1 | None: every command (758 over 39,685 inputs), scoreboards (81,000 operations), the player list (872,000 draw calls), chat and its suggestions (3,000 sessions) match |
| Audio | 1 | None: the sound engine, sound events, music, situational music, ambience and subtitles match vanilla |
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000 crafts), block entity removal (containers spill, 4,000 cases), copper golem chest sorting and the sculk family (435 scenarios: shrieker warnings and warden summoning included) match vanilla |
| Block behaviours and ticking | 1 | None: block updates, random ticks, redstone, dispensers (6,000 cases), crafters (4,000), container spills (4,000), copper golems and the sculk family (435 scenarios) match |
### Known issues
@@ -170,9 +153,7 @@ Skins served over https show the default skin unless already cached; pack icons
## Licence
[MIT](LICENSE), for the project's original work only. Dependencies keep their own licences. Minecraft is a trademark of Mojang Synergies AB; Minecraft code and assets belong to Mojang and Microsoft and are covered by the [Minecraft EULA](https://www.minecraft.net/en-us/eula). Decompiled game source and prepared resources stay on your machine and are never published.
Third-party credits and licenses: [Third-party notices](THIRD_PARTY_NOTICES.md).
[MIT](LICENSE), for the project's original work; dependencies keep their own licences ([notices](THIRD_PARTY_NOTICES.md)). Minecraft is a trademark of Mojang Synergies AB; its code and assets belong to Mojang and Microsoft under the [Minecraft EULA](https://www.minecraft.net/en-us/eula).
## Author
+2 -2
View File
@@ -326,11 +326,11 @@ A mapping table from intermediary and SRG names so pre-26.1 jars can be classifi
#### A modpack's own resource and shader packs
Importing a modpack also brings the resource packs and shader packs it ships with, where Horizon can use them.
Imported modpacks carry their resource packs into a profile of their own. Shader-pack conversion is still pending.
<details><summary>Technical</summary>
Porting a pack carries data, assets and worldgen today; the resource packs and shader packs bundled with it are left out. Resource packs can be carried as they are; shader packs go through the converter ([docs/modpacks.md](docs/modpacks.md)).
Resource-pack carry-over is implemented locally: Modrinth common/client overrides, CurseForge's override folder, installed instances (ZIP and directory packs), and hash-checked indexed ZIP downloads. The launcher keeps profile edits and lets resource-only profiles play; packs are enabled in Resource Packs. Synthetic import and profile tests cover the file pipeline; in-game rendering has not been verified. Shader-pack conversion is still pending, so this combined item remains partial ([docs/modpacks.md](docs/modpacks.md)).
</details>
+3 -2
View File
@@ -3,8 +3,9 @@
## wgpu-hal
`third_party/wgpu-hal` is [wgpu-hal](https://github.com/gfx-rs/wgpu) 30.0.1 by the gfx-rs
developers, with one upstream fix applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124)). It is licensed under MIT or
developers, with upstream fixes applied
([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124),
[gfx-rs/wgpu#10714](https://github.com/gfx-rs/wgpu/pull/10714)). It is licensed under MIT or
Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`).
## Pixelify Sans
+457
View File
@@ -0,0 +1,457 @@
#!/usr/bin/env python3
"""Server load bench: the same horizon-bots load on Horizon, vanilla 26.3 or Pumpkin.
python3 bench/bench.py --server horizon --exe target/release/horizon-server --bots 50
python3 bench/bench.py --server vanilla --jar ~/mc/server.jar --bots 50 --pre-generated ~/worlds/seed12345
python3 bench/bench.py --server pumpkin --exe ./pumpkin --bots 50
Every run uses a fresh scratch folder (bound to 127.0.0.1, offline, creative so the bots can fly), starts the
server, lets `horizon-bots` join one every --join-every seconds, warms up, then measures for --seconds:
- TPS: `time query gametime` at the start and the end of the window, as game ticks per wall-clock second;
- MSPT: `tick query` every 5 s (average, P50, P95, P99 over the last 100 ticks), averaged over the window;
- RSS and CPU of the server's whole process tree, once a second (needs `ps`: Linux and macOS);
- bandwidth: `bytes_in`/`bytes_out` from the bots' report, per player and second online (each bot's whole time
on the server, its join included), when the bots report them (they do not yet: the field is null until S-6); join time: the bots' `joined_after_ms`.
It writes one JSON report (--out) and prints the Markdown table of the design (docs/design.md, section 1).
Vanilla is the owner's own server.jar (--jar): nothing of Mojang's is fetched or committed. Pumpkin is a released
binary run as a black box (--exe); its Bedrock listeners, LAN broadcast and telemetry are turned off in the
config this script writes (--pumpkin-config adds a TOML file of your own on top if a release names a key
differently). `--pre-generated DIR` copies a finished world into every run's scratch folder as `world`, so runs
repeat on the same terrain. Build the bots first: cargo build --release -p horizon-bots
"""
import argparse
import datetime
import json
import os
from pathlib import Path
import platform
import re
import shutil
import socket
import statistics
import subprocess
import sys
import tempfile
import threading
import time
ROOT = Path(__file__).resolve().parent.parent
EXE = '.exe' if os.name == 'nt' else ''
SERVERS = ('horizon', 'vanilla', 'pumpkin')
PROPERTIES = {
'level-seed': '12345',
'view-distance': '10',
'simulation-distance': '10',
'online-mode': 'false',
'white-list': 'false',
'gamemode': 'creative',
'force-gamemode': 'true',
'spawn-protection': '0',
'max-players': '200',
'difficulty': 'easy',
'server-ip': '127.0.0.1',
'enable-query': 'false',
'enable-rcon': 'false',
}
# Pumpkin's two config files. Bedrock, LAN broadcast and telemetry (plugins' included) are off.
PUMPKIN_CONFIGURATION = '''\
server_address = "127.0.0.1:{port}"
seed = "{seed}"
max_players = 200
view_distance = {view}
simulation_distance = {view}
online_mode = false
default_difficulty = "Easy"
allow_chat_reports = false
encryption = false
lan_broadcast = false
[networking.bedrock]
enabled = false
[networking.lan_broadcast]
enabled = false
[networking.query]
enabled = false
[networking.rcon]
enabled = false
'''
PUMPKIN_FEATURES = '''\
[telemetry]
enabled = false
[plugins]
enabled = false
'''
DONE = re.compile(r'Done|listening|Server started', re.IGNORECASE)
TICK_AVERAGE = re.compile(r'Average time per tick: ([0-9.]+) ?ms')
TICK_PERCENTILES = re.compile(r'P50: ([0-9.]+) ?ms P95: ([0-9.]+) ?ms P99: ([0-9.]+) ?ms')
GAMETIME = re.compile(r'The time is (-?\d+)')
def free_port():
with socket.socket() as s:
s.bind(('127.0.0.1', 0))
return s.getsockname()[1]
def parse_tick_query(lines):
"""MSPT numbers from the lines `tick query` printed, or None until both lines are there."""
average = percentiles = None
for line in lines:
if (m := TICK_AVERAGE.search(line)) and average is None:
average = float(m.group(1))
if (m := TICK_PERCENTILES.search(line)) and percentiles is None:
percentiles = tuple(float(g) for g in m.groups())
if average is None or percentiles is None:
return None
return {'average': average, 'p50': percentiles[0], 'p95': percentiles[1], 'p99': percentiles[2]}
def parse_gametime(lines):
"""The game time `time query gametime` printed, or None."""
for line in lines:
if m := GAMETIME.search(line):
return int(m.group(1))
return None
def tps(start_ticks, end_ticks, seconds):
"""Game ticks per wall-clock second over the window, or None when a query went unanswered."""
if start_ticks is None or end_ticks is None or seconds <= 0:
return None
return round((end_ticks - start_ticks) / seconds, 2)
def percentile(values, p):
if not values:
return None
ordered = sorted(values)
return ordered[min(len(ordered) - 1, int(p / 100 * len(ordered)))]
def cpu_seconds(text):
"""ps's cumulative CPU time: [[DD-]HH:]MM:SS[.ss]."""
days, _, rest = text.rpartition('-')
seconds = 0.0
for part in rest.split(':'):
seconds = seconds * 60 + float(part)
return seconds + (float(days) * 86400 if days else 0)
def tree_usage(root):
"""RSS (bytes) and CPU seconds of a process and all its descendants; None without `ps`."""
try:
out = subprocess.run(['ps', '-A', '-o', 'pid=,ppid=,rss=,time='], capture_output=True, text=True).stdout
except OSError:
return None
rows = {}
for line in out.splitlines():
parts = line.split()
if len(parts) == 4:
rows[int(parts[0])] = (int(parts[1]), int(parts[2]) * 1024, cpu_seconds(parts[3]))
tree, frontier = {root}, [root]
while frontier:
parent = frontier.pop()
for pid, (ppid, _, _) in rows.items():
if ppid == parent and pid not in tree:
tree.add(pid)
frontier.append(pid)
return sum(rows[p][1] for p in tree if p in rows), sum(rows[p][2] for p in tree if p in rows)
def prepare(kind, directory, port, args):
"""Writes the server's settings into its scratch folder and returns the command that starts it."""
directory = Path(directory)
if args.pre_generated:
shutil.copytree(args.pre_generated, directory / 'world')
props = dict(PROPERTIES, **{'server-port': str(port), 'level-seed': str(args.seed),
'view-distance': str(args.view_distance),
'simulation-distance': str(args.view_distance)})
if kind == 'pumpkin':
config = directory / 'config'
config.mkdir()
(config / 'configuration.toml').write_text(PUMPKIN_CONFIGURATION.format(
port=port, seed=args.seed, view=args.view_distance))
(config / 'features.toml').write_text(PUMPKIN_FEATURES)
if args.pumpkin_config:
with (config / 'configuration.toml').open('a') as f:
f.write('\n' + Path(args.pumpkin_config).read_text())
return [Path(args.exe).resolve()]
(directory / 'server.properties').write_text(''.join(f'{k}={v}\n' for k, v in props.items()))
(directory / 'eula.txt').write_text('eula=true\n')
if kind == 'vanilla':
return ['java', f'-Xms{args.heap}', f'-Xmx{args.heap}', '-jar', Path(args.jar).resolve(), 'nogui']
server = Path(args.exe).resolve()
subprocess.run([server, '--dir', directory, 'import', directory / 'server.properties'], check=True,
capture_output=True)
toml = directory / 'server.toml'
# No web panel in a bench.
toml.write_text(re.sub(r'(?m)^bind = "127\.0\.0\.1:8130"', 'bind = ""', toml.read_text()))
return [server, '--dir', directory, 'run']
class Server:
"""A server with its console on stdin and its output collected."""
def __init__(self, command, directory, log_path):
self.lines = []
self.lock = threading.Lock()
self.log = log_path.open('w', encoding='utf-8')
env = dict(os.environ, HORIZON_NO_AUDIO='1', HORIZON_HOME=str(directory / 'horizon-home'),
HORIZON_LAUNCHER_NO_SELF_UPDATE='1')
self.process = subprocess.Popen([str(c) for c in command], cwd=directory, stdin=subprocess.PIPE,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
encoding='utf-8', errors='replace', env=env)
threading.Thread(target=self._read, daemon=True).start()
def _read(self):
for line in self.process.stdout:
with self.lock:
self.lines.append(line.rstrip('\n'))
self.log.write(line)
self.log.flush()
def count(self):
with self.lock:
return len(self.lines)
def since(self, n):
with self.lock:
return self.lines[n:]
def wait_for(self, regex, timeout):
deadline = time.time() + timeout
while time.time() < deadline:
if any(regex.search(line) for line in self.since(0)):
return
if self.process.poll() is not None:
raise RuntimeError('the server exited:\n' + '\n'.join(self.since(0)[-20:]))
time.sleep(0.1)
raise RuntimeError(f'the server was not up within {timeout} s')
def ask(self, command, parse, timeout=5):
"""Sends a console command and returns parse(its output lines), or None if nothing parsed in time."""
start = self.count()
try:
self.process.stdin.write(command + '\n')
self.process.stdin.flush()
except (BrokenPipeError, OSError):
return None
deadline = time.time() + timeout
while time.time() < deadline:
if (value := parse(self.since(start))) is not None:
return value
time.sleep(0.05)
return None
def stop(self):
if self.process.poll() is None:
try:
self.process.stdin.write('stop\n')
self.process.stdin.flush()
self.process.wait(timeout=60)
except (BrokenPipeError, OSError, subprocess.TimeoutExpired):
self.process.kill()
self.process.wait()
self.log.close()
def bots_command(args, port, total):
return [str(args.bots_bin), '--server', f'127.0.0.1:{port}', '--bots', str(args.bots),
'--behaviour', args.behaviour, '--join-every', str(args.join_every), '--seconds', str(int(total)),
'--seed', str(args.seed), '--view-distance', str(args.view_distance), '--report', '-']
def split_bot_output(lines):
"""The per-second samples (JSON lines with a `t`) and the final summary the bots printed."""
samples, rest = [], []
for line in lines:
try:
sample = json.loads(line)
except ValueError:
sample = None
if isinstance(sample, dict) and 't' in sample:
samples.append(sample)
else:
rest.append(line)
text = ''.join(rest)
return samples, (json.loads(text) if text.strip() else {})
def bytes_per_player_second(per_bot, run_seconds, join_every):
"""The bots' bytes over the seconds they were connected: bot i connects i * join_every after the first and
stays to the end of the run. So it is each bot's average from its login on, its join included, not the
measuring window's alone (the bots report one total at the end). None until the bots count bytes (S-6)."""
counted = [(i, b) for i, b in enumerate(per_bot) if 'bytes_in' in b and not b.get('error')]
online = sum(max(run_seconds - i * join_every, 0) for i, _ in counted)
if not counted or online <= 0:
return None
return {k: round(sum(b.get(k, 0) for _, b in counted) / online) for k in ('bytes_in', 'bytes_out')}
def summarize(kind, args, ticks, rss, cpu, gametime, window_seconds, summary, start_seconds, run_seconds):
mean = lambda xs: round(statistics.fmean(xs), 2) if xs else None
joins = [b['joined_after_ms'] for b in summary.get('per_bot', []) if b.get('joined_after_ms', 0) > 0]
bandwidth = bytes_per_player_second(summary.get('per_bot', []), run_seconds, args.join_every)
return {
'server': kind,
'bots': args.bots,
'behaviour': args.behaviour,
'start_seconds': start_seconds,
'tps': tps(*gametime, window_seconds),
'mspt': {k: mean([t[k] for t in ticks]) for k in ('average', 'p50', 'p95', 'p99')},
'mspt_worst_p99': max((t['p99'] for t in ticks), default=None),
'rss_mb': {'mean': mean([r / 1e6 for r in rss]), 'max': round(max(rss) / 1e6, 1) if rss else None},
'cpu_percent': mean(cpu),
'bytes_per_player_second': bandwidth,
'join_ms': {'p50': percentile(joins, 50), 'worst': max(joins, default=None)},
'bots_joined': summary.get('joined'),
'bots_failed': [b for b in summary.get('per_bot', []) if b.get('error')],
'chunks_total': summary.get('chunks'),
'tick_queries': len(ticks),
}
def run_once(kind, args, out):
scratch = Path(tempfile.mkdtemp(prefix=f'horizon-bench-{kind}-'))
result = {'server': kind, 'bots': args.bots, 'behaviour': args.behaviour}
server = None
try:
port = free_port()
command = prepare(kind, scratch, port, args)
started = time.time()
server = Server(command, scratch, out / f'{kind}-{args.bots}.log')
server.wait_for(DONE, args.start_timeout)
start_seconds = round(time.time() - started, 2)
total = args.bots * args.join_every + args.warmup + args.seconds
bot_process = subprocess.Popen(bots_command(args, port, total), stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True)
bot_lines = []
threading.Thread(target=lambda: bot_lines.extend(bot_process.stdout), daemon=True).start()
time.sleep(args.bots * args.join_every + args.warmup)
first_time = server.ask('time query gametime', parse_gametime)
began = time.time()
rss, cpu, ticks = [], [], []
usage = tree_usage(server.process.pid)
last_cpu, last_time = (usage[1] if usage else 0), time.time()
next_query = time.time()
while time.time() - began < args.seconds:
time.sleep(1)
now = time.time()
if usage and (u := tree_usage(server.process.pid)):
rss.append(u[0])
cpu.append((u[1] - last_cpu) / (now - last_time) * 100)
last_cpu, last_time = u[1], now
if now >= next_query:
if (t := server.ask('tick query', parse_tick_query)) is not None:
ticks.append(t)
next_query = now + 5
window = time.time() - began
last = server.ask('time query gametime', parse_gametime)
bot_process.wait(timeout=total + 120)
time.sleep(0.5)
_, summary = split_bot_output(list(bot_lines))
result.update(summarize(kind, args, ticks, rss, cpu, (first_time, last), window, summary, start_seconds,
total))
except Exception as e: # a failed run is reported, and the next one still runs
result['error'] = str(e)
finally:
if server:
server.stop()
shutil.rmtree(scratch, ignore_errors=True)
return result
def table(runs):
"""The Markdown table of the design: one column per server, one row per measure."""
ok = [r for r in runs if 'error' not in r]
head = '| | ' + ' | '.join(r['server'] for r in ok) + ' |\n|---|' + '---|' * len(ok) + '\n'
def cell(r, path):
value = r
for key in path:
value = value.get(key) if isinstance(value, dict) else None
return '-' if value is None else str(value)
rows = [
('Players', ('bots',)),
('TPS', ('tps',)),
('MSPT average', ('mspt', 'average')),
('MSPT p99 (mean of 5 s queries)', ('mspt', 'p99')),
('MSPT p99 (worst query)', ('mspt_worst_p99',)),
('RSS mean MB', ('rss_mb', 'mean')),
('CPU %', ('cpu_percent',)),
('Bytes in per player and s online', ('bytes_per_player_second', 'bytes_in')),
('Join p50 ms', ('join_ms', 'p50')),
('Join worst ms', ('join_ms', 'worst')),
('Start s', ('start_seconds',)),
]
body = ''.join(f'| {name} | ' + ' | '.join(cell(r, path) for r in ok) + ' |\n' for name, path in rows)
failed = ''.join(f'\n{r["server"]} failed: {r["error"].splitlines()[0]}\n' for r in runs if 'error' in r)
return head + body + failed
def check(args):
for kind in args.server:
if kind not in SERVERS:
sys.exit(f'--server: {kind!r} is not one of {", ".join(SERVERS)}')
if 'vanilla' in args.server and not args.jar:
sys.exit('vanilla needs --jar, your own server.jar')
if any(k != 'vanilla' for k in args.server) and not args.exe:
sys.exit('horizon and pumpkin need --exe, the server binary')
if not Path(args.bots_bin).exists():
sys.exit(f'{args.bots_bin} is missing: cargo build --release -p horizon-bots')
if args.pre_generated and not Path(args.pre_generated).is_dir():
sys.exit(f'--pre-generated {args.pre_generated} is not a folder')
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split('\n\n')[0])
parser.add_argument('--server', type=lambda s: s.split(','), default=['horizon'],
help='horizon, vanilla or pumpkin (comma-separated, one run each)')
parser.add_argument('--exe', help='the horizon-server or Pumpkin binary')
parser.add_argument('--jar', help="vanilla's server.jar (your own copy)")
parser.add_argument('--bots-bin', default=str(ROOT / 'target/release' / f'horizon-bots{EXE}'))
parser.add_argument('--bots', type=int, default=50)
parser.add_argument('--behaviour', default='mixed')
parser.add_argument('--join-every', type=float, default=0.5)
parser.add_argument('--warmup', type=int, default=30)
parser.add_argument('--seconds', type=int, default=60)
parser.add_argument('--seed', type=int, default=12345)
parser.add_argument('--view-distance', type=int, default=10)
parser.add_argument('--heap', default='4g', help='the Java heap for vanilla')
parser.add_argument('--pre-generated', help='a finished world folder, copied into every run as `world`')
parser.add_argument('--pumpkin-config', help='extra TOML appended to Pumpkin\'s configuration.toml')
parser.add_argument('--start-timeout', type=int, default=600)
parser.add_argument('--out', type=Path, default=ROOT / '.local/bench', help='the folder for report.json and logs')
args = parser.parse_args(argv)
check(args)
args.out.mkdir(parents=True, exist_ok=True)
runs = []
for kind in args.server:
print(f'{kind}, {args.bots} {args.behaviour} bots...', flush=True)
runs.append(run_once(kind, args, args.out))
print(json.dumps(runs[-1]), flush=True)
report = {
'kind': 'horizon-bench',
'date': datetime.datetime.now(datetime.timezone.utc).isoformat(timespec='seconds'),
'machine': {'system': platform.system(), 'machine': platform.machine(), 'cpus': os.cpu_count()},
'settings': {k: str(v) if isinstance(v, Path) else v for k, v in vars(args).items()},
'runs': runs,
}
path = args.out / 'report.json'
path.write_text(json.dumps(report, indent=2) + '\n')
print('\n' + table(runs) + f'\nWrote {path}')
return 1 if any('error' in r for r in runs) else 0
if __name__ == '__main__':
sys.exit(main())
@@ -3,8 +3,8 @@ name = "{name}"
version = "0.1.0"
description = ""
authors = []
# A content mod: blocks and items from data/ and assets/, no code (API 0.2).
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
api = "0.2"
side = "both"
# registries: "May add blocks and items to your worlds".
# registries: "May add blocks, items and mob effects to your worlds".
capabilities = ["registries"]
+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);
}
}
+58 -15
View File
@@ -2,17 +2,22 @@
//! `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;
/// What one check's violation level leads to.
@@ -71,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 {
@@ -88,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(),
@@ -119,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
@@ -128,9 +134,31 @@ 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
/// (`prediction::Record`), with a ping a tick to place what the server changes, and played again
/// (horizon-client's `lan::prediction`). A move that lands farther than `tolerance` from the
/// replay flags, by how far it lands (one `tolerance` off weighs 1, at most 5 a tick), and the
/// 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. 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.
@@ -171,6 +199,10 @@ impl Config {
));
}
}
let tolerance = self.prediction.tolerance;
if !tolerance.is_finite() || tolerance <= 0.0 {
return Err("anticheat.prediction: tolerance is a number of blocks above 0".into());
}
Ok(())
}
}
@@ -198,6 +230,10 @@ pub struct Player {
pub timer: timer::Balance,
/// The server tick and height where the ground last held the 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 {
@@ -241,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]
@@ -257,6 +298,8 @@ mod tests {
assert_eq!(c.timer.kick, 0.0);
assert_eq!(c.timer.tolerance_ms, 500.0);
assert_eq!(c.timer.alert, TimerConfig::default().alert);
let zero = "[anticheat]\n[anticheat.prediction]\ntolerance = 0\n";
assert!(Config::from_server_toml(zero).is_err());
let off = "[anticheat]\nenabled = true\n[anticheat.timer]\nenabled = false\n";
assert!(!Config::from_server_toml(off).unwrap().runs(Check::Timer));
}
+404
View File
@@ -0,0 +1,404 @@
//! The movement prediction's record: each of a player's client ticks as the server heard them, so
//! the server can play the tick again with vanilla's movement code and compare (docs/anticheat.md).
//!
//! A vanilla client ends every tick with ServerboundClientTickEndPacket, after the tick's input
//! (ServerboundPlayerInputPacket, sent when the keys change) and its move (ServerboundMovePlayerPacket,
//! when it moved or turned). Those packets arrive in order, so the packets between two tick ends are one
//! client tick.
//!
//! What the server changes about the player (a push, a teleport, an effect) takes effect on the client
//! when it handles the packet, which is some client ticks after the server sent it. At the end of each of
//! its ticks the server sends a ClientboundPingPacket whose id is that server tick; the client answers
//! with ServerboundPongPacket as soon as it handles it, before its next tick runs. A client tick knows
//! the last server tick it had handled: every change of that server tick or an earlier one was in effect
//! during the client tick, none of a later one.
use std::collections::VecDeque;
/// Client ticks kept: two seconds at 20 a second.
pub const KEPT: usize = 40;
/// Server changes kept, the oldest dropped first.
const CHANGES_KEPT: usize = 64;
/// Changes near the player kept until every kept tick has seen them.
const NEAR_KEPT: usize = 1024;
/// Something the server sent the player that changes how its client moves. The client applies it when
/// it handles the packet, before its next tick.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Change {
/// ClientboundSetEntityMotionPacket for the player itself: its new velocity, as the client
/// decodes it.
Velocity([f64; 3]),
/// ClientboundExplodePacket's player knockback, added to the velocity.
Knockback([f64; 3]),
/// ClientboundUpdateAttributesPacket or a mob effect for the player itself: the server's
/// attributes and effects, as they are when the replay reaches it.
State,
/// ClientboundPlayerAbilitiesPacket (a game mode, flight given or taken), or the player's chest
/// slot (a glider put on or taken off): the replay takes these from the server every tick, so a
/// tick played before the client had them may be off.
Abilities,
}
/// A teleport the client accepted: the replay starts again from it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Restart {
/// Where it put the player.
pub at: [f64; 3],
/// It kept the player's motion (an End gateway's).
pub kept_motion: bool,
/// Client ticks ended and changes heard before it: the ones after are the replay's.
pub ended: u64,
pub changed: u64,
}
/// One client tick.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ClientTick {
/// The keys held (ServerboundPlayerInputPacket's flags: forward, backward, left, right, jump,
/// shift, sprint), as last sent.
pub input: u8,
/// Sprinting, as the player's commands left it (ServerboundPlayerCommandPacket).
pub sprinting: bool,
/// The rotation the client last sent.
pub yaw: f32,
pub pitch: f32,
/// Where the tick's move said the player is, if it said: a move with a position.
pub position: Option<[f64; 3]>,
/// The tick's move packet's flags (1: on the ground, 2: against a wall), if it sent one.
pub flags: Option<u8>,
/// The last server tick whose packets the client had handled (its pong) before this tick ended.
pub seen: Option<u64>,
}
/// A player's last client ticks.
#[derive(Clone, Debug, Default)]
pub struct Record {
/// The tick being heard.
current: ClientTick,
/// The ended ones, oldest first, at most [`KEPT`].
ticks: VecDeque<ClientTick>,
/// The last server tick the client answered for.
seen: Option<u64>,
/// Client ticks ended since the record began.
ended: u64,
/// Where an accepted teleport put the player, until the replay starts again from there.
restart: Option<Restart>,
/// What the server changed, with the server tick that sent it, oldest first.
changes: VecDeque<(u64, Change)>,
/// Changes heard since the record began.
changed: u64,
/// The blocks the server changed near the player (None: an entity it stopped showing the player
/// from near it, which counts wherever the player has gone since), with the server tick that sent
/// each, until every kept tick has seen them.
near: VecDeque<(u64, Option<[i32; 3]>)>,
/// The server tick of the last of them dropped early ([`NEAR_KEPT`] came after it).
near_dropped: Option<u64>,
}
impl Record {
/// The tick's move: its position if it has one, the rotation if it has one, and its flags.
pub fn moved(&mut self, position: Option<[f64; 3]>, rotation: Option<(f32, f32)>, flags: u8) {
if position.is_some() {
self.current.position = position;
}
if let Some((yaw, pitch)) = rotation {
(self.current.yaw, self.current.pitch) = (yaw, pitch);
}
self.current.flags = Some(flags);
}
/// ServerboundPongPacket for the ping sent at the end of `server_tick`. False for an answer to a
/// ping that was never sent, or one older than an answer already heard: a vanilla client answers
/// every ping, once, in order.
pub fn pong(&mut self, server_tick: u64, sent_up_to: u64) -> bool {
if server_tick > sent_up_to || self.seen.is_some_and(|seen| server_tick <= seen) {
return false;
}
self.seen = Some(server_tick);
true
}
/// ServerboundClientTickEndPacket: the tick ends with these keys held and this sprinting. The
/// next tick starts with the same keys and rotation, until the client says otherwise.
pub fn tick_end(&mut self, input: u8, sprinting: bool) -> &ClientTick {
self.current.input = input;
self.current.sprinting = sprinting;
self.current.seen = self.seen;
let next = ClientTick {
yaw: self.current.yaw,
pitch: self.current.pitch,
..ClientTick::default()
};
let ended = std::mem::replace(&mut self.current, next);
if self.ticks.len() == KEPT {
self.ticks.pop_front();
}
self.ticks.push_back(ended);
self.ended += 1;
// What the oldest tick kept had seen, every later one had too.
if let Some(seen) = self.ticks.front().and_then(|t| t.seen) {
while self.near.front().is_some_and(|&(sent, _)| sent <= seen) {
self.near.pop_front();
}
self.near_dropped = self.near_dropped.filter(|&sent| sent > seen);
}
self.ticks.back().expect("just pushed")
}
/// The ended ticks, oldest first.
pub fn ticks(&self) -> impl DoubleEndedIterator<Item = &ClientTick> + ExactSizeIterator {
self.ticks.iter()
}
/// The last server tick the client answered for.
pub fn seen(&self) -> Option<u64> {
self.seen
}
/// Client ticks ended since the record began.
pub fn ended(&self) -> u64 {
self.ended
}
/// The ticks ended after the first `played`, oldest first; None when some of them are no longer
/// kept.
pub fn since(&self, played: u64) -> Option<impl Iterator<Item = &ClientTick>> {
let new = self.ended.checked_sub(played)?;
let new = usize::try_from(new)
.ok()
.filter(|&n| n <= self.ticks.len())?;
Some(self.ticks.iter().skip(self.ticks.len() - new))
}
/// `change`, sent during server tick `server_tick` (before that tick's ping).
pub fn changed(&mut self, server_tick: u64, change: Change) {
if self.changes.len() == CHANGES_KEPT {
self.changes.pop_front();
}
self.changes.push_back((server_tick, change));
self.changed += 1;
}
/// Changes heard since the record began.
pub fn changes(&self) -> u64 {
self.changed
}
/// The changes after the first `applied`, oldest first; None when some of them are no longer kept.
pub fn changes_since(
&self,
applied: u64,
) -> Option<impl Iterator<Item = &(u64, Change)> + Clone> {
let new = self.changed.checked_sub(applied)?;
let new = usize::try_from(new)
.ok()
.filter(|&n| n <= self.changes.len())?;
Some(self.changes.iter().skip(self.changes.len() - new))
}
/// During server tick `server_tick` (before that tick's ping) the server sent the client the block
/// at `pos`.
pub fn changed_near(&mut self, server_tick: u64, pos: [i32; 3]) {
self.note_near(server_tick, Some(pos));
}
/// During server tick `server_tick` the server stopped showing the client an entity near it (a
/// firework it flew with, a mob that pushed it): the client may still have it, wherever it goes.
pub fn entity_gone(&mut self, server_tick: u64) {
if self.near.back() != Some(&(server_tick, None)) {
self.note_near(server_tick, None);
}
}
fn note_near(&mut self, server_tick: u64, pos: Option<[i32; 3]>) {
if self.near.len() == NEAR_KEPT {
self.near_dropped = self.near.pop_front().map(|(sent, _)| sent);
}
self.near.push_back((server_tick, pos));
}
/// Whether a client tick that had `seen` up to that server tick (its [`ClientTick::seen`]) may
/// not have had a change at a position for which `near` holds: it still had the old block, or the
/// entity, where the server no longer has it.
pub fn unseen_near(&self, seen: Option<u64>, near: impl Fn([i32; 3]) -> bool) -> bool {
let unseen = |sent: u64| seen.is_none_or(|seen| sent > seen);
self.near_dropped.is_some_and(unseen)
|| self
.near
.iter()
.any(|&(sent, pos)| unseen(sent) && pos.is_none_or(&near))
}
/// The client accepted a teleport to `at` (ServerboundAcceptTeleportationPacket): it goes on from
/// there, standing still unless the teleport `kept_motion` (an End gateway's). The ticks it ended
/// before stay, for a replay that hasn't played them yet; the one under way starts again.
pub fn teleported(&mut self, at: [f64; 3], kept_motion: bool) {
self.restart_tick();
self.restart = Some(Restart {
at,
kept_motion,
ended: self.ended,
changed: self.changed,
});
}
/// The last teleport the client accepted, once.
pub fn take_restart(&mut self) -> Option<Restart> {
self.restart.take()
}
/// The ticks before now say nothing about the ones after.
pub fn clear(&mut self) {
self.ticks.clear();
self.restart_tick();
}
fn restart_tick(&mut self) {
self.current = ClientTick {
yaw: self.current.yaw,
pitch: self.current.pitch,
..ClientTick::default()
};
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_change_near_is_unseen_by_the_ticks_before_its_pong() {
let mut r = Record::default();
r.changed_near(4, [1, 2, 3]);
r.changed_near(5, [7, 2, 3]);
let near = |p: [i32; 3]| p[0] < 5;
assert!(r.unseen_near(None, near));
assert!(r.unseen_near(Some(3), near));
assert!(!r.unseen_near(Some(4), near));
assert!(r.unseen_near(Some(4), |_| true));
// Kept until the oldest tick kept had seen it: one that ended before the pong hadn't.
r.tick_end(0, false);
assert!(r.pong(5, 5));
r.tick_end(0, false);
assert!(r.unseen_near(Some(3), near));
for _ in 0..KEPT {
r.tick_end(0, false);
}
assert!(!r.unseen_near(None, |_| true));
// An entity gone counts anywhere.
r.entity_gone(5);
r.entity_gone(5);
assert!(r.unseen_near(Some(4), |_| false));
assert!(!r.unseen_near(Some(5), |_| false));
// One dropped early counts for the ticks that hadn't seen it.
for i in 0..=NEAR_KEPT as u64 {
r.changed_near(6 + i, [100, 0, 0]);
}
assert!(
r.unseen_near(Some(5), near),
"a change dropped unseen counts"
);
assert!(!r.unseen_near(Some(6), near));
}
#[test]
fn a_tick_is_the_packets_between_two_tick_ends() {
let mut r = Record::default();
r.moved(Some([1.0, 2.0, 3.0]), Some((90.0, 10.0)), 1);
let t = *r.tick_end(0b1001, true);
assert_eq!(t.position, Some([1.0, 2.0, 3.0]));
assert_eq!((t.yaw, t.pitch, t.flags), (90.0, 10.0, Some(1)));
assert_eq!((t.input, t.sprinting, t.seen), (0b1001, true, None));
// A tick without a move keeps the rotation and has no position.
let t = *r.tick_end(0, false);
assert_eq!((t.position, t.flags, t.yaw), (None, None, 90.0));
assert_eq!(r.ticks().len(), 2);
}
#[test]
fn pongs_say_which_server_ticks_a_client_tick_had_seen() {
let mut r = Record::default();
assert!(r.pong(5, 6));
r.tick_end(0, false);
assert!(r.pong(6, 6));
r.tick_end(0, false);
r.tick_end(0, false);
let seen: Vec<_> = r.ticks().map(|t| t.seen).collect();
assert_eq!(seen, [Some(5), Some(6), Some(6)]);
// Never sent, answered twice, or out of order.
assert!(!r.pong(9, 6));
assert!(!r.pong(6, 6));
assert!(!r.pong(4, 6));
assert_eq!(r.seen(), Some(6));
}
#[test]
fn only_the_last_two_seconds_are_kept() {
let mut r = Record::default();
for i in 0..KEPT + 5 {
r.moved(Some([i as f64, 0.0, 0.0]), None, 0);
r.tick_end(0, false);
}
assert_eq!(r.ticks().len(), KEPT);
assert_eq!(r.ticks().next().unwrap().position, Some([5.0, 0.0, 0.0]));
r.clear();
assert_eq!(r.ticks().len(), 0);
}
#[test]
fn the_replay_takes_the_ticks_it_has_not_played() {
let mut r = Record::default();
for i in 0..3 {
r.moved(Some([i as f64, 0.0, 0.0]), None, 0);
r.tick_end(0, false);
}
fn x(r: &Record, played: u64) -> Option<Vec<f64>> {
r.since(played)
.map(|t| t.map(|t| t.position.unwrap()[0]).collect())
}
assert_eq!(x(&r, 1), Some(vec![1.0, 2.0]));
assert_eq!(x(&r, 3), Some(vec![]));
// Played ahead of the record, or so far behind the ticks are gone.
assert_eq!(x(&r, 4), None);
r.clear();
assert_eq!(x(&r, 2), None);
assert_eq!(x(&r, 3), Some(vec![]));
r.moved(Some([8.0, 0.0, 0.0]), None, 0);
r.tick_end(0, false);
r.moved(Some([9.0, 0.0, 0.0]), None, 0);
r.teleported([5.0, 6.0, 7.0], false);
// A tick that ends before the replay starts again is still one after the teleport.
r.tick_end(0, false);
let restart = r.take_restart().unwrap();
assert_eq!((restart.at, restart.kept_motion), ([5.0, 6.0, 7.0], false));
assert_eq!(r.since(restart.ended).unwrap().count(), 1);
// The tick ended before it stays for a replay that hasn't played it; the move of the one
// under way is gone.
let positions: Vec<_> = r.since(3).unwrap().map(|t| t.position).collect();
assert_eq!(positions, [Some([8.0, 0.0, 0.0]), None]);
assert_eq!(r.take_restart(), None);
}
#[test]
fn changes_keep_the_server_tick_that_sent_them() {
let mut r = Record::default();
r.changed(3, Change::Velocity([0.0, 0.4, 0.0]));
r.changed(5, Change::State);
let all: Vec<_> = r.changes_since(0).unwrap().copied().collect();
assert_eq!(
all,
[(3, Change::Velocity([0.0, 0.4, 0.0])), (5, Change::State)]
);
assert_eq!(r.changes_since(1).unwrap().count(), 1);
assert!(r.changes_since(3).is_none());
for i in 0..CHANGES_KEPT as u64 {
r.changed(6 + i, Change::State);
}
// The first two are gone.
assert!(r.changes_since(1).is_none());
assert_eq!(r.changes_since(2).unwrap().count(), CHANGES_KEPT);
}
}
+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]);
+238 -17
View File
@@ -15,10 +15,13 @@
//! entered the view apart from chunks sent again while the bot still held them (`resent`), and counts the
//! times the server pulled a bot back (`pulled_back`: a teleport after the first, as for "moved too quickly").
//! The report also has wire bytes in and out (`bytes_in`, `bytes_out`, per bot and in total, after
//! compression) and join-time percentiles (`join_time`: p50, p99 and max in milliseconds).
use horizon_protocol::{
PROTOCOL_VERSION,
buf::{Reader, Writer},
connection::{ConnWriter, Connection},
connection::{ConnWriter, Connection, Counters},
crypto::offline_uuid,
ids,
item::Stack,
@@ -33,7 +36,7 @@ use serde_json::json;
use std::{
collections::HashSet,
sync::{
Arc, LazyLock, Mutex,
Arc, LazyLock, Mutex, OnceLock,
atomic::{AtomicBool, AtomicU64, Ordering},
},
thread,
@@ -60,9 +63,14 @@ 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
server's replies pile up)
--join-every SECONDS default 1
@@ -75,6 +83,12 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--height H blocks above spawn the bots fly at (walk, explore, idle); default 24
--ground claim the ground in every move (with --height 0 on a flat world: walking
on it; higher, or over water: a no-fall or jesus cheat, to test an anticheat)
--give SLOT:ID:COUNT,... creative-slot packets sent once joined (inventory menu slot, item
registry id, count), to test what a server takes
--click X,Y,Z:SLOT:BUTTON:MODE 3 s after joining, open the block at X,Y,Z (a chest), then a second later
click SLOT of its window (ServerboundContainerClickPacket), to test what a server takes
--raw TICK:ID:HEX,... raw serverbound play packets (id, body in hex) sent TICK ticks after joining,
to test what a server takes
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
--report FILE a JSON summary at the end (- prints it after the per-second lines)
";
@@ -131,10 +145,18 @@ 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,
height: f64,
ground: bool,
/// --raw: (tick after joining, packet id, body).
raw: Vec<(u64, i32, Vec<u8>)>,
/// ServerboundSetCreativeModeSlotPackets to send once joined: (slot, item id, count).
give: Vec<(i16, i32, i32)>,
/// --click: the block to open, then the slot, button and click type in its window.
click: Option<([i32; 3], i16, i8, i32)>,
name: String,
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
@@ -156,10 +178,15 @@ 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,
height: 24.0,
ground: false,
raw: Vec::new(),
give: Vec::new(),
click: None,
name: "Bot".into(),
report: None,
brand: None,
@@ -175,6 +202,14 @@ 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;
}
if arg == "--ground" {
o.ground = true;
continue;
@@ -202,6 +237,53 @@ fn options() -> Result<Options, String> {
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--brand" => o.brand = Some(value),
"--give" => {
for part in value.split(',') {
let n: Vec<&str> = part.split(':').collect();
let parsed = match n[..] {
[s, i, c] => s.parse().ok().zip(i.parse().ok()).zip(c.parse().ok()),
_ => None,
};
let ((slot, item), count) = parsed.ok_or_else(|| number("SLOT:ID:COUNT"))?;
o.give.push((slot, item, count));
}
}
"--click" => {
let bad = || number("X,Y,Z:SLOT:BUTTON:MODE");
let n: Vec<&str> = value.split(':').collect();
let [at, slot, button, mode] = n[..] else {
return Err(bad());
};
let at: Vec<i32> = at
.split(',')
.map(str::parse)
.collect::<Result<_, _>>()
.map_err(|_| bad())?;
let at = <[i32; 3]>::try_from(at).map_err(|_| bad())?;
let parse = |s: &str| s.parse::<i32>().map_err(|_| bad());
o.click = Some((at, parse(slot)? as i16, parse(button)? as i8, parse(mode)?));
}
"--raw" => {
for part in value.split(',') {
let bad = || number("TICK:ID:HEX");
let [tick, id, hex] = part.split(':').collect::<Vec<_>>()[..] else {
return Err(bad());
};
let body = (0..hex.len())
.step_by(2)
.map(|i| {
hex.get(i..i + 2)
.and_then(|b| u8::from_str_radix(b, 16).ok())
})
.collect::<Option<Vec<u8>>>()
.ok_or_else(bad)?;
o.raw.push((
tick.parse().map_err(|_| bad())?,
id.parse().map_err(|_| bad())?,
body,
));
}
}
"--height" => o.height = value.parse().map_err(|_| number("blocks"))?,
"--timer" => {
o.timer = value.parse().map_err(|_| number("a speed, like 1.5"))?;
@@ -258,15 +340,50 @@ struct Bot {
teleport: Mutex<Option<[f64; 3]>>,
error: Mutex<Option<String>>,
joined_after_ms: AtomicU64,
/// The connection's byte counters (wire bytes, after compression and encryption), set once it is open.
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>>,
}
impl Bot {
fn bytes_in(&self) -> u64 {
self.counters
.get()
.map_or(0, |c| c.bytes_in.load(Ordering::Relaxed).max(0) as u64)
}
fn bytes_out(&self) -> u64 {
self.counters
.get()
.map_or(0, |c| c.bytes_out.load(Ordering::Relaxed).max(0) as u64)
}
}
/// Nearest-rank percentile (`p` in 0..=100) of an ascending-sorted slice; 0 when it is empty.
fn percentile(sorted: &[u64], p: u64) -> u64 {
if sorted.is_empty() {
return 0;
}
let rank = (p * sorted.len() as u64).div_ceil(100).max(1) as usize;
sorted[rank - 1]
}
/// Join-time summary in milliseconds over the bots that got in: count, p50, p99 and max.
fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
times.sort_unstable();
json!({
"count": times.len(),
"p50_ms": percentile(&times, 50),
"p99_ms": percentile(&times, 99),
"max_ms": times.last().copied().unwrap_or(0),
})
}
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
struct Random(u64);
impl Random {
@@ -593,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(())
@@ -692,8 +810,14 @@ 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());
@@ -716,7 +840,9 @@ fn run_bot(
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
send(raw(sb::PLAYER_LOADED, |_| {}));
if !o.no_loaded {
send(raw(sb::PLAYER_LOADED, |_| {}));
}
bot.deaf.store(o.no_read, Ordering::Relaxed);
// What the costly floods need in hand (creative: the server takes the bot's word for it).
let tool = match behaviour {
@@ -724,6 +850,15 @@ fn run_bot(
Behaviour::FloodBook => Some(Stack::new(WRITABLE_BOOK, 1)),
_ => None,
};
for &(slot, item, count) in &o.give {
send(
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
slot,
item: Some(Stack::new(item, count)),
})
.encode(),
);
}
if tool.is_some() {
send(
play::Serverbound::SetCreativeModeSlot(play::SetCreativeModeSlot {
@@ -848,6 +983,43 @@ fn run_bot(
}
}
}
if let Some((at, slot, button, input)) = o.click {
// Ticks of 50 ms: the block 3 s after joining, the click a second later.
if tick == 60 {
sequence += 1;
send(
play::Serverbound::UseItemOn(play::UseItemOn {
hand: 0,
pos: at,
face: 1,
cursor: [0.5, 1.0, 0.5],
inside: false,
world_border: false,
sequence,
})
.encode(),
);
} else if tick == 80 {
// The first window a player opens is 1.
send(
play::Serverbound::ContainerClick(play::ContainerClick {
container: 1,
state: 0,
slot,
button,
input,
changed: Vec::new(),
carried: None,
})
.encode(),
);
}
}
for (at, id, body) in &o.raw {
if *at == tick {
send(raw(*id, |w| w.bytes(body)));
}
}
// The costly floods go out wherever the bot is, chunks or not (--no-read gets none).
for i in 0..o.flood {
sequence += 1;
@@ -855,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(
@@ -996,10 +1171,20 @@ fn main() {
"resent": bots.iter().map(|b| b.resent.load(Ordering::Relaxed)).sum::<u64>(),
"pulled_back": bots.iter().map(|b| b.pulled_back.load(Ordering::Relaxed)).sum::<u64>(),
"waited_ticks": bots.iter().map(|b| b.waited.load(Ordering::Relaxed)).sum::<u64>(),
"bytes_in": bots.iter().map(|b| b.bytes_in()).sum::<u64>(),
"bytes_out": bots.iter().map(|b| b.bytes_out()).sum::<u64>(),
"join_time": join_summary(
bots.iter()
.map(|b| b.joined_after_ms.load(Ordering::SeqCst))
.filter(|&ms| ms > 0)
.collect(),
),
"per_bot": bots.iter().enumerate().map(|(i, b)| json!({
"name": format!("{}{i}", o.name),
"behaviour": behaviour_of(&o.behaviour, i).name(),
"joined_after_ms": b.joined_after_ms.load(Ordering::SeqCst),
"bytes_in": b.bytes_in(),
"bytes_out": b.bytes_out(),
"chunks": b.chunks.load(Ordering::Relaxed),
"unique_chunks": b.unique.load(Ordering::Relaxed),
"resent": b.resent.load(Ordering::Relaxed),
@@ -1034,3 +1219,39 @@ fn main() {
}
std::process::exit(if errors.is_empty() { 0 } else { 1 });
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn percentiles_use_nearest_rank() {
assert_eq!(percentile(&[], 50), 0);
assert_eq!(percentile(&[7], 99), 7);
let v: Vec<u64> = (1..=100).collect();
assert_eq!(percentile(&v, 50), 50);
assert_eq!(percentile(&v, 99), 99);
assert_eq!(percentile(&[10, 20, 30, 40], 50), 20);
}
#[test]
fn join_summary_sorts_and_reports_max() {
let s = join_summary(vec![300, 100, 200]);
assert_eq!(s["count"], 3);
assert_eq!(s["p50_ms"], 200);
assert_eq!(s["p99_ms"], 300);
assert_eq!(s["max_ms"], 300);
assert_eq!(join_summary(vec![])["count"], 0);
}
#[test]
fn bot_bytes_come_from_the_connection_counters() {
let bot = Bot::default();
assert_eq!((bot.bytes_in(), bot.bytes_out()), (0, 0));
let counters = Arc::new(Counters::default());
counters.bytes_in.store(1234, Ordering::Relaxed);
counters.bytes_out.store(56, Ordering::Relaxed);
let _ = bot.counters.set(counters);
assert_eq!((bot.bytes_in(), bot.bytes_out()), (1234, 56));
}
}
+114 -39
View File
@@ -186,6 +186,73 @@ impl Parser<'_> {
Err("unterminated string".into())
}
}
/// `click_event` / `hover_event`, or the pre-datafixer `clickEvent` /
/// `hoverEvent` names SNBT and older chat JSON still use.
fn style_event_object<'a>(
o: &'a serde_json::Map<String, Value>,
modern: &str,
legacy: &str,
) -> Option<&'a serde_json::Map<String, Value>> {
o.get(modern)
.or_else(|| o.get(legacy))
.and_then(Value::as_object)
}
/// Style flags from JSON bools, or SNBT `1b`/`0b` which parse as numbers.
fn style_flag(v: Option<&Value>) -> Option<bool> {
match v {
Some(Value::Bool(b)) => Some(*b),
Some(Value::Number(n)) => n.as_i64().map(|i| i != 0),
_ => None,
}
}
fn untrusted_http_url(url: &str) -> bool {
// ExtraCodecs.UNTRUSTED_URI / Util.parseAndValidateUntrustedUri: http and
// https only, scheme case-insensitive.
url.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
}
/// ClickEvent.CODEC plus the legacy `value` field TextComponentHoverAndClickEventFix
/// renames (`url` / `command` / `page`).
fn parse_click_event(e: &serde_json::Map<String, Value>) -> Option<crate::text::ClickEvent> {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
let legacy = || text("value");
match e.get("action").and_then(Value::as_str) {
Some("open_url") => text("url")
.or_else(legacy)
.filter(|u| untrusted_http_url(u))
.map(crate::text::ClickEvent::OpenUrl),
Some("change_page") => e
.get("page")
.and_then(Value::as_i64)
.or_else(|| e.get("value").and_then(Value::as_i64))
.or_else(|| {
e.get("value")
.and_then(Value::as_str)
.and_then(|s| s.parse().ok())
})
.and_then(|p| i32::try_from(p).ok())
.filter(|p| *p >= 1)
.map(crate::text::ClickEvent::ChangePage),
Some("suggest_command") => text("command")
.or_else(legacy)
.map(crate::text::ClickEvent::SuggestCommand),
Some("run_command") => text("command")
.or_else(legacy)
.map(crate::text::ClickEvent::RunCommand),
Some("copy_to_clipboard") => text("value").map(crate::text::ClickEvent::CopyToClipboard),
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
// the dialog itself; the server may send it (isAllowedFromServer).
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
_ => None,
}
}
/// The style fields of a component object.
fn object_style(o: &serde_json::Map<String, Value>) -> Style {
let mut style = Style::default();
@@ -196,52 +263,27 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
});
}
style.shadow_color = o.get("shadow_color").and_then(shadow_color);
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
style.bold = flag("bold");
style.italic = flag("italic");
style.underlined = flag("underlined");
style.strikethrough = flag("strikethrough");
style.obfuscated = flag("obfuscated");
style.bold = style_flag(o.get("bold"));
style.italic = style_flag(o.get("italic"));
style.underlined = style_flag(o.get("underlined"));
style.strikethrough = style_flag(o.get("strikethrough"));
style.obfuscated = style_flag(o.get("obfuscated"));
style.font = o.get("font").and_then(crate::text::font_id);
// ClickEvent.CODEC: the actions a chat line acts on here. A component
// from a server never opens a file.
if let Some(e) = o.get("click_event").and_then(Value::as_object) {
let text = |k: &str| e.get(k).and_then(Value::as_str).map(str::to_owned);
style.click = match e.get("action").and_then(Value::as_str) {
// ClickEvent.OpenUrl.CODEC: ExtraCodecs.UNTRUSTED_URI allows http and
// https only, the scheme in any case (Util.parseAndValidateUntrustedUri).
Some("open_url") => text("url")
.filter(|u| {
u.split_once(':').is_some_and(|(scheme, _)| {
scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https")
})
})
.map(crate::text::ClickEvent::OpenUrl),
// ClickEvent.ChangePage.CODEC: ExtraCodecs.POSITIVE_INT.
Some("change_page") => e
.get("page")
.and_then(Value::as_i64)
.and_then(|p| i32::try_from(p).ok())
.filter(|p| *p >= 1)
.map(crate::text::ClickEvent::ChangePage),
Some("suggest_command") => text("command").map(crate::text::ClickEvent::SuggestCommand),
Some("run_command") => text("command").map(crate::text::ClickEvent::RunCommand),
Some("copy_to_clipboard") => {
text("value").map(crate::text::ClickEvent::CopyToClipboard)
}
// ClickEvent.ShowDialog.CODEC: a `minecraft:dialog` registry id or
// the dialog itself; the server may send it (isAllowedFromServer).
Some("show_dialog") => e.get("dialog").map(|dialog| {
crate::text::ClickEvent::ShowDialog(crate::scoreboard::json_to_tag(dialog))
}),
_ => None,
};
if let Some(e) = style_event_object(o, "click_event", "clickEvent") {
style.click = parse_click_event(e);
}
// HoverEvent.ShowText, ShowItem and ShowEntity.
if let Some(e) = o.get("hover_event").and_then(Value::as_object) {
if let Some(e) = style_event_object(o, "hover_event", "hoverEvent") {
match e.get("action").and_then(Value::as_str) {
Some("show_text") => {
if let Some(value) = e.get("value") {
// Modern `value`, or the older `contents` / nested `text`.
let value = e
.get("value")
.or_else(|| e.get("contents"))
.or_else(|| e.get("text"));
if let Some(value) = value {
style.hover = Some(Box::new(to_text(value, &mut |s| Text::literal(s))));
}
}
@@ -506,10 +548,43 @@ mod tests {
let t = to_text(&v, &mut |_| Text::empty());
assert_eq!(t.plain(), "Hi there");
assert_eq!(t.spans[1].style.color, named_color("red"));
// SNBT `1b` is a number in JSON; style flags still take it as true.
assert_eq!(t.spans[1].style.bold, Some(true));
let (v, _) = parse_snbt("[\"a\",{\"text\":\"b\"}]").unwrap();
assert_eq!(to_text(&v, &mut |_| Text::empty()).plain(), "ab");
}
#[test]
fn legacy_click_event_open_url_uses_value_field() {
use crate::text::ClickEvent;
let modern = serde_json::json!({
"text": "site",
"click_event": {"action": "open_url", "url": "https://example.com"},
"underlined": true
});
let legacy = serde_json::json!({
"text": "site",
"clickEvent": {"action": "open_url", "value": "https://example.com"},
"underlined": 1
});
for component in [modern, legacy] {
let text = to_text(&component, &mut |_| Text::empty());
assert_eq!(
text.spans[0].style.click,
Some(ClickEvent::OpenUrl("https://example.com".into()))
);
assert_eq!(text.spans[0].style.underlined, Some(true));
}
let snbt = r#"{text:"site",underlined:1b,clickEvent:{action:open_url,value:"https://example.com"}}"#;
let (v, _) = parse_snbt(snbt).unwrap();
let text = to_text(&v, &mut |_| Text::empty());
assert_eq!(
text.spans[0].style.click,
Some(ClickEvent::OpenUrl("https://example.com".into()))
);
assert_eq!(text.spans[0].style.underlined, Some(true));
}
#[test]
fn the_font_is_inherited_and_kept() {
let (v, _) =
+29
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")
}
@@ -66,6 +69,15 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/uk_ua.json" => {
include_str!("../../horizon-client/assets/horizon/lang/uk_ua.json")
}
"assets/horizon/lang/es_es.json"
| "assets/horizon/lang/es_ar.json"
| "assets/horizon/lang/es_cl.json"
| "assets/horizon/lang/es_ec.json"
| "assets/horizon/lang/es_mx.json"
| "assets/horizon/lang/es_uy.json"
| "assets/horizon/lang/es_ve.json" => {
include_str!("../../horizon-client/assets/horizon/lang/es_es.json")
}
_ => return None,
};
Some(body.as_bytes().to_vec())
@@ -279,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"));
@@ -287,6 +309,13 @@ mod tests {
uk.get("horizon.options.on_screen_keyboard"),
Some("Екранна клавіатура")
);
let es = Language::load(&[&empty], "es_es");
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
let es_ve = Language::load(&[&empty], "es_ve");
assert_eq!(
es_ve.get("horizon.options.on_screen_keyboard"),
Some("Teclado en pantalla")
);
}
#[test]
+106 -7
View File
@@ -9,7 +9,8 @@ pub enum Category {
Harmful,
Neutral,
}
/// A `MobEffect`: id (without namespace), colour, category, instantaneous.
/// A `MobEffect`: id (vanilla's without namespace, a mod's `<ns>:<path>`),
/// colour, category, instantaneous.
#[derive(Clone, Copy, Debug)]
pub struct EffectDef {
pub id: &'static str,
@@ -84,11 +85,52 @@ pub const EFFECTS: &[EffectDef] = &[
fx("infested", 9214860, H),
fx("breath_of_the_nautilus", 65518, B),
];
pub fn effect(id: &str) -> Option<&'static EffectDef> {
let id = id.trim_start_matches("minecraft:");
EFFECTS.iter().find(|e| e.id == id)
/// The effects content mods declare, registered after vanilla's.
static MOD_EFFECTS: OnceLock<Vec<EffectDef>> = OnceLock::new();
/// Registers the content mods' effects, `<ns>:<path>` ids in registry order.
/// The list is set once per process: false when it already was, and the
/// effects stay those of the first call until the game restarts.
pub fn register_mod_effects(effects: impl IntoIterator<Item = (String, u32, Category)>) -> bool {
if MOD_EFFECTS.get().is_some() {
return false;
}
let defs: Vec<EffectDef> = effects
.into_iter()
.map(|(id, color, category)| {
debug_assert!(id.contains(':'), "{id} has no namespace");
fx(Box::leak(id.into_boxed_str()), color, category)
})
.collect();
MOD_EFFECTS.set(defs).is_ok()
}
/// Namespaced effect ids for mob effect storage.
/// The content mods' effects, in registry order.
pub fn mod_effects() -> &'static [EffectDef] {
MOD_EFFECTS.get().map_or(&[], Vec::as_slice)
}
/// Every registered effect: vanilla's, then the content mods'.
pub fn all_effects() -> impl Iterator<Item = &'static EffectDef> {
EFFECTS.iter().chain(mod_effects())
}
pub fn effect(id: &str) -> Option<&'static EffectDef> {
let short = id.trim_start_matches("minecraft:");
match EFFECTS.iter().find(|e| e.id == short) {
Some(e) => Some(e),
None => mod_effect(id),
}
}
/// Out of line, so vanilla's lookups (every frame, every tick) stay as they were.
#[cold]
#[inline(never)]
fn mod_effect(id: &str) -> Option<&'static EffectDef> {
mod_effects().iter().find(|e| e.id == id)
}
/// `MobEffect.getDescriptionId` (`Util.makeDescriptionId("effect", key)`):
/// `effect.<namespace>.<path>`, `/` in the path as `.`.
pub fn description_id(id: &str) -> String {
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
format!("effect.{namespace}.{}", path.replace('/', "."))
}
/// Namespaced effect ids for mob effect storage; a mod's id already is.
pub fn namespaced(id: &'static str) -> &'static str {
static IDS: OnceLock<HashMap<&'static str, &'static str>> = OnceLock::new();
let key = id.strip_prefix("minecraft:").unwrap_or(id);
@@ -230,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);
@@ -248,3 +295,55 @@ pub fn potion_color(id: Option<&str>) -> u32 {
_ => BASE_POTION_COLOR,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn description_ids_keep_the_namespace() {
assert_eq!(description_id("speed"), "effect.minecraft.speed");
assert_eq!(description_id("minecraft:speed"), "effect.minecraft.speed");
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() {
assert!(register_mod_effects([(
"ruby:glow".to_owned(),
0xFF0000,
Category::Neutral
)]));
assert!(!register_mod_effects([(
"ruby:late".to_owned(),
1,
Category::Harmful
)]));
let glow = effect("ruby:glow").unwrap();
assert_eq!(
(glow.color, glow.category, glow.instant),
(0xFF0000, Category::Neutral, false)
);
assert!(effect("glow").is_none() && effect("ruby:late").is_none());
assert_eq!(namespaced(glow.id), "ruby:glow");
assert_eq!(effect("minecraft:speed").unwrap().id, "speed");
let ids: Vec<_> = all_effects().map(|e| e.id).collect();
assert_eq!(ids.len(), EFFECTS.len() + 1);
assert_eq!(ids.last(), Some(&"ruby:glow"));
}
}
+2 -173
View File
@@ -3,179 +3,8 @@
//! number formatting.
use crate::text::{self, Style, Text};
use horizon_world::nbt::Tag;
/// `Double.toString`.
pub fn java_double(v: f64) -> String {
java_float_text(v, &format!("{v}"), &format!("{v:e}"))
}
/// `Float.toString`.
pub fn java_float(v: f32) -> String {
java_float_text(f64::from(v), &format!("{v}"), &format!("{v:e}"))
}
fn java_float_text(v: f64, plain: &str, sci: &str) -> String {
if v.is_nan() {
return "NaN".into();
}
if v.is_infinite() {
return if v > 0.0 { "Infinity" } else { "-Infinity" }.into();
}
if v == 0.0 {
return if v.is_sign_negative() { "-0.0" } else { "0.0" }.into();
}
let a = v.abs();
if (1.0e-3..1.0e7).contains(&a) {
if plain.contains('.') {
plain.to_owned()
} else {
format!("{plain}.0")
}
} else {
let (mantissa, exponent) = sci.split_once('e').unwrap_or((sci, "0"));
let mantissa = if mantissa.contains('.') {
mantissa.to_owned()
} else {
format!("{mantissa}.0")
};
format!("{mantissa}E{exponent}")
}
}
/// `SnbtGrammar.escapeControlCharacters`.
fn escape_control(c: char) -> Option<&'static str> {
Some(match c {
'\u{8}' => "b",
'\u{c}' => "f",
'\n' => "n",
'\r' => "r",
'\t' => "t",
_ => return None,
})
}
/// `StringTag.quoteAndEscape`.
pub fn quote_and_escape(input: &str) -> String {
let mut out = String::from(" ");
let mut quote = '\0';
for c in input.chars() {
if c == '\\' {
out.push_str("\\\\");
} else if c != '"' && c != '\'' {
match escape_control(c) {
Some(e) => {
out.push('\\');
out.push_str(e);
}
None => out.push(c),
}
} else {
if quote == '\0' {
quote = if c == '"' { '\'' } else { '"' };
}
if quote == c {
out.push('\\');
}
out.push(c);
}
}
if quote == '\0' {
quote = '"';
}
out.replace_range(0..1, &quote.to_string());
out.push(quote);
out
}
fn unquoted_key(key: &str) -> bool {
if key.eq_ignore_ascii_case("true") || key.eq_ignore_ascii_case("false") {
return false;
}
let mut chars = key.chars();
let first_ok = chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '.' || c == '_');
// `[A-Za-z._]+[A-Za-z0-9._+-]*`
first_ok && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '+' | '-'))
}
fn string_of(t: &horizon_world::nbt::JavaString) -> String {
t.to_string_lossy()
}
/// `Tag.toString` (StringTagVisitor).
pub fn to_snbt(tag: &Tag) -> String {
let mut out = String::new();
visit(tag, &mut out);
out
}
fn visit(tag: &Tag, out: &mut String) {
match tag {
Tag::End => out.push_str("END"),
Tag::Byte(v) => out.push_str(&format!("{v}b")),
Tag::Short(v) => out.push_str(&format!("{v}s")),
Tag::Int(v) => out.push_str(&v.to_string()),
Tag::Long(v) => out.push_str(&format!("{v}L")),
Tag::Float(v) => {
out.push_str(&java_float(f32::from_bits(*v)));
out.push('f');
}
Tag::Double(v) => {
out.push_str(&java_double(f64::from_bits(*v)));
out.push('d');
}
Tag::String(s) => out.push_str(&quote_and_escape(&string_of(s))),
Tag::ByteArray(a) => {
out.push_str("[B;");
out.push_str(
&a.iter()
.map(|v| format!("{}B", *v as i8))
.collect::<Vec<_>>()
.join(","),
);
out.push(']');
}
Tag::IntArray(a) => {
out.push_str("[I;");
out.push_str(&a.iter().map(i32::to_string).collect::<Vec<_>>().join(","));
out.push(']');
}
Tag::LongArray(a) => {
out.push_str("[L;");
out.push_str(
&a.iter()
.map(|v| format!("{v}L"))
.collect::<Vec<_>>()
.join(","),
);
out.push(']');
}
Tag::List(l) => {
out.push('[');
for (i, t) in l.iter().enumerate() {
if i > 0 {
out.push(',');
}
visit(t, out);
}
out.push(']');
}
Tag::Compound(c) => {
out.push('{');
let mut entries: Vec<(String, &Tag)> =
c.iter().map(|(k, v)| (string_of(k), v)).collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
for (i, (k, v)) in entries.iter().enumerate() {
if i > 0 {
out.push(',');
}
if unquoted_key(k) {
out.push_str(k);
} else {
out.push_str(&quote_and_escape(k));
}
out.push(':');
visit(v, out);
}
out.push('}');
}
}
}
use horizon_world::snbt_text::string_of;
pub use horizon_world::snbt_text::{java_double, java_float, quote_and_escape, to_snbt};
// ---- the coloured form -----------------------------------------------------
@@ -567,6 +567,34 @@ fn length(v: [f32; 2]) -> f32 {
(v[0] * v[0] + v[1] * v[1]).sqrt()
}
/// 8BitDo Ultimate 2 Wireless (`2dc8:6012`). gilrs' bundled SDL DB only has a
/// Windows row for it; on Linux/Bluetooth the right stick is on ABS_Z/ABS_RX
/// (`LeftZ`/`RightStickX`) until an SDL mapping matches.
pub const BITDO_ULTIMATE2_VID_PID: (u16, u16) = (0x2dc8, 0x6012);
/// Stick axes after correcting pads whose right stick gilrs still exposes on
/// `LeftZ`/`RightStickX` (no matching SDL row for this device version).
/// `sdl_mapped` is true when gilrs already applied an SDL gamecontroller row.
pub fn stick_axes(
left: [f32; 2],
right: [f32; 2],
left_z: f32,
vendor: Option<u16>,
product: Option<u16>,
sdl_mapped: bool,
) -> ([f32; 2], [f32; 2]) {
if !sdl_mapped
&& vendor == Some(BITDO_ULTIMATE2_VID_PID.0)
&& product == Some(BITDO_ULTIMATE2_VID_PID.1)
{
// Default map: RightStickX is the stick's Y, LeftZ its X; RightStickY
// is the right trigger and must not drive look.
(left, [left_z, right[0]])
} else {
(left, right)
}
}
/// A radial deadzone: inside it the stick is centred; beyond it the rest of
/// the travel is rescaled to 0..1 in the same direction, so diagonals aren't
/// clipped and the first movement past the deadzone is small.
@@ -1178,6 +1206,38 @@ mod tests {
assert_eq!(odd, PadSettings::default());
}
#[test]
fn ultimate2_right_stick_on_z_without_sdl() {
let (left, right) = stick_axes(
[0.2, 0.3],
[0.7, -0.9],
-0.5,
Some(BITDO_ULTIMATE2_VID_PID.0),
Some(BITDO_ULTIMATE2_VID_PID.1),
false,
);
assert_eq!(left, [0.2, 0.3]);
assert_eq!(right, [-0.5, 0.7], "X from LeftZ, Y from RightStickX");
let kept = stick_axes(
[0.2, 0.3],
[0.7, -0.9],
-0.5,
Some(BITDO_ULTIMATE2_VID_PID.0),
Some(BITDO_ULTIMATE2_VID_PID.1),
true,
);
assert_eq!(kept, ([0.2, 0.3], [0.7, -0.9]), "SDL row already remapped");
let other = stick_axes(
[0.0, 0.0],
[1.0, 0.0],
0.5,
Some(0x045e),
Some(0x028e),
false,
);
assert_eq!(other, ([0.0, 0.0], [1.0, 0.0]));
}
#[test]
fn radial_deadzone_rescales() {
assert_eq!(radial([0.1, 0.05], 0.15), [0.0, 0.0]);
+89 -4
View File
@@ -10,6 +10,10 @@ pub struct KeyState {
released_by_screen_when_down: bool,
toggle: Option<bool>,
keyboard: bool,
/// Held down by a controller's toggle (Horizon's pad toggle-sneak, which
/// vanilla has no option for): until it lets go, the mapping toggles as
/// if its vanilla option were on, so a screen releases and restores it.
pad_toggled: bool,
}
impl KeyState {
@@ -20,6 +24,7 @@ impl KeyState {
released_by_screen_when_down: false,
toggle: None,
keyboard: true,
pad_toggled: false,
}
}
/// A `ToggleKeyMapping`; `keyboard` is whether the bound key is a keyboard key.
@@ -43,16 +48,28 @@ impl KeyState {
true
}
}
/// The mapping toggles: its option is on, or a pad toggled it down.
fn needs_toggle(&self, option: bool) -> bool {
option || self.pad_toggled
}
/// `setDown`; `needs_toggle` is the mapping's option value.
pub fn set_down(&mut self, down: bool, needs_toggle: bool) {
if self.toggle.is_some() && needs_toggle {
if self.toggle.is_some() && self.needs_toggle(needs_toggle) {
if down {
self.is_down = !self.is_down;
// Toggled back up: the pad's toggle is over.
self.pad_toggled &= self.is_down;
}
} else {
self.is_down = down;
}
}
/// A controller's toggle button: flips the mapping like `setDown(true)`
/// with its option on, whatever the option says.
pub fn pad_toggle(&mut self) {
self.is_down = !self.is_down;
self.pad_toggled = self.is_down;
}
/// `shouldSetOnIngameFocus` for a keyboard-bound, bound key.
pub fn should_set_on_ingame_focus(&self, needs_toggle: bool) -> bool {
self.keyboard && (self.toggle.is_none() || !needs_toggle)
@@ -61,7 +78,8 @@ impl KeyState {
pub fn release(&mut self, needs_toggle: bool) {
if self.toggle.is_some() {
// ToggleKeyMapping.release does not clear pending clicks.
if needs_toggle && self.is_down || self.released_by_screen_when_down {
if self.needs_toggle(needs_toggle) && self.is_down || self.released_by_screen_when_down
{
self.released_by_screen_when_down = true;
}
self.reset();
@@ -70,7 +88,8 @@ impl KeyState {
self.set_down(false, needs_toggle);
}
}
/// `ToggleKeyMapping.reset`.
/// `ToggleKeyMapping.reset`. A pad toggle stays on record, so the screen
/// that released it restores it.
pub fn reset(&mut self) {
self.is_down = false;
}
@@ -83,7 +102,10 @@ impl KeyState {
let Some(restore) = self.toggle else {
return false;
};
let result = restore && needs_toggle && self.keyboard && self.released_by_screen_when_down;
let result = restore
&& self.needs_toggle(needs_toggle)
&& self.keyboard
&& self.released_by_screen_when_down;
self.released_by_screen_when_down = false;
result
}
@@ -138,6 +160,15 @@ impl ToggleKeys {
_ => {}
}
}
/// A controller's toggle button bound to `slot` (the pad's toggle-sneak):
/// a press with no screen open flips the mapping and clicks it, as a
/// key press does with the mapping's toggle option on.
pub fn pad_toggle(&mut self, slot: usize, screen_open: bool) {
if !screen_open {
self.0[slot].pad_toggle();
self.0[slot].click();
}
}
/// `MouseHandler.onButton` for a button bound to `slot`: only without a
/// screen or overlay.
pub fn button(&mut self, slot: usize, pressed: bool, screen_open: bool, needs_toggle: bool) {
@@ -167,6 +198,9 @@ impl ToggleKeys {
pub fn respawn(&mut self) {
for key in &mut self.0 {
key.reset();
// As with the vanilla option, the death screen closing after
// this still restores a toggle it released.
key.pad_toggled &= key.released_by_screen_when_down;
}
}
}
@@ -197,6 +231,57 @@ mod tests {
keys.respawn();
assert!(!keys.is_down(SPRINT));
}
/// #3061: the pad's toggle-sneak (vanilla's toggle crouch off) comes
/// back after a screen, as the vanilla option's toggle does; a
/// keyboard shift-click in the inventory does not drop it.
#[test]
fn pad_toggle_is_restored_after_a_screen() {
let off = [false; 4];
let mut keys = ToggleKeys::default();
keys.pad_toggle(SNEAK, false);
assert!(keys.is_down(SNEAK));
keys.screen_opened(off);
assert!(!keys.is_down(SNEAK));
keys.key(SNEAK, KeyAction::Press, true, false);
keys.key(SNEAK, KeyAction::Release, true, false);
keys.screen_closed(off);
assert!(keys.is_down(SNEAK), "the pad's toggle is restored");
// Toggled back up, it stays up through the next screen.
keys.pad_toggle(SNEAK, false);
assert!(!keys.is_down(SNEAK));
keys.screen_opened(off);
keys.screen_closed(off);
assert!(!keys.is_down(SNEAK));
// A held key is not restored, as before.
keys.key(SNEAK, KeyAction::Press, false, false);
keys.screen_opened(off);
keys.key(SNEAK, KeyAction::Release, true, false);
keys.screen_closed(off);
assert!(!keys.is_down(SNEAK));
}
/// After the pad's toggle is over (toggled up, or reset by a respawn
/// with no screen to restore it), the shift key holds again.
#[test]
fn pad_toggle_ends_with_its_release() {
let off = [false; 4];
let mut keys = ToggleKeys::default();
keys.pad_toggle(SNEAK, false);
keys.respawn();
assert!(!keys.is_down(SNEAK));
keys.key(SNEAK, KeyAction::Press, false, false);
keys.key(SNEAK, KeyAction::Release, false, false);
assert!(!keys.is_down(SNEAK), "held, not toggled");
// The death screen releases it and closes after the respawn: the
// toggle comes back, as with the vanilla option.
keys.pad_toggle(SNEAK, false);
keys.screen_opened(off);
keys.respawn();
keys.screen_closed(off);
assert!(keys.is_down(SNEAK));
// A press with a screen open does nothing.
keys.pad_toggle(SNEAK, true);
assert!(keys.is_down(SNEAK));
}
#[test]
fn screen_release_restores_toggled_state() {
let mut k = KeyState::toggle(true, true);
@@ -673,6 +673,11 @@ mod tests {
vsync_present_mode(&[PresentMode::Fifo], false),
Some(PresentMode::Fifo)
);
// Regression guard: GLES capabilities report [Fifo, Immediate] without Mailbox.
assert_eq!(
vsync_present_mode(&[PresentMode::Fifo, PresentMode::Immediate], false),
Some(PresentMode::Immediate)
);
}
#[test]
fn vsync_on_waits_for_the_display_whatever_the_present_mode() {
+2 -2
View File
@@ -32,7 +32,7 @@ flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs",
glam = "0.30"
log = "0.4"
miniz_oxide = "0.8"
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
png = "0.18"
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
@@ -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"] }
+9 -5
View File
@@ -192,9 +192,8 @@ impl Assets {
pub fn reload(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load(root, self)
}
/// [`Assets::reload`] for a server, which draws nothing: the item models
/// and the sprites' layers, without the block models or the textures'
/// pixels.
/// [`Assets::reload`] for a server, which draws nothing: the sprites'
/// layers, without the block or item models or the textures' pixels.
pub fn reload_for_server(&self, root: &Path) -> Result<RenderAssets> {
RenderAssets::load_with(root, self, false)
}
@@ -209,8 +208,13 @@ impl RenderAssets {
for name in MATERIALS {
sprites.insert(root, &format!("minecraft:block/{name}"))?;
}
let parsed = crate::model_library::ParsedModels::load(root);
// A server draws no block: its models (about 24 MB baked) stay empty.
// A server draws no block and no item: its block models (about 24 MB
// baked) and item model library (7 MB) stay empty.
let parsed = if draws {
crate::model_library::ParsedModels::load(root)
} else {
crate::model_library::ParsedModels::default()
};
let models = if draws {
crate::models::prepare_with_models(root, registry, &mut sprites, &parsed)?
} else {
+696 -62
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,7 +52,37 @@ 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")]
device_name: String,
/// The stream reported its device gone (`Library.isCurrentDeviceDisconnected`).
#[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.
@@ -69,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
@@ -167,6 +230,20 @@ pub struct Audio {
pub stopped: std::collections::BTreeMap<String, u32>,
/// The volume sliders' `SoundPreviewHandler`.
preview: crate::sound_preview::SoundPreview,
/// `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>,
/// Ticks since the output devices were last looked at (`DeviceTracker.tick`).
#[cfg(feature = "audio")]
device_ticks: u32,
}
fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
use symphonia::core::{
@@ -210,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)
@@ -235,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)
@@ -272,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();
@@ -380,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();
@@ -412,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;
@@ -506,19 +685,96 @@ fn open_device(
use cpal::SampleFormat as F;
let config = device.default_output_config()?;
let stream = match config.sample_format() {
F::F32 => build_stream::<f32>(device, &config, mixer),
F::F64 => build_stream::<f64>(device, &config, mixer),
F::I16 => build_stream::<i16>(device, &config, mixer),
F::I32 => build_stream::<i32>(device, &config, mixer),
F::I8 => build_stream::<i8>(device, &config, mixer),
F::U16 => build_stream::<u16>(device, &config, mixer),
F::U32 => build_stream::<u32>(device, &config, mixer),
F::U8 => build_stream::<u8>(device, &config, mixer),
F::F32 => build_stream::<f32>(device, &config, mixer.clone()),
F::F64 => build_stream::<f64>(device, &config, mixer.clone()),
F::I16 => build_stream::<i16>(device, &config, mixer.clone()),
F::I32 => build_stream::<i32>(device, &config, mixer.clone()),
F::I8 => build_stream::<i8>(device, &config, mixer.clone()),
F::U16 => build_stream::<u16>(device, &config, mixer.clone()),
F::U32 => build_stream::<u32>(device, &config, mixer.clone()),
F::U8 => build_stream::<u8>(device, &config, mixer.clone()),
other => Err(format!("unsupported sample format {other}").into()),
}?;
stream.play()?;
if let Ok(mut m) = mixer.lock() {
m.device_name = device.name().unwrap_or_default();
m.device_lost = false;
}
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> {
cpal::default_host()
.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,
@@ -528,6 +784,7 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
let rate = config.sample_rate().0;
let channels = config.channels() as usize;
let mut scratch = Vec::new();
let on_error = mixer.clone();
let stream = device.build_output_stream(
&config.config(),
move |data: &mut [T], _| {
@@ -541,7 +798,14 @@ fn build_stream<T: cpal::SizedSample + cpal::FromSample<f32>>(
*out = T::from_sample(*s);
}
},
|e| log::warn!("Horizon: audio stream error: {e}"),
move |e| {
log::warn!("Horizon: audio stream error: {e}");
if matches!(e, cpal::StreamError::DeviceNotAvailable)
&& let Ok(mut m) = on_error.lock()
{
m.device_lost = true;
}
},
None,
)?;
Ok(stream)
@@ -615,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(),
@@ -626,6 +901,9 @@ impl Audio {
decoder: None,
next: 0,
live,
owner: 0,
world: 0,
shares: Arc::default(),
},
record_heard: false,
listener: [0.0; 3],
@@ -637,6 +915,14 @@ impl Audio {
heard: Default::default(),
stopped: Default::default(),
preview: Default::default(),
#[cfg(feature = "audio")]
default_seen: if live { default_output_name() } else { None },
#[cfg(feature = "audio")]
cards_seen: cards,
#[cfg(feature = "audio")]
devices_seen: devices,
#[cfg(feature = "audio")]
device_ticks: 0,
}
}
#[cfg(not(feature = "audio"))]
@@ -701,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() {
@@ -737,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
@@ -764,8 +1101,81 @@ impl Audio {
}
self.engine.listening = self.record_heard;
}
/// `SoundEngine.shouldChangeDevice`: the open device was lost, the system
/// default changed (with no device chosen in the options), or the chosen
/// device came back; the output is then opened again. The devices are looked
/// 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,
};
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;
}
self.device_ticks += 1;
if self._stream.is_none() || self.device_ticks < 20 {
return false;
}
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.
if default == self.default_seen && devices == self.devices_seen {
return false;
}
self.default_seen = default.clone();
let wanted = self.device_name.clone();
let available = devices.contains(&wanted);
self.devices_seen = devices;
if wanted.is_empty() {
let changed = default.as_deref().is_some_and(|d| d != current);
if changed {
log::info!("Horizon: system default audio device has changed");
}
return changed;
}
let changed = current != wanted && available;
if changed {
log::info!("Horizon: preferred audio device has become available");
}
changed
}
/// `SoundManager.tick(paused)`, once per client tick.
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
#[cfg(feature = "audio")]
{
if self.should_change_device() {
// SoundEngine.reload: stop every sound, open the output again.
self.stop_all();
let device = self.device_name.clone();
if let Err(e) = self.set_device(&device) {
log::warn!("Horizon: could not reopen the audio output: {e}");
}
}
}
self.sync_volumes();
self.engine.tick(paused, ctx, &mut self.backend);
}
@@ -801,6 +1211,37 @@ impl Audio {
let seed = self.random.next_long();
self.play_seeded(event, at, volume, pitch, seed);
}
/// `ClientLevel.playLocalSound` with `distanceDelay`: a sound more than 10
/// blocks from the camera starts `sqrt(distance) / 40` seconds later.
pub fn play_distance_delayed(
&mut self,
event: &str,
at: [f64; 3],
volume: f32,
pitch: f32,
camera: [f64; 3],
) {
let distance_sqr: f64 = (0..3).map(|i| (at[i] - camera[i]).powi(2)).sum();
if distance_sqr <= 100.0 {
self.play(event, Some(at), volume, pitch);
return;
}
let seed = self.random.next_long();
if let Some(capture) = self.capture.as_mut() {
capture.push((event.to_owned(), at, volume, pitch));
}
*self.heard.entry(event.to_owned()).or_default() += 1;
self.sync_volumes();
let event = crate::sound_events::normalize(event);
let source = self
.source_override
.unwrap_or_else(|| crate::settings::sound_source(&event));
let instance =
SoundInstance::at(&event, source, volume, pitch, LegacyRandom::new(seed), at);
let id = self.engine.add(instance, false);
self.engine
.play_delayed(id, (distance_sqr.sqrt() / 40.0 * 20.0) as i32);
}
/// `play` for this client only: a sound a packet's handler plays
/// (`playLocalSound`), which the guests play from that packet themselves.
pub fn play_unshared(&mut self, event: &str, at: Option<[f64; 3]>, volume: f32, pitch: f32) {
@@ -1013,6 +1454,9 @@ mod tests {
looping,
at,
relative: false,
owner: 0,
world: 0,
started: None,
}
}
@@ -1170,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();
@@ -1179,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 ]")));
}
}
+59
View File
@@ -203,6 +203,46 @@ pub fn endpoint(doc: &serde_json::Value, service: &str, name: &str) -> Option<St
.map(str::to_owned)
}
/// `Minecraft`'s constructor futures for the title screen: `UserApiService.fetchProperties`
/// (the `player` service's `getAttributes`, with the bearer token) and
/// `SessionService.fetchProfile(uuid, true)` (`getProfileById`). A failed
/// attributes call falls back to the offline properties, a failed profile call
/// to no actions, as vanilla does. Blocks: call it off the main thread.
pub fn fetch_account_status(session: &AuthSession) -> crate::ban_notice::AccountStatus {
use crate::ban_notice::{AccountStatus, parse_profile_actions, parse_properties};
let Ok(doc) = discovery() else {
return AccountStatus::default();
};
let properties = endpoint(&doc, "player", "getAttributes")
.and_then(|url| {
agent()
.get(&url)
.set("Authorization", &format!("Bearer {}", session.access_token))
.call()
.ok()?
.into_string()
.ok()
})
.and_then(|body| parse_properties(&body))
.unwrap_or_default();
let actions = endpoint(&doc, "session", "getProfileById")
.and_then(|url| {
let url = url.replace("{profileId}", &format!("{:032x}", session.uuid));
agent()
.get(&format!("{url}?unsigned=true"))
.call()
.ok()?
.into_string()
.ok()
})
.map(|body| parse_profile_actions(&body))
.unwrap_or_default();
AccountStatus {
properties,
actions,
}
}
/// The JoinMinecraftServerRequest body; `selectedProfile` is the undashed UUID.
pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
serde_json::json!({
@@ -232,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)]
@@ -0,0 +1,373 @@
//! The ban notices `Gui.addInitialScreens` queues before the title screen:
//! `Minecraft.multiplayerBan()` (the account's MULTIPLAYER ban from the services
//! attributes call), the forced name change and the banned skin
//! (`ProfileResult.actions()`), and the text `BanNoticeScreens` shows for each.
//! The network calls are in `auth.rs`; this file parses what they return.
use crate::text::Text;
/// `CommonLinks.SUSPENSION_HELP`.
pub const SUSPENSION_HELP: &str = "https://aka.ms/mcjavamoderation";
/// authlib's `BanDetails` (the id is never shown).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BanDetails {
/// Epoch milliseconds the ban ends; `None` is permanent.
pub expires_ms: Option<i64>,
pub reason: Option<String>,
pub reason_message: Option<String>,
}
/// authlib's `UserProperties`, as far as the title screen reads it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct UserProperties {
/// `UserFlag.SERVERS_ALLOWED` (`privileges.multiplayerServer.enabled`).
pub servers_allowed: bool,
/// `bannedScopes.get("MULTIPLAYER")`.
pub multiplayer_ban: Option<BanDetails>,
}
impl Default for UserProperties {
/// `UserApiService.OFFLINE_PROPERTIES`: what a failed fetch falls back to.
fn default() -> Self {
Self {
servers_allowed: true,
multiplayer_ban: None,
}
}
}
/// authlib's `ProfileResult.actions()`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ProfileActions {
pub forced_name_change: bool,
pub using_banned_skin: bool,
}
/// What the title screen learns about the account.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct AccountStatus {
pub properties: UserProperties,
pub actions: ProfileActions,
}
impl AccountStatus {
/// `Minecraft.allowsMultiplayer` (without the `--disableMultiplayer` flag).
pub fn allows_multiplayer(&self) -> bool {
self.properties.servers_allowed
&& self.properties.multiplayer_ban.is_none()
&& !self.actions.forced_name_change
}
}
/// One of the screens `Gui.addInitialScreens` adds.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Notice {
/// `BanNoticeScreens.create`.
Multiplayer(BanDetails),
/// `BanNoticeScreens.createNameBan` with the profile's name.
Name(String),
/// `BanNoticeScreens.createSkinBan`.
Skin,
}
/// `Gui.addInitialScreens`' ban part, in its order: the multiplayer ban, the
/// name ban, the skin ban.
pub fn initial_notices(status: &AccountStatus, profile_name: &str) -> Vec<Notice> {
let mut notices = Vec::new();
if let Some(ban) = &status.properties.multiplayer_ban {
notices.push(Notice::Multiplayer(ban.clone()));
}
if status.actions.forced_name_change {
notices.push(Notice::Name(profile_name.to_owned()));
}
if status.actions.using_banned_skin {
notices.push(Notice::Skin);
}
notices
}
impl Notice {
/// The screen's title (bold).
pub fn title(&self) -> Text {
let key = match self {
Self::Multiplayer(ban) if ban.expires_ms.is_some() => "gui.banned.title.temporary",
Self::Multiplayer(_) => "gui.banned.title.permanent",
Self::Name(_) => "gui.banned.name.title",
Self::Skin => "gui.banned.skin.title",
};
Text::translate(key, &[]).bold()
}
/// The screen's message; `now_ms` is `Instant.now()`.
pub fn message(&self, now_ms: i64) -> Text {
let link = || Text::literal(SUSPENSION_HELP);
match self {
Self::Multiplayer(ban) => Text::translate(
"gui.banned.description",
&[reason_text(ban), status_text(ban, now_ms), link()],
),
Self::Name(name) => Text::translate(
"gui.banned.name.description",
&[Text::colored(name.clone(), YELLOW), link()],
),
Self::Skin => Text::translate("gui.banned.skin.description", &[link()]),
}
}
}
/// `ChatFormatting.YELLOW`.
const YELLOW: u32 = 0xFFFF55;
/// `BanReason.byId`: the translation key suffix.
fn ban_reason(id: i32) -> Option<&'static str> {
Some(match id {
2 => "false_reporting",
5 => "hate_speech",
16 | 25 => "hate_terrorism_notorious_figure",
17 | 19 | 23 | 31 => "generic_violation",
21 => "harassment_or_bullying",
27 => "defamation_impersonation_false_information",
28 => "drugs",
29 => "fraud",
30 => "spam_or_advertising",
32 => "nudity_or_pornography",
33 | 35 | 36 => "sexually_inappropriate",
34 => "extreme_violence_or_gore",
53 => "imminent_harm_to_person_or_property",
_ => return None,
})
}
/// `BanNoticeScreens.getBanReasonText`.
fn reason_text(ban: &BanDetails) -> Text {
let numeric = ban
.reason
.as_deref()
.filter(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit()));
let Some(reason) = numeric else {
return Text::translate("gui.banned.description.unknownreason", &[]);
};
// Integer.parseInt: past i32 the vanilla screen would throw; show the code as is.
let id = reason.parse::<i32>().ok();
let text = if let Some(key) = id.and_then(ban_reason) {
Text::translate(&format!("gui.banned.reason.{key}"), &[]).bold()
} else if let Some(message) = &ban.reason_message {
Text::translate(
"gui.banned.description.reason_id_message",
&[Text::literal(reason), Text::literal(message.clone())],
)
.bold()
} else {
Text::translate("gui.banned.description.reason_id", &[Text::literal(reason)]).bold()
};
Text::translate("gui.banned.description.reason", &[text])
}
/// `BanNoticeScreens.getBanStatusText`.
fn status_text(ban: &BanDetails, now_ms: i64) -> Text {
match ban.expires_ms {
Some(expires) => {
let duration = Text::translate(
"gui.banned.description.temporary.duration",
&[duration_text(expires - now_ms)],
)
.bold();
Text::translate("gui.banned.description.temporary", &[duration])
}
None => Text::translate("gui.banned.description.permanent", &[]).bold(),
}
}
/// `BanNoticeScreens.getBanDurationText`: days past 72 hours, else hours,
/// else minutes (`Duration.toHours` and friends truncate toward zero).
fn duration_text(millis: i64) -> Text {
let count = |n: i64| Text::literal(n.to_string());
let hours = millis / 3_600_000;
if hours > 72 {
Text::translate("gui.days", &[count(millis / 86_400_000)])
} else if hours < 1 {
Text::translate("gui.minutes", &[count(millis / 60_000)])
} else {
Text::translate("gui.hours", &[count(hours)])
}
}
/// `Instant` as authlib's gson adapter reads it: an ISO-8601 string, or epoch milliseconds.
fn parse_instant(value: &serde_json::Value) -> Option<i64> {
match value {
serde_json::Value::Number(n) => n.as_i64(),
serde_json::Value::String(s) => chrono::DateTime::parse_from_rfc3339(s)
.ok()
.map(|t| t.timestamp_millis())
.or_else(|| s.parse().ok()),
_ => None,
}
}
/// `UserAttributesResponse` into `UserProperties` (`fetchProperties`). Bad JSON
/// is an error, which vanilla answers with the offline properties.
pub fn parse_properties(body: &str) -> Option<UserProperties> {
let v: serde_json::Value = serde_json::from_str(body).ok()?;
let servers_allowed = v
.pointer("/privileges/multiplayerServer/enabled")
.and_then(|e| e.as_bool())
.unwrap_or(false);
let multiplayer_ban = v
.pointer("/banStatus/bannedScopes/MULTIPLAYER")
.filter(|b| b.is_object())
.map(|b| {
let text = |k: &str| b.get(k).and_then(|s| s.as_str()).map(str::to_owned);
BanDetails {
expires_ms: b.get("expires").and_then(parse_instant),
reason: text("reason"),
reason_message: text("reasonMessage"),
}
});
Some(UserProperties {
servers_allowed,
multiplayer_ban,
})
}
/// `MinecraftProfilePropertiesResponse.profileActions` into `ProfileActionType`s.
pub fn parse_profile_actions(body: &str) -> ProfileActions {
let mut actions = ProfileActions::default();
let v: serde_json::Value = serde_json::from_str(body).unwrap_or_default();
for action in v
.get("profileActions")
.and_then(|a| a.as_array())
.into_iter()
.flatten()
{
match action.get("type").and_then(|t| t.as_str()) {
Some("FORCED_NAME_CHANGE") => actions.forced_name_change = true,
Some("USING_BANNED_SKIN") => actions.using_banned_skin = true,
_ => {}
}
}
actions
}
#[cfg(test)]
mod tests {
use super::*;
const HOUR: i64 = 3_600_000;
/// 2026-11-01T00:00:00Z.
const EXPIRES: i64 = 1_793_491_200_000;
#[test]
fn parses_a_multiplayer_ban() {
let body = r#"{"privileges":{"multiplayerServer":{"enabled":false}},
"banStatus":{"bannedScopes":{"MULTIPLAYER":{"banId":"6b0f0a0e-0000-4000-8000-000000000000",
"expires":"2026-11-01T00:00:00Z","reason":"21","reasonMessage":"x"}}}}"#;
let p = parse_properties(body).unwrap();
assert!(!p.servers_allowed);
let ban = p.multiplayer_ban.unwrap();
assert_eq!(ban.expires_ms, Some(EXPIRES));
assert_eq!(ban.reason.as_deref(), Some("21"));
let numeric =
parse_properties(r#"{"banStatus":{"bannedScopes":{"MULTIPLAYER":{"expires":5}}}}"#)
.unwrap();
assert_eq!(numeric.multiplayer_ban.unwrap().expires_ms, Some(5));
assert!(parse_properties("nope").is_none());
let clean = parse_properties(
r#"{"privileges":{"multiplayerServer":{"enabled":true}},"banStatus":{"bannedScopes":{}}}"#,
)
.unwrap();
assert!(clean.servers_allowed && clean.multiplayer_ban.is_none());
}
#[test]
fn parses_profile_actions() {
let a = parse_profile_actions(
r#"{"id":"x","profileActions":[{"type":"FORCED_NAME_CHANGE"},{"type":"OTHER"}]}"#,
);
assert!(a.forced_name_change && !a.using_banned_skin);
let a = parse_profile_actions(r#"{"profileActions":[{"type":"USING_BANNED_SKIN"}]}"#);
assert!(!a.forced_name_change && a.using_banned_skin);
assert_eq!(parse_profile_actions("{}"), ProfileActions::default());
}
#[test]
fn queues_in_vanilla_order() {
let ban = BanDetails {
expires_ms: None,
reason: None,
reason_message: None,
};
let status = AccountStatus {
properties: UserProperties {
servers_allowed: true,
multiplayer_ban: Some(ban.clone()),
},
actions: ProfileActions {
forced_name_change: true,
using_banned_skin: true,
},
};
assert_eq!(
initial_notices(&status, "Steve"),
vec![
Notice::Multiplayer(ban),
Notice::Name("Steve".into()),
Notice::Skin
]
);
assert!(!status.allows_multiplayer());
assert!(initial_notices(&AccountStatus::default(), "Steve").is_empty());
assert!(AccountStatus::default().allows_multiplayer());
let no_servers = AccountStatus {
properties: UserProperties {
servers_allowed: false,
multiplayer_ban: None,
},
..Default::default()
};
assert!(!no_servers.allows_multiplayer());
}
/// The message's component JSON: the keys and arguments, whatever language is loaded.
fn json(notice: &Notice, now: i64) -> String {
notice.message(now).component().to_string()
}
#[test]
fn ban_text_follows_banned_screens() {
let temp = |reason: Option<&str>, message: Option<&str>| {
Notice::Multiplayer(BanDetails {
expires_ms: Some(100 * HOUR),
reason: reason.map(str::to_owned),
reason_message: message.map(str::to_owned),
})
};
assert_eq!(
temp(None, None).title().translation_key(),
Some("gui.banned.title.temporary")
);
let perm = Notice::Multiplayer(BanDetails {
expires_ms: None,
reason: Some("5".into()),
reason_message: None,
});
assert_eq!(
perm.title().translation_key(),
Some("gui.banned.title.permanent")
);
let text = json(&perm, 0);
assert!(text.contains("gui.banned.reason.hate_speech"));
assert!(text.contains("gui.banned.description.permanent"));
assert!(text.contains(SUSPENSION_HELP));
// Unknown ids show their code, with the message when there is one.
let with_message = json(&temp(Some("999"), Some("why")), 0);
assert!(with_message.contains("gui.banned.description.reason_id_message"));
assert!(with_message.contains("why"));
assert!(json(&temp(Some("999"), None), 0).contains("gui.banned.description.reason_id\""));
assert!(json(&temp(Some("abc"), None), 0).contains("gui.banned.description.unknownreason"));
// 100 hours left is past 72: days (4); less is hours; under an hour is minutes.
let duration = |now| json(&temp(None, None), now);
assert!(duration(0).contains("gui.days") && duration(0).contains("[4]"));
assert!(duration(60 * HOUR).contains("gui.hours") && duration(60 * HOUR).contains("[40]"));
let minutes = duration(100 * HOUR - 90_000);
assert!(minutes.contains("gui.minutes") && minutes.contains("[1]"));
assert!(json(&Notice::Name("Bob".into()), 0).contains("Bob"));
assert!(json(&Notice::Skin, 0).contains(SUSPENSION_HELP));
}
}
@@ -789,6 +789,8 @@ pub struct Scene<'a> {
pub glyphs: u64,
}
/// Empty by default (a dedicated server): no state has a block entity model.
#[derive(Default)]
pub struct BlockEntityRenderer {
models: HashMap<&'static str, ModelPart>,
kinds: Vec<Kind>,
@@ -1116,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,
@@ -1136,6 +1139,8 @@ impl BlockEntityRenderer {
| Kind::Bell
| Kind::Lectern { .. }
| Kind::CopperGolem { .. }
| Kind::Conduit
| Kind::EnchantingTable
) {
return;
}
@@ -1490,6 +1495,7 @@ impl BlockEntityRenderer {
} else {
None
};
let has_profile = skin.is_some();
let texture = skin.as_deref().unwrap_or(texture);
let (Some(model), Some(paint)) =
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
@@ -1524,7 +1530,13 @@ impl BlockEntityRenderer {
"right_ear" => p[5] = (f64::from(t * PI * 0.2).cos() + 2.5) as f32 * 0.2,
_ => {}
};
let start = out.len();
draw_part(model, "root", m, &anim, paint, out);
// `SkullBlockRenderer.resolveSkullRenderType`: a head with a profile takes
// the player skin's `entityTranslucent` render type.
if has_profile {
crate::gfx::mark_oit(&mut out[start..]);
}
}
Kind::Bell => {
let (Some(model), Some(paint)) = (
@@ -1674,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 {
@@ -24,6 +24,9 @@ struct Out {
return out;
}
@fragment fn fs_border(in: Out) -> @location(0) vec4<f32> {
return border_color(in);
}
fn border_color(in: Out) -> vec4<f32> {
let color = textureSample(forcefield, forcefield_sampler, in.uv);
if color.a == 0.0 {
discard;
+3 -4
View File
@@ -12,8 +12,8 @@ use serde_json::Value;
use std::{collections::HashSet, fs, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, effect, namespaced,
potion, potion_color,
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
description_id, effect, mod_effects, namespaced, potion, potion_color, register_mod_effects,
};
/// `MobEffectUtil.formatDuration` at 20 TPS.
pub fn format_duration(ticks: i32) -> String {
@@ -80,8 +80,7 @@ pub fn effect_lines<'a>(
.into_iter()
.map(|(e, duration, amp)| {
let e = e.trim_start_matches("minecraft:");
let mut line =
translate(&format!("effect.minecraft.{e}")).unwrap_or_else(|| e.to_owned());
let mut line = translate(&description_id(e)).unwrap_or_else(|| e.to_owned());
if amp > 0 {
line.push(' ');
line.push_str(
+169 -18
View File
@@ -21,6 +21,8 @@ const COMMAND_HISTORY_FILE: &str = "command_history.txt";
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
/// GuiMessageTag.systemSinglePlayer's indicator colour.
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
/// `GuiMessageTag.CHAT_MODIFIED_INDICATOR_COLOR`.
const MODIFIED_INDICATOR: u32 = 0x60_6060;
/// A player message's signature (MessageSignature), which names it to a
/// server's deletion.
pub type Signature = horizon_protocol::secure_chat::Signature;
@@ -169,7 +171,7 @@ impl ChatOptions {
struct Message {
added: u64,
content: Text,
indicator: Option<u32>,
tag: Option<std::sync::Arc<Tag>>,
source: ChatSource,
/// `GuiMessage.signature`: a signed player message's, for deletion.
signature: Option<Box<Signature>>,
@@ -179,7 +181,54 @@ struct Message {
struct Line {
added: u64,
content: Text,
indicator: Option<u32>,
tag: Option<std::sync::Arc<Tag>>,
}
/// `GuiMessageTag`: the bar at the left of a message's lines, the tooltip
/// over it, and for a modified message the icon after each line.
#[derive(Clone, Debug, PartialEq)]
pub struct Tag {
pub color: u32,
/// `GuiMessageTag.Icon.CHAT_MODIFIED`.
pub icon: bool,
pub text: Text,
}
impl Tag {
/// `GuiMessageTag.systemSinglePlayer`, in `color`.
pub fn system_single_player(color: u32) -> Self {
Self {
color,
icon: false,
text: Text::translate("chat.tag.system_single_player", &[]),
}
}
/// `GuiMessageTag.chatNotSecure`.
pub fn not_secure() -> Self {
Self {
color: SYSTEM_INDICATOR,
icon: false,
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 {
color: MODIFIED_INDICATOR,
icon: true,
text: Text::empty()
.append(Text::translate("chat.tag.modified", &[]))
.append(Text::literal("\n"))
.append(Text::literal(original_content).with_color(text::GRAY)),
}
}
}
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
@@ -236,7 +285,8 @@ impl Chat {
ticks: u64,
focused: bool,
) {
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
let tag = indicator.map(|color| std::sync::Arc::new(Tag::system_single_player(color)));
self.add_message(&atlas.font, content, tag, source, ticks, focused);
}
/// `add` of a player message with its signature, which a server's
/// deletion names it by (`GuiMessage.signature`).
@@ -245,11 +295,13 @@ impl Chat {
atlas: &HudAtlas,
content: Text,
signature: Option<Signature>,
tag: Option<Tag>,
ticks: u64,
focused: bool,
) {
self.add_signed_message(&atlas.font, content, signature, ticks, focused);
self.add_player_message(&atlas.font, content, signature, tag, ticks, focused);
}
#[cfg(test)]
fn add_signed_message(
&mut self,
font: &crate::font::Font,
@@ -257,13 +309,25 @@ impl Chat {
signature: Option<Signature>,
ticks: u64,
focused: bool,
) {
self.add_player_message(font, content, signature, None, ticks, focused);
}
/// `ChatComponent.addPlayerMessage`: the tag the message's trust level made.
fn add_player_message(
&mut self,
font: &crate::font::Font,
content: Text,
signature: Option<Signature>,
tag: Option<Tag>,
ticks: u64,
focused: bool,
) {
self.push_message(
font,
Message {
added: ticks,
content,
indicator: None,
tag: tag.map(std::sync::Arc::new),
source: ChatSource::Player,
signature: signature.map(Box::new),
},
@@ -274,7 +338,7 @@ impl Chat {
&mut self,
font: &crate::font::Font,
content: Text,
indicator: Option<u32>,
tag: Option<std::sync::Arc<Tag>>,
source: ChatSource,
ticks: u64,
focused: bool,
@@ -284,7 +348,7 @@ impl Chat {
Message {
added: ticks,
content,
indicator,
tag,
source,
signature: None,
},
@@ -307,7 +371,13 @@ impl Chat {
self.received += 1;
}
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
let width = self.max_width();
// GuiMessage.splitLines: room for the tag's icon (its width, 4 before and 2 after).
let icon = if message.tag.as_ref().is_some_and(|tag| tag.icon) {
9 + 4 + 2
} else {
0
};
let width = self.max_width() - icon;
let content = self.displayed(&message.content);
for line in font.wrap_components(&content, width) {
if focused && self.scroll > 0 {
@@ -319,7 +389,7 @@ impl Chat {
Line {
added: message.added,
content: line,
indicator: message.indicator,
tag: message.tag.clone(),
},
);
}
@@ -416,7 +486,9 @@ impl Chat {
*message = Message {
added: message.added,
content: Self::deleted_marker(),
indicator: Some(SYSTEM_INDICATOR),
tag: Some(std::sync::Arc::new(Tag::system_single_player(
SYSTEM_INDICATOR,
))),
source: ChatSource::Server,
signature: None,
};
@@ -540,8 +612,8 @@ impl Chat {
shadow: true,
});
}
if let Some(indicator) = line.indicator {
let color = hud_layout::as8(opacity) << 24 | indicator & 0xFF_FFFF;
if let Some(tag) = &line.tag {
let color = hud_layout::as8(opacity) << 24 | tag.color & 0xFF_FFFF;
out.push(hud_layout::fill(
-4,
bottom - entry_height,
@@ -702,12 +774,91 @@ impl Chat {
}
None
}
/// Whether the cursor is on a line's indicator bar while chat is focused
/// (`DrawingFocusedGraphicsAccess.handleTag`), whose tooltip is the
/// message tag's text.
pub fn indicator_at(&self, height: i32, x: f64, y: f64) -> bool {
self.line_at(height, x, y)
.is_some_and(|(line, x)| line.indicator.is_some() && (-4.0..-2.0).contains(&x))
/// `DrawingFocusedGraphicsAccess.handleTag` and `handleTagIcon` with the
/// cursor at GUI position `(x, y)` while chat is focused: the tooltip of
/// the tag under it (its bar, or its icon) and the icons to draw, those
/// under the cursor and those of the message the cursor is over.
pub fn focused_tags(
&self,
font: &crate::font::Font,
height: i32,
x: f64,
y: f64,
) -> (Option<Text>, Vec<GuiDraw>) {
let o = &self.options;
let scale = o.scale as f32;
let chat_bottom = hud_layout::floor((height - 40) as f32 / scale);
let entry_height = o.line_height();
let bottom_to_text =
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
let (mouse_x, mouse_y) = (x / f64::from(scale) - 4.0, y / f64::from(scale));
let inside = |left: i32, top: i32, right: i32, bottom: i32| {
mouse_x >= f64::from(left)
&& mouse_x < f64::from(right)
&& mouse_y >= f64::from(top)
&& mouse_y < f64::from(bottom)
};
let visible = self
.lines
.len()
.saturating_sub(self.scroll)
.min(self.lines_per_page(true));
let mut tooltip = None;
let mut icons = Vec::new();
let mut hovered_message = false;
for i in (0..visible).rev() {
let index = i + self.scroll;
let line = &self.lines[index];
let bottom = chat_bottom - i as i32 * entry_height;
let top = bottom - entry_height;
let width = font.width_text(&line.content).ceil() as i32;
hovered_message |= inside(0, top, width, bottom);
// GuiMessage.Line.endOfEntry: the next newer line is another message's.
let end_of_entry = index == 0
|| match (&line.tag, &self.lines[index - 1].tag) {
(Some(a), Some(b)) => !std::sync::Arc::ptr_eq(a, b),
(None, None) => false,
_ => true,
};
let force_icon = end_of_entry && hovered_message;
if end_of_entry {
hovered_message = false;
}
let Some(tag) = &line.tag else { continue };
if inside(-4, top, -2, bottom) {
tooltip = Some(tag.text.clone());
}
if tag.icon {
let (left, text_bottom) = (width + 4, bottom - bottom_to_text + 9);
let icon_top = text_bottom - 9 - 1;
let over = inside(left, icon_top, left + 9, text_bottom);
if over {
tooltip = Some(tag.text.clone());
}
if force_icon || over {
icons.push(GuiDraw::Sprite {
sprite: "icon/chat_modified".into(),
x: left,
y: icon_top,
w: 9,
h: 9,
region: None,
color: hud_layout::WHITE,
blend: hud_layout::Blend::Textured,
});
}
}
}
if icons.is_empty() {
return (tooltip, icons);
}
let mut draws = vec![GuiDraw::Push(vec![
PoseOp::Scale(scale, scale),
PoseOp::Translate(4.0, 0.0),
])];
draws.extend(icons);
draws.push(GuiDraw::Pop);
(tooltip, draws)
}
/// Plain text of the newest messages (for tests and the control socket).
pub fn recent_lines(&self, n: usize) -> Vec<String> {
@@ -33,7 +33,7 @@ fn add(chat: &mut Chat, font: &Font, content: Text) {
Line {
added: 0,
content,
indicator: None,
tag: None,
},
);
}
@@ -557,7 +557,7 @@ fn a_deleted_message_shows_60_ticks_then_becomes_the_marker() {
assert_eq!(lines(&chat), ["<Alex> hi".to_owned(), marker.clone()]);
assert!(chat.deletions.is_empty());
let line = &chat.lines[1];
assert_eq!(line.indicator, Some(SYSTEM_INDICATOR));
assert_eq!(line.tag.as_ref().map(|t| t.color), Some(SYSTEM_INDICATOR));
assert_eq!(line.added, 100, "the marker keeps the message's time");
assert!(
line.content
+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`.
+42 -3
View File
@@ -223,6 +223,35 @@ pub fn install(dir: &Path, world: &Path, selected: &[String]) -> Result<(), Stri
let file = world.join("datapacks.json");
std::fs::write(&file, json.to_string()).map_err(|e| err(&file, e))
}
/// Re-Create World's packs from a saved world: a new folder holding a copy of
/// its `datapacks` (`CreateWorldScreen.createTempDataPackDirFromExistingWorld`; `None` when the
/// world has none), and the selection its `datapacks.json` makes, lowest
/// priority first (`WorldOpenFlows.recreateWorldData` keeps the world's `DataConfiguration`).
/// The built-in pack is selected unless the world turned it off.
pub fn for_recreate(world: &Path) -> (Option<PathBuf>, Vec<String>) {
let packs = discover(world);
let mut selected = enabled_ids(world, &packs);
let vanilla_off = std::fs::read(world.join("datapacks.json"))
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())
.and_then(|v| v["disabled"].as_array().cloned())
.is_some_and(|a| a.iter().any(|id| id.as_str() == Some("vanilla")));
if !vanilla_off {
selected.insert(0, "vanilla".into());
}
if packs.is_empty() {
return (None, selected);
}
let dir = temp_folder().ok().and_then(|dir| {
if copy(&world.join("datapacks"), &dir).is_ok() {
Some(dir)
} else {
let _ = std::fs::remove_dir_all(&dir);
None
}
});
(dir, selected)
}
/// A file, or a folder and everything in it.
fn copy(from: &Path, to: &Path) -> Result<(), String> {
let err = |e: std::io::Error| format!("{} -> {}: {e}", from.display(), to.display());
@@ -424,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>>>,
@@ -432,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")?));
@@ -468,6 +497,7 @@ impl DataStore {
let builtin = pack.id == "vanilla";
for f in pack.list_kinds(&[
"advancement",
"dialog",
"function",
"item_modifier",
"loot_table",
@@ -836,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");
@@ -848,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);
}
+27 -2
View File
@@ -6,7 +6,8 @@ use horizon_client_core::potions;
#[derive(Clone, Debug, PartialEq)]
pub struct EffectInstance {
/// Effect id without namespace (`speed`, `poison`, ...).
/// Effect id: vanilla's without namespace (`speed`, `poison`, ...), a
/// content mod's `<ns>:<path>`.
pub id: &'static str,
/// Remaining ticks; -1 is infinite.
pub duration: i32,
@@ -219,7 +220,7 @@ impl PlayerEffects {
.iter()
.map(|e| {
(
format!("minecraft:{}", e.id),
potions::namespaced(e.id).to_owned(),
e.duration,
e.amplifier,
e.ambient,
@@ -525,6 +526,30 @@ mod tests {
assert_eq!(fx.get("speed").unwrap().duration, 49);
assert_eq!(fx.get("raid_omen").unwrap().amplifier, 1);
}
/// The only test here that registers mod effects (once per process).
#[test]
fn a_mod_effect_saves_under_its_namespace() {
potions::register_mod_effects([(
"ruby:glow".to_owned(),
0xFF0000,
potions::Category::Neutral,
)]);
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new(
potions::effect("ruby:glow").unwrap().id,
600,
1,
));
let saved = fx.saved();
assert_eq!(saved, [("ruby:glow".to_owned(), 600, 1, false, true)]);
assert_eq!(PlayerEffects::from_saved(&saved).list, fx.list);
// Without its mod the saved effect is dropped, as an unknown id is.
assert!(
PlayerEffects::from_saved(&[("gone:glow".into(), 600, 1, false, true)])
.list
.is_empty()
);
}
#[test]
fn raid_omen_acts_on_its_last_tick() {
let mut fx = PlayerEffects::default();
+26 -4
View File
@@ -23,6 +23,7 @@ struct Globals {
// Horizon Extras' dynamic lights: camera-relative position and level (dynamic_light.rs).
dynamic_lights: array<vec4<f32>, 16>,
sunrise_fan: array<vec4<f32>, 32>,
filtering: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -131,7 +132,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
@location(5) normal: vec4<f32>, @location(6) glint_uv: vec2<f32>) -> Out {
var out = entity_vertex(position, uv, overlay & 0xffffu, color, light, normal);
out.glint_uv = glint_uv;
out.tier = overlay >> 16u;
out.tier = (overlay >> 16u) & 0xffu;
return out;
}
// Retained bolt-local geometry, translated using the same f64 camera
@@ -224,6 +225,9 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
}
// CRUMBLING_UV_END
@fragment fn fs_entity(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return entity_color(in, front);
}
fn entity_color(in: Out, front: bool) -> vec4<f32> {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
if in.overlay == 11u || in.overlay == 12u {
@@ -271,6 +275,9 @@ fn crumbling_uv(uv: vec2<f32>, packed_origin: vec2<f32>, sprite_size: vec2<f32>,
// rendertype_entity_shadow.fsh: the shadow texture times the vertex colour,
// then fog, with no alpha cutout, so a faint shadow in dim light still draws.
@fragment fn fs_entity_shadow(in: Out) -> @location(0) vec4<f32> {
return shadow_color(in);
}
fn shadow_color(in: Out) -> vec4<f32> {
var color = textureSample(atlas, atlas_sampler, in.uv) * in.color;
color *= in.light_color;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
@@ -306,21 +313,33 @@ fn celestial_color(in: Out) -> vec4<f32> {
@fragment fn fs_sky_celestial(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
let added = color.rgb * color.a;
return vec4(added, smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
// The sky under the disc keeps alpha 1 less this: 0 for the brightest tier
// (the sun), terrain.wgsl's mark for the lower ones (the moon). Tier 0 is
// geometry that sets none (the End sky, the End flash): it stays brightest.
let tier = select(4u, min(in.tier, 4u), in.tier > 0u);
let mark = 1.0 - 0.7 * f32(4u - tier) / 4.0;
return vec4(added, mark * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
}
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
// the UV and repeated within itself, times the layer colour 0xFF808080, unlit; the pipeline adds
// it to the frame. The light bytes carry the sprite's origin and the colour bytes its size, both
// as breaking::stage_light packs them.
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
fn energy_swirl_base(in: Out) -> vec4<f32> {
let bytes = round(in.color * 255.0);
let size = vec2(bytes.x + bytes.y * 256.0, bytes.z + bytes.w * 256.0) / 65535.0;
let color = textureSample(atlas, atlas_sampler, crumbling_uv(in.uv, in.wrap, size, textureDimensions(atlas)));
if color.a < 0.1 { discard; }
return vec4(color.rgb * (128.0 / 255.0), color.a);
}
fn energy_swirl_fog(in: Out) -> f32 {
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return vec4(mix(color.rgb * (128.0 / 255.0), globals.fog_color.rgb, f * globals.fog_color.a), color.a);
return f * globals.fog_color.a;
}
@fragment fn fs_energy_swirl(in: Out) -> @location(0) vec4<f32> {
let color = energy_swirl_base(in);
return vec4(mix(color.rgb, globals.fog_color.rgb, energy_swirl_fog(in)), color.a);
}
// RenderPipelines.WATER_MASK: depth only (the pipeline masks the colour off).
@@ -449,6 +468,9 @@ struct WeatherTime { values: vec4<f32> }
return out;
}
@fragment fn fs_weather(in: Out) -> @location(0) vec4<f32> {
return weather_color(in);
}
fn weather_color(in: Out) -> vec4<f32> {
// Repeat within this sprite, never across the atlas. The atlas uses nearest
// filtering and no mipmaps, so fractional UVs have no seam derivatives.
let texel = weather_texel(in.uv, in.weather_rect, textureDimensions(atlas));
+555 -76
View File
@@ -40,9 +40,9 @@ mod entity_outline;
mod weather;
use entity_batch::EntityBatch;
pub use entity_batch::{
ENERGY_SWIRL_OVERLAY, SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY,
ENERGY_SWIRL_OVERLAY, OIT_ENTITY, SHADOW_OVERLAY, TEXT_POLYGON_OFFSET_OVERLAY,
TRANSLUCENT_GLINT_ITEM_OVERLAY, TRANSLUCENT_ITEM_OVERLAY, TRANSLUCENT_PARTICLE_OVERLAY,
WATER_MASK_OVERLAY,
WATER_MASK_OVERLAY, mark_oit,
};
pub use view::{View, ViewSet};
@@ -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;
@@ -84,6 +85,8 @@ mod native_pack;
mod native_post;
mod native_sky;
mod occlusion;
mod oit;
mod oit_features;
mod pack_atlas;
pub mod pack_draw;
mod pack_entities;
@@ -224,6 +227,8 @@ struct Globals {
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
/// The sunrise fan's triangles as the sky shader tests them (`sunrise_fan::normals`).
sunrise_fan: [[f32; 4]; 2 * sunrise_fan::TRIANGLES],
/// x: `Options.textureFiltering` (0 none, 1 RGSS, 2 anisotropic); y: the sprite arrays' width.
filtering: [f32; 4],
}
pub type SectionKey = (i32, i32, i32);
struct SectionGpu {
@@ -542,6 +547,10 @@ pub struct Renderer {
/// terrain.wgsl's `SPRITE_ARRAYS` and `SPRITE_SPLIT`, for a shader pack's terrain.
terrain_sprites: [f64; 2],
sprite_sampler: wgpu::Sampler,
/// `Options.textureFiltering`: 0 none, 1 RGSS, 2 anisotropic.
texture_filtering: u32,
/// The anisotropic sampler's clamp (`Options.maxAnisotropyValue`); 1 unless anisotropic.
max_anisotropy: u16,
lightmap_view: wgpu::TextureView,
lightmap_sampler: wgpu::Sampler,
animations: animate::SpriteAnimator,
@@ -672,6 +681,10 @@ pub struct Renderer {
pub gamma: f32,
/// Optional full-light lightmap (Horizon Extras).
pub fullbright: bool,
/// The Improved transparency option (`GameRenderer.useImprovedTransparency`).
pub improved_transparency: bool,
/// Its passes and targets, made when first drawn with.
oit: Option<oit::Oit>,
/// Advance animated block textures (Horizon setting; vanilla always does).
pub animated_textures: bool,
present_modes: Vec<wgpu::PresentMode>,
@@ -723,6 +736,36 @@ fn sprite_views(
})
.collect()
}
/// The terrain sprites' sampler for a Texture Filtering mode. None keeps the nearest sampler;
/// RGSS and anisotropic take LevelRenderer.chunkLayerSampler's linear one (clamped to the edge),
/// with `anisotropy` samples when it is above 1 (wgpu then needs a linear mipmap filter too).
fn sprite_sampler_descriptor(filtering: u32, anisotropy: u16) -> wgpu::SamplerDescriptor<'static> {
if filtering == 0 {
return wgpu::SamplerDescriptor {
label: Some("Block sprites"),
address_mode_u: wgpu::AddressMode::Repeat,
address_mode_v: wgpu::AddressMode::Repeat,
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
..Default::default()
};
}
wgpu::SamplerDescriptor {
label: Some("Block sprites (filtered)"),
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
mipmap_filter: wgpu::MipmapFilterMode::Linear,
anisotropy_clamp: anisotropy,
..Default::default()
}
}
/// `Options.maxAnisotropyValue` for a `maxAnisotropyBit`: `1 << bit`, at most wgpu's 16.
fn sampler_anisotropy(bit: i32) -> u16 {
1u16 << bit.clamp(0, 4)
}
/// terrain.wgsl's group 0.
#[allow(clippy::too_many_arguments)]
fn terrain_group(
@@ -1119,6 +1162,7 @@ async fn open_backend(
// stand-in neither clamps nor drops NaN.
let features = timing::GpuTimer::features(&adapter)
| cull::Cull::features(&adapter)
| oit::features(&adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
@@ -1207,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] {
@@ -1701,15 +1758,7 @@ impl Renderer {
..Default::default()
});
let sprite_views = sprite_views(&sprite_textures, &sprite_unused, None);
let sprite_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Block sprites"),
address_mode_u: wgpu::AddressMode::Repeat,
address_mode_v: wgpu::AddressMode::Repeat,
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
mipmap_filter: wgpu::MipmapFilterMode::Nearest,
..Default::default()
});
let sprite_sampler = device.create_sampler(&sprite_sampler_descriptor(0, 1));
let lightmap = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Lightmap"),
size: wgpu::Extent3d {
@@ -2728,7 +2777,7 @@ impl Renderer {
}
cull
});
let depth = Self::depth(&device, &config, cull.as_ref());
let depth = Self::depth(&device, &config);
let world_post = world_post::Post::new(&device, config.format.remove_srgb_suffix());
let uploads = upload::Uploads::new(&device);
let section_uploads = upload::Uploads::with_chunk(&device, SECTION_UPLOAD_CHUNK);
@@ -2840,6 +2889,8 @@ impl Renderer {
terrain_layout: bindings,
terrain_sprites: sprite_constants.map(|(_, v)| v),
sprite_sampler,
texture_filtering: 0,
max_anisotropy: 1,
lightmap_view,
lightmap_sampler,
animations,
@@ -2914,6 +2965,8 @@ impl Renderer {
screenshot: None,
screenshot_result: None,
gamma: 0.5,
improved_transparency: false,
oit: None,
fullbright: false,
animated_textures: true,
present_modes,
@@ -2998,13 +3051,8 @@ impl Renderer {
self.surface = Some(surface);
Ok(())
}
/// The world's depth, which occlusion culling's test may read.
fn depth(
device: &wgpu::Device,
config: &wgpu::SurfaceConfiguration,
cull: Option<&cull::Cull>,
) -> wgpu::TextureView {
let read = cull.is_some_and(|c| c.depth_test() == cull::DepthTest::InShader);
/// The world's depth, which occlusion culling's test and post effects may read.
fn depth(device: &wgpu::Device, config: &wgpu::SurfaceConfiguration) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon depth"),
@@ -3017,11 +3065,11 @@ impl Renderer {
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: if read {
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING
} else {
wgpu::TextureUsages::RENDER_ATTACHMENT
},
// TEXTURE_BINDING: a post effect may read the depth buffer.
// COPY_SRC: improved transparency copies it.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default())
@@ -3085,7 +3133,7 @@ impl Renderer {
} else {
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
}
self.depth = Self::depth(&self.device, &self.config, self.cull.as_ref());
self.depth = Self::depth(&self.device, &self.config);
}
/// Whether sections are drawn as one index list per frame (`flat`).
pub fn flat_draws(&self) -> bool {
@@ -3184,7 +3232,7 @@ impl Renderer {
section.is_some() || (mesh.solid.is_empty() && mesh.translucent.is_empty()),
);
if let Some(section) = section {
self.sections.insert(key, section);
self.sections.insert_with_bounds(key, section, mesh.bounds);
}
}
fn upload_mesh(&mut self, mesh: &SectionMesh) -> Option<SectionGpu> {
@@ -3516,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) {
@@ -3529,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
@@ -3583,7 +3657,8 @@ impl Renderer {
// Only HDR output asks the system about the display, so with HDR off the
// game never waits on the compositor for it.
self.display = if output.is_some() {
let (watch, now) = DisplayWatch::new(&self.window);
let (watch, now) =
DisplayWatch::new(&self.window, self.surface.as_ref(), &self.adapter);
crate::graphics::report(now);
self.display_fresh = true;
watch
@@ -3591,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!(
@@ -3622,7 +3698,7 @@ impl Renderer {
if std::mem::take(&mut self.display_fresh) {
return true;
}
let Some(now) = self.display.changed() else {
let Some(now) = self.display.changed(self.surface.as_ref(), &self.adapter) else {
return false;
};
crate::graphics::report(now);
@@ -3712,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());
};
@@ -3723,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)?;
@@ -3805,6 +3886,44 @@ impl Renderer {
pub fn shader_pack_dimension(&self) -> Option<i32> {
self.pack_post.as_ref().map(|p| p.0)
}
/// The Texture Filtering and Max Anisotropy options (`filtering`: 0 none, 1 RGSS, 2
/// anisotropic; `bit`: `maxAnisotropyBit`). None is the nearest sampler terrain always used;
/// the others take LevelRenderer's chunk layer sampler (linear, clamped, `bit` anisotropy
/// when anisotropic) and terrain.wgsl's sampleNearest / sampleRGSS.
pub fn set_texture_filtering(&mut self, filtering: i32, bit: i32) {
let filtering = filtering.clamp(0, 2) as u32;
let anisotropy = if filtering == 2 {
sampler_anisotropy(bit)
} else {
1
};
if filtering == self.texture_filtering && anisotropy == self.max_anisotropy {
return;
}
self.texture_filtering = filtering;
self.max_anisotropy = anisotropy;
self.sprite_sampler = self
.device
.create_sampler(&sprite_sampler_descriptor(filtering, anisotropy));
self.rebuild_terrain_group();
}
fn rebuild_terrain_group(&mut self) {
let views = sprite_views(
&self.sprite_textures,
&self.sprite_unused,
Some(self.mipmaps + 1),
);
self.group = terrain_group(
&self.device,
"Horizon terrain resources",
&self.terrain_layout,
&self.globals,
&views,
&self.sprite_sampler,
&self.lightmap_view,
&self.lightmap_sampler,
);
}
/// The Mipmap Levels option: block textures sample at most `levels`
/// reduced copies (0: none).
pub fn set_mipmap_levels(&mut self, levels: u32) {
@@ -3818,17 +3937,7 @@ impl Renderer {
return;
}
self.mipmaps = levels;
let views = sprite_views(&self.sprite_textures, &self.sprite_unused, Some(levels + 1));
self.group = terrain_group(
&self.device,
"Horizon terrain resources",
&self.terrain_layout,
&self.globals,
&views,
&self.sprite_sampler,
&self.lightmap_view,
&self.lightmap_sampler,
);
self.rebuild_terrain_group();
}
/// The views `render` draws `frame` from.
pub fn views(&self, frame: &Frame) -> ViewSet {
@@ -4148,6 +4257,12 @@ impl Renderer {
lights
},
sunrise_fan: sunrise_fan::normals(frame.sky.sunrise_color[3], sun),
filtering: [
self.texture_filtering as f32,
self.sprite_textures[0].width() as f32,
0.0,
0.0,
],
};
self.uploads
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
@@ -4700,6 +4815,13 @@ impl Renderer {
);
entities = &self.pack_entity_rest;
}
// GameRenderer.useImprovedTransparency. A shader pack keeps its own
// path.
let oit_on = self.improved_transparency
&& !frame.title
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& self.native.is_none()
&& oit::supported(&self.device);
let [
entity_quads,
text_quads,
@@ -4710,9 +4832,14 @@ impl Renderer {
shadow_quads,
translucent_particle_quads,
translucent_item_quads,
] = self
.entity_batch
.update(entities, frame.additive, frame.hand, frame.see_through);
oit_entity_quads,
] = self.entity_batch.update(
entities,
frame.additive,
frame.hand,
frame.see_through,
oit_on,
);
let weather_quads = frame.weather.len() as u32 / 4;
let world_quads = entity_quads
+ text_quads
@@ -4722,7 +4849,11 @@ impl Renderer {
+ swirl_quads
+ shadow_quads
+ translucent_particle_quads
+ translucent_item_quads;
+ translucent_item_quads
+ oit_entity_quads;
// Where the tail of materials the classic and the improved passes
// both draw ends: the shadows, particles and items count back from it.
let base_quads = world_quads - oit_entity_quads;
let cloud_quads = if let Some(input) = &frame.clouds {
let (rebuilt, uniform) =
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
@@ -4763,7 +4894,7 @@ impl Renderer {
);
let all_entities = &self.entity_batch.combined;
let hand_quads = (frame.hand.len() / 4) as u32;
let additive_quads = (frame.additive.len() / 4) as u32;
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
let see_through_quads = (frame.see_through.len() / 4) as u32;
let entity_vertices = all_entities.len() as u64;
if entity_vertices > self.entity_capacity {
@@ -4972,7 +5103,7 @@ impl Renderer {
}
let pack_reads_world = pack_post || native_post;
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
let keep_depth = pack_reads_world || (taa && self.world_post.taa);
let keep_depth = pack_reads_world || (taa && self.world_post.taa) || oit_on;
// The pack's program reads the vertices this frame's draws bind.
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
// The shadow camera's lists, drawn by the pack's shadow programs.
@@ -5025,6 +5156,46 @@ impl Renderer {
if culling && let Some(cull) = &mut self.cull {
cull.bind_depth(&self.device, world_depth);
}
if oit_on {
let size = self
.world_post
.size()
.filter(|_| scaled.is_some())
.unwrap_or([self.config.width, self.config.height]);
let (device, layout, sprites) =
(&self.device, &self.terrain_layout, self.terrain_sprites);
let clouds = [0, 1].map(|i| self.cloud_pipeline.get_bind_group_layout(i));
let format = self.config.format.remove_srgb_suffix();
let flat = self
.flat
.as_ref()
.map(|f| f.translucent.get_bind_group_layout(1));
let features = [
self.entity_pipeline.get_bind_group_layout(0),
self.weather.pipeline().get_bind_group_layout(1),
self.border_pipeline.get_bind_group_layout(0),
];
self.oit
.get_or_insert_with(|| {
oit::Oit::new(
device,
layout,
flat.as_ref(),
[&features[0], &features[1], &features[2]],
[&clouds[0], &clouds[1]],
sprites,
format,
)
})
.prepare(
&self.device,
&self.queue,
size,
&views.primary().projection,
[water_mask_quads > 0, cloud_quads > 0],
);
}
let oit = self.oit.as_ref().filter(|_| oit_on);
let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0);
let clear = if frame.title || (frame.end_sky && !frame.underwater && sky_open(frame)) {
wgpu::Color::BLACK
@@ -5262,29 +5433,29 @@ impl Renderer {
}
pass.set_bind_group(0, &self.group, &[]);
}
if text_quads > 0 {
if text_quads > 0 && !oit_on {
pass.set_pipeline(&self.text_polygon_offset_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..text_quads * 6, (entity_quads * 4) as i32, 0..1);
pass.set_bind_group(0, &self.group, &[]);
}
if translucent_item_quads > 0 {
if translucent_item_quads > 0 && !oit_on {
pass.set_pipeline(&self.translucent_item_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..translucent_item_quads * 6,
((world_quads - translucent_item_quads) * 4) as i32,
((base_quads - translucent_item_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
}
if !pack_draws_weather {
if !pack_draws_weather && !oit_on {
self.weather.draw(&mut pass, &self.entity_group);
}
pass.set_bind_group(0, &self.group, &[]);
if crumbling_quads > 0 {
if crumbling_quads > 0 && !oit_on {
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -5295,13 +5466,13 @@ impl Renderer {
);
pass.set_bind_group(0, &self.group, &[]);
}
if shadow_quads > 0 {
if shadow_quads > 0 && !oit_on {
pass.set_pipeline(&self.shadow_blob_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..shadow_quads * 6,
((world_quads
((base_quads
- translucent_item_quads
- translucent_particle_quads
- shadow_quads)
@@ -5312,7 +5483,7 @@ impl Renderer {
}
// BoatRenderer's water patch (RenderTypes.waterMask): depth only,
// before translucent terrain, so no water shows inside the boat.
if water_mask_quads > 0 {
if water_mask_quads > 0 && !oit_on {
pass.set_pipeline(&self.water_mask_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -5324,13 +5495,13 @@ impl Renderer {
);
pass.set_bind_group(0, &self.group, &[]);
}
if !frame.lightning.bolts.is_empty() {
if !frame.lightning.bolts.is_empty() && !oit_on {
pass.set_pipeline(&self.lightning_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
self.lightning.draw(&mut pass, &frame.lightning);
pass.set_bind_group(0, &self.group, &[]);
}
if swirl_quads > 0 {
if swirl_quads > 0 && !oit_on {
pass.set_pipeline(&self.energy_swirl_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -5346,7 +5517,7 @@ impl Renderer {
);
pass.set_bind_group(0, &self.group, &[]);
}
if additive_quads > 0 {
if additive_quads > 0 && !oit_on {
pass.set_pipeline(&self.additive_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -5369,6 +5540,168 @@ impl Renderer {
match self.flat.as_ref().filter(|_| flat_frame) {
// The pack draws translucent terrain after its deferred programs.
_ if pack_draws_water => {}
// LevelRenderer.executeOit in place of the classic blend; what
// follows draws over its composite in a pass of its own.
flat if let Some(oit) = oit => {
drop(pass);
// LevelRenderer.executeOitWaterMask and CloudRenderer.renderOit.
let mask = |pass: &mut wgpu::RenderPass<'_>| {
pass.set_pipeline(&self.water_mask_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..water_mask_quads * 6,
((entity_quads
+ text_quads
+ crumbling_quads
+ translucent_crumbling_quads)
* 4) as i32,
0..1,
);
};
let clouds =
|pass: &mut wgpu::RenderPass<'_>, pipeline: &wgpu::RenderPipeline| {
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(1, &self.cloud_group, &[]);
pass.set_index_buffer(
self.indices.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.set_vertex_buffer(0, self.cloud_buffer.slice(..));
pass.draw_indexed(0..cloud_quads * 6, 0, 0..1);
};
// "Features, World Border, Weather": what follows the opaque
// world in the classic pass, one stage at a time.
let features = |pass: &mut wgpu::RenderPass<'_>, stage: oit::Stage| {
let f = oit.features();
let s = stage as usize;
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
// Quads of a material, `first` quads into the buffer.
let quads = |pass: &mut wgpu::RenderPass<'_>,
pipeline: &wgpu::RenderPipeline,
count: u32,
first: u32| {
if count > 0 {
pass.set_pipeline(pipeline);
pass.draw_indexed(0..count * 6, (first * 4) as i32, 0..1);
}
};
quads(pass, &f.text[s], text_quads, entity_quads);
quads(
pass,
&f.crumbling[s],
crumbling_quads,
entity_quads + text_quads,
);
quads(
pass,
&f.crumbling[s],
translucent_crumbling_quads,
entity_quads + text_quads + crumbling_quads,
);
quads(
pass,
&f.swirl[s],
swirl_quads,
entity_quads
+ text_quads
+ crumbling_quads
+ translucent_crumbling_quads
+ water_mask_quads,
);
quads(
pass,
&f.shadow[s],
shadow_quads,
base_quads
- translucent_item_quads
- translucent_particle_quads
- shadow_quads,
);
quads(
pass,
&f.entity[s],
translucent_particle_quads,
base_quads - translucent_item_quads - translucent_particle_quads,
);
quads(
pass,
&f.entity[s],
translucent_item_quads,
base_quads - translucent_item_quads,
);
// Translucent, emissive, eyes and breeze wind models:
// the same fragment, their light and shading are in
// the vertices.
quads(pass, &f.entity[s], oit_entity_quads, base_quads);
if !frame.lightning.bolts.is_empty() {
pass.set_pipeline(&f.lightning[s]);
self.lightning.draw(pass, &frame.lightning);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
}
quads(pass, &f.additive[s], additive_quads, world_quads);
self.weather
.draw_with(pass, &f.weather[s], &self.entity_group);
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title) {
pass.set_pipeline(&f.border[s]);
pass.set_bind_group(0, &self.border_group, &[]);
pass.set_vertex_buffer(0, self.border_vertices.slice(..));
for &side in &b.sides {
let first = side as u32 * 6;
pass.draw_indexed(first..first + 6, 0, 0..1);
}
}
};
let extras = oit::Extras {
water_mask: (water_mask_quads > 0).then_some(&mask as _),
clouds: (cloud_quads > 0).then_some(&clouds as _),
features: Some(&features),
};
oit.run(
&mut encoder,
world_view,
world_depth,
&|pass, stage| {
pass.set_bind_group(0, &self.group, &[]);
// Flat section draws take the flat pipelines.
if let Some((flat, pipeline)) = flat.zip(oit.flat(stage)) {
pass.set_pipeline(pipeline);
flat.draw(pass, flat.translucent_calls(), |p| self.arena.buffer(p));
return;
}
pass.set_pipeline(oit.terrain(stage));
pass.set_index_buffer(
self.indices.slice(..),
wgpu::IndexFormat::Uint32,
);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections(
pass,
&self.indirect,
0,
solid_draws,
translucent_draws,
);
},
&extras,
);
pass = world_pass_after_culling(&mut encoder, world_view, world_depth, true);
pass.set_bind_group(0, &self.group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
// HDR output's mark on the eyes drawn in the stages, as the
// classic pass marks them after the entities.
if oit_entity_quads > 0 && self.hdr.is_some() {
pass.set_pipeline(&self.hdr_mark_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..oit_entity_quads * 6, (base_quads * 4) as i32, 0..1);
pass.set_bind_group(0, &self.group, &[]);
}
}
Some(flat) => {
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
flat.draw(&mut pass, flat.translucent_calls(), |p| {
@@ -5394,15 +5727,14 @@ impl Renderer {
// translucent particle layer draws after translucent terrain —
// the quads write depth, so before it they would hide the glass
// or water behind them.
if translucent_particle_quads > 0 {
if translucent_particle_quads > 0 && !oit_on {
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..translucent_particle_quads * 6,
((world_quads - translucent_item_quads - translucent_particle_quads) * 4)
as i32,
((base_quads - translucent_item_quads - translucent_particle_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
@@ -5414,7 +5746,7 @@ impl Renderer {
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
// LevelRenderer: the world border after translucent terrain and weather.
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title) {
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title && !oit_on) {
pass.set_pipeline(&self.border_pipeline);
pass.set_bind_group(0, &self.border_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -5425,7 +5757,7 @@ impl Renderer {
}
pass.set_bind_group(0, &self.group, &[]);
}
if translucent_crumbling_quads > 0 {
if translucent_crumbling_quads > 0 && !oit_on {
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
@@ -5435,7 +5767,7 @@ impl Renderer {
0..1,
);
}
if cloud_quads > 0 && !pack_draws_clouds {
if cloud_quads > 0 && !pack_draws_clouds && !oit_on {
pass.set_pipeline(&self.cloud_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(1, &self.cloud_group, &[]);
@@ -5633,6 +5965,8 @@ impl Renderer {
&target_texture,
&view_target,
[self.config.width, self.config.height],
// The scaled world pass drew its own depth buffer.
scaled.is_none().then_some(&self.depth),
&frame.post_effects,
);
}
@@ -6153,6 +6487,46 @@ mod tests {
assert!(!drawable_size(984, 65496, 32768));
assert!(!drawable_size(984, u32::MAX, 32768));
}
#[test]
fn os_luminances_become_auto_values() {
use super::os_reported;
use crate::graphics::Reported;
let info = |max, white| wgpu::DisplayHdrInfo {
luminance: Some(wgpu::DisplayLuminance {
max_nits: max,
sdr_white_nits: white,
..Default::default()
}),
..Default::default()
};
// Windows' SDR content brightness slider runs 80..480 nits.
assert_eq!(
os_reported(&info(Some(998.4), Some(240.0))),
Some(Reported {
paper_white: 240,
peak: 998
})
);
assert_eq!(
os_reported(&info(Some(120.0), Some(1000.0))),
Some(Reported {
paper_white: 480,
peak: 480
})
);
// White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456).
assert_eq!(
os_reported(&info(Some(380.0), Some(456.0))),
Some(Reported {
paper_white: 456,
peak: 456
})
);
assert_eq!(os_reported(&info(Some(1000.0), None)), None);
assert_eq!(os_reported(&info(Some(f32::NAN), Some(80.0))), None);
assert_eq!(os_reported(&wgpu::DisplayHdrInfo::default()), None);
}
#[test]
fn surface_is_8_bit_and_not_srgb() {
use super::surface_format;
@@ -6225,9 +6599,9 @@ mod tests {
.flat_map(|(layer, id)| quad(layer, id))
.collect();
let mut batch = super::EntityBatch::default();
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8), false);
let vertices = &batch.combined;
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0]);
assert_eq!(counts, [4, 1, 1, 1, 0, 0, 1, 1, 0, 0]);
assert_eq!(
vertices
.as_chunks::<4>()
@@ -6299,8 +6673,31 @@ mod tests {
&mut files,
);
assert!(files.len() >= 10, "{files:?}");
// Improved transparency's files are appended to terrain.wgsl and entity.wgsl.
let mut sources: Vec<(std::path::PathBuf, String)> = Vec::new();
for path in files {
let text = std::fs::read_to_string(&path).unwrap();
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if name == "oit.wgsl" {
// The shared maths: it needs the file it is appended to.
continue;
} else if name == "oit_world.wgsl" {
let base = include_str!("terrain.wgsl").to_owned() + include_str!("oit.wgsl");
sources.push((path, base + &text));
} else if name == "oit_features.wgsl" {
let base = super::entity_shader() + include_str!("oit.wgsl");
sources.push((path, base + &text));
} else if name == "oit_border.wgsl" {
let base = include_str!("border.wgsl").to_owned() + include_str!("oit.wgsl");
sources.push((path, base + &text));
} else if name == "oit_clouds.wgsl" {
let base = super::entity_shader() + include_str!("oit.wgsl");
sources.push((path, base + &text));
} else {
sources.push((path, text));
}
}
for (path, text) in sources {
// entity.wgsl comes after the overlay's constants (entity_shader).
let module = naga::front::wgsl::parse_str(&text)
.or_else(|_| {
@@ -6419,6 +6816,30 @@ mod tests {
);
}
#[test]
fn texture_filtering_samplers() {
use super::{sampler_anisotropy, sprite_sampler_descriptor};
// None is the sampler terrain always used.
let none = sprite_sampler_descriptor(0, 1);
assert_eq!(none.mag_filter, wgpu::FilterMode::Nearest);
assert_eq!(none.min_filter, wgpu::FilterMode::Nearest);
assert_eq!(none.address_mode_u, wgpu::AddressMode::Repeat);
assert_eq!(none.anisotropy_clamp, 1);
// LevelRenderer.chunkLayerSampler: linear both ways, clamped.
for (mode, aniso) in [(1, 1), (2, 8)] {
let d = sprite_sampler_descriptor(mode, aniso);
assert_eq!(d.mag_filter, wgpu::FilterMode::Linear);
assert_eq!(d.min_filter, wgpu::FilterMode::Linear);
assert_eq!(d.mipmap_filter, wgpu::MipmapFilterMode::Linear);
assert_eq!(d.address_mode_v, wgpu::AddressMode::ClampToEdge);
assert_eq!(d.anisotropy_clamp, aniso);
}
assert_eq!(
[1, 2, 3].map(sampler_anisotropy),
[2, 4, 8],
"Options.maxAnisotropyValue is 1 << bit"
);
}
#[test]
fn shader_globals_match_the_rust_layout() {
// terrain.wgsl and entity.wgsl bind the same buffer: same fields, same order.
let fields = |source: &str| -> Vec<String> {
@@ -6635,32 +7056,65 @@ mod sprite_upload_tests {
}
/// What the system says about the window's display, kept up to date: on Wayland, the
/// compositor's preferred image description of the surface.
/// compositor's preferred image description of the surface; elsewhere what wgpu reads
/// from the OS (on Windows, DXGI's luminances and the SDR white level).
#[derive(Default)]
struct DisplayWatch {
#[cfg(target_os = "linux")]
wayland: Option<horizon_hdr::wayland::Watch>,
/// Without Wayland's event, what wgpu last read and when to read it again: the OS
/// sends no event for a moved window or a changed SDR brightness slider.
polled: Option<(Option<crate::graphics::Reported>, std::time::Instant)>,
}
/// How often [`DisplayWatch`] reads the OS's values again where no event says they changed.
const DISPLAY_POLL: std::time::Duration = std::time::Duration::from_secs(2);
impl DisplayWatch {
/// Starts watching, with what the system reports now.
fn new(window: &Window) -> (Self, Option<crate::graphics::Reported>) {
fn new(
window: &Window,
surface: Option<&wgpu::Surface>,
adapter: &wgpu::Adapter,
) -> (Self, Option<crate::graphics::Reported>) {
#[cfg(target_os = "linux")]
if let Some((wayland, now)) = horizon_hdr::wayland::Watch::new(window) {
let watch = Self {
wayland: Some(wayland),
polled: None,
};
return (watch, now.map(reported));
}
let _ = window;
(Self::default(), None)
let now = surface.and_then(|surface| os_reported(&surface.display_hdr_info(adapter)));
log_os_reported(now);
let watch = Self {
#[cfg(target_os = "linux")]
wayland: None,
polled: Some((now, std::time::Instant::now() + DISPLAY_POLL)),
};
(watch, now)
}
/// What the system reports, if that changed since the last call.
fn changed(&mut self) -> Option<Option<crate::graphics::Reported>> {
fn changed(
&mut self,
surface: Option<&wgpu::Surface>,
adapter: &wgpu::Adapter,
) -> Option<Option<crate::graphics::Reported>> {
#[cfg(target_os = "linux")]
if let Some(wayland) = &mut self.wayland {
return wayland.changed().map(|now| now.map(reported));
}
None
let (last, next) = self.polled.as_mut()?;
let time = std::time::Instant::now();
if time < *next {
return None;
}
*next = time + DISPLAY_POLL;
let now = surface.and_then(|surface| os_reported(&surface.display_hdr_info(adapter)));
(now != *last).then(|| {
log_os_reported(now);
*last = now;
now
})
}
}
@@ -6680,8 +7134,6 @@ static DISPLAY_LOG: std::sync::Mutex<DisplayLog> = std::sync::Mutex::new(Display
pending: None,
});
const DISPLAY_LOG_QUIET: std::time::Duration = std::time::Duration::from_secs(1);
// Only Wayland's HDR reports call it so far.
#[cfg(target_os = "linux")]
fn log_display(line: String) {
let now = std::time::Instant::now();
let mut log = DISPLAY_LOG
@@ -6711,6 +7163,33 @@ fn flush_display_log() {
(log.last, log.at) = (line, Some(now));
}
}
fn log_os_reported(reported: Option<crate::graphics::Reported>) {
log_display(match reported {
Some(r) => format!(
"Horizon renderer: the system reports SDR white at {} nits and a peak of {} nits",
r.paper_white, r.peak
),
None => "Horizon renderer: the system doesn't report the display's luminance".into(),
});
}
/// What the OS reports in absolute nits (Windows: DXGI's `MaxLuminance` and the SDR
/// white level the brightness slider sets), which is HDR output's scale there: SDR
/// white is Paper White, the display's peak is Peak Brightness. Both are kept within
/// the settings' ranges. The OS's figures often come from the display's EDID and may
/// run optimistic; the player's own value always wins.
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
let luminance = info.luminance?;
let usable = |n: f32| n.is_finite() && n > 0.0;
let peak = luminance.max_nits.filter(|&n| usable(n))?;
let white = luminance.sdr_white_nits.filter(|&n| usable(n))?;
let paper_white = (white.round() as u32).clamp(80, 480);
Some(crate::graphics::Reported {
paper_white,
// Windows' SDR brightness slider can set white above the reported peak (480 against
// a calibrated 380): the highlights then stay at white instead of dropping below it.
peak: (peak.round() as u32).clamp(200, 10_000).max(paper_white),
})
}
/// What a compositor's luminances mean in HDR output's scale.
///
@@ -491,10 +491,6 @@ impl Cull {
}
}
pub fn depth_test(&self) -> DepthTest {
self.depth_test
}
/// In the world pass after phase 1 (`Early`): draw the tested boxes
/// against its depth. Leaves the pass's pipeline and group 0 changed.
pub fn test(&self, pass: &mut wgpu::RenderPass<'_>) {
@@ -9,7 +9,24 @@ use super::Vertex;
/// `RenderPipelines.ENTITY_SHADOW`), translucent-layer particles (15, drawn
/// after translucent terrain: `SubmitNodeCollection.submitQuadParticleGroup`)
/// and translucent item quads (32, drawn without depth writes).
const MATERIALS: usize = 9;
///
/// With improved transparency on, quads tagged `OIT_ENTITY` are a tenth
/// material at the end: the render types vanilla gives an `OitPipelineSet`
/// (translucent, emissive, eyes and breeze wind entity models). Off, the tag is
/// ignored and they stay in the first one, in their order.
const MATERIALS: usize = 10;
/// A bit of `Vertex::layer`, above the overlay (low 16 bits) and the HDR tier
/// (bits 16 to 23): the quad's render type has an `OitPipelineSet`
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
/// `breezeWind`). Only the improved transparency path reads it.
pub const OIT_ENTITY: u32 = 1 << 24;
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
pub fn mark_oit(quads: &mut [Vertex]) {
for vertex in quads {
vertex.layer |= OIT_ENTITY;
}
}
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
pub const WATER_MASK_OVERLAY: u32 = 13;
/// The overlay of energy swirl quads (`RenderTypes.energySwirl`: the charged creeper's and the
@@ -30,7 +47,10 @@ pub const TRANSLUCENT_GLINT_ITEM_OVERLAY: u32 = 33;
pub const TEXT_POLYGON_OFFSET_OVERLAY: u32 = 35;
/// The material of a quad by its overlay (`MATERIALS` order).
fn material(layer: u32) -> usize {
fn material(layer: u32, oit: bool) -> usize {
if oit && layer & OIT_ENTITY != 0 {
return 9;
}
match layer {
TEXT_POLYGON_OFFSET_OVERLAY => 1,
11 => 2,
@@ -51,6 +71,10 @@ pub(super) struct EntityBatch {
// frame, keeping their storage.
runs: [Vec<(usize, usize)>; MATERIALS],
pub(super) combined: Vec<Vertex>,
/// Additive quads tagged `OIT_ENTITY` that the last `update` moved into the
/// last material (improved transparency on): the dragon's eyes, which share
/// the additive buffer with its rays.
pub(super) moved_additive: u32,
}
impl EntityBatch {
@@ -60,6 +84,7 @@ impl EntityBatch {
additive: &[Vertex],
hand: &[Vertex],
see_through: &[Vertex],
oit: bool,
) -> [u32; MATERIALS] {
let quads = entities.as_chunks::<4>().0;
for runs in &mut self.runs {
@@ -68,10 +93,10 @@ impl EntityBatch {
let mut counts = [0usize; MATERIALS];
let mut start = 0;
while start < quads.len() {
let kind = material(quads[start][0].layer);
let kind = material(quads[start][0].layer, oit);
let end = quads[start + 1..]
.iter()
.position(|quad| material(quad[0].layer) != kind)
.position(|quad| material(quad[0].layer, oit) != kind)
.map_or(quads.len(), |n| start + 1 + n);
self.runs[kind].push((start, end - start));
counts[kind] += end - start;
@@ -87,7 +112,32 @@ impl EntityBatch {
.extend_from_slice(&entities[first * 4..(first + count) * 4]);
}
}
self.combined.extend_from_slice(additive);
// `RenderTypes.eyes` is blended like the other entities, not added: with
// improved transparency on, tagged additive quads join the last
// material, drawn without the additive overlay and the baked shading.
self.moved_additive = 0;
if oit {
for quad in additive.as_chunks::<4>().0 {
if quad[0].layer & OIT_ENTITY != 0 {
self.combined.extend(quad.iter().map(|v| Vertex {
layer: v.layer & !0xffff,
normal: [0; 4],
..*v
}));
counts[9] += 1;
self.moved_additive += 1;
}
}
}
if self.moved_additive == 0 {
self.combined.extend_from_slice(additive);
} else {
for quad in additive.as_chunks::<4>().0 {
if quad[0].layer & OIT_ENTITY == 0 {
self.combined.extend_from_slice(quad);
}
}
}
self.combined.extend_from_slice(hand);
self.combined.extend_from_slice(see_through);
counts.map(|count| count as u32)
@@ -156,13 +206,78 @@ mod tests {
hand: &[Vertex],
) {
let (expected, counts) = baseline(entities, additive, hand);
assert_eq!(batch.update(entities, additive, hand, &[]), counts);
assert_eq!(batch.update(entities, additive, hand, &[], false), counts);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&batch.combined),
bytemuck::cast_slice::<_, u8>(&expected)
);
}
#[test]
fn oit_entity_quads_are_a_last_material_only_when_on() {
let quad = |layer: u32, x: f32| {
(0..4).map(move |_| Vertex {
pos: [x, 0.0, 0.0],
layer,
..geometry(1, false)[0]
})
};
// Plain, tagged, plain, shadow, tagged with a hurt overlay.
let entities: Vec<Vertex> = [
(0, 0.0),
(OIT_ENTITY | 3 << 16, 1.0),
(1, 2.0),
(SHADOW_OVERLAY, 3.0),
(OIT_ENTITY | 1, 4.0),
]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
.collect();
let mut batch = EntityBatch::default();
// Off: the tag changes nothing, the quads keep their place.
let counts = batch.update(&entities, &[], &[], &[], false);
assert_eq!(counts, [4, 0, 0, 0, 0, 0, 1, 0, 0, 0]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 1.0, 2.0, 4.0, 3.0]);
// On: the tagged quads come last, in order.
let counts = batch.update(&entities, &[], &[], &[], true);
assert_eq!(counts, [2, 0, 0, 0, 0, 0, 1, 0, 0, 2]);
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 2.0, 3.0, 1.0, 4.0]);
}
#[test]
fn tagged_additive_quads_join_the_last_material_only_when_on() {
let quad = |layer: u32, x: f32| {
(0..4).map(move |_| Vertex {
pos: [x, 0.0, 0.0],
layer,
normal: [1, 2, 3, 4],
..geometry(1, false)[0]
})
};
// The dragon's eyes (tagged, overlay 2) and a ray (plain).
let additive: Vec<Vertex> = [(2 | OIT_ENTITY | 3 << 16, 1.0), (2, 2.0)]
.into_iter()
.flat_map(|(layer, x)| quad(layer, x))
.collect();
let entities: Vec<Vertex> = quad(0, 0.0).collect();
let mut batch = EntityBatch::default();
// Off: untouched.
let counts = batch.update(&entities, &additive, &[], &[], false);
assert_eq!((counts[9], batch.moved_additive), (0, 0));
assert_eq!(batch.combined.len(), 12);
assert_eq!(batch.combined[4].layer, 2 | OIT_ENTITY | 3 << 16);
// On: the eyes are an entity quad without the additive overlay, the ray stays.
let counts = batch.update(&entities, &additive, &[], &[], true);
assert_eq!((counts[0], counts[9], batch.moved_additive), (1, 1, 1));
let xs: Vec<f32> = batch.combined.iter().step_by(4).map(|v| v.pos[0]).collect();
assert_eq!(xs, [0.0, 1.0, 2.0]);
assert_eq!(batch.combined[4].layer, OIT_ENTITY | 3 << 16);
assert_eq!(batch.combined[4].normal, [0; 4]);
assert_eq!(batch.combined[8].layer, 2);
}
#[test]
fn stable_material_order_tails_and_reuse() {
let mut batch = EntityBatch::default();
@@ -227,7 +342,7 @@ mod tests {
equivalent(&mut batch, &entities, &additive, &hand);
for _ in 0..50 {
black_box(baseline(black_box(&entities), &additive, &hand));
black_box(batch.update(black_box(&entities), &additive, &hand, &[]));
black_box(batch.update(black_box(&entities), &additive, &hand, &[], false));
black_box(&batch.combined);
}
let mut before = Vec::new();
@@ -240,7 +355,7 @@ mod tests {
before.push(start.elapsed().as_nanos() / 100);
let start = Instant::now();
for _ in 0..100 {
black_box(batch.update(black_box(&entities), &additive, &hand, &[]));
black_box(batch.update(black_box(&entities), &additive, &hand, &[], false));
black_box(&batch.combined);
}
after.push(start.elapsed().as_nanos() / 100);
+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"
);
}
}
+968
View File
@@ -0,0 +1,968 @@
//! Improved transparency: vanilla 26.3's order-independent transparency
//! (`LevelRenderer.executeOit`). The translucent layer is drawn three times
//! against the opaque world's depth, without writing it, into targets the
//! next stage reads (`oit.wgsl` says what each holds), then a full-screen pass
//! composites the sum. With the option off none of this exists and the world
//! draws as before.
use super::oit_features::Features;
use glam::Mat4;
/// The depth bounds' clear: `LevelRenderer.DEPTH_BOUNDS_CLEAR_COLOR`.
const BOUNDS_CLEAR: wgpu::Color = wgpu::Color {
r: -(f32::MAX as f64),
g: 0.0,
b: 0.0,
a: 0.0,
};
const BOUNDS_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
const COEFFICIENT_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The shader's bind group: `terrain.wgsl` holds groups 0 and 1.
const GROUP: u32 = 2;
/// `OitStage`.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Stage {
DepthBounds,
Transmittance,
Accumulate,
}
/// What the device must support: max blending on 32-bit float targets.
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
adapter.features() & wgpu::Features::FLOAT32_BLENDABLE
}
pub fn supported(device: &wgpu::Device) -> bool {
device
.features()
.contains(wgpu::Features::FLOAT32_BLENDABLE)
}
/// What a stage's pipelines write: `OIT_*_SNIPPET`'s colour targets.
pub(super) fn stage_targets(stage: Stage) -> Vec<Option<wgpu::ColorTargetState>> {
let blend = |operation| wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation,
};
let target = |format, operation| {
Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: blend(operation),
alpha: blend(operation),
}),
write_mask: wgpu::ColorWrites::ALL,
})
};
let add = wgpu::BlendOperation::Add;
match stage {
Stage::DepthBounds => vec![target(BOUNDS_FORMAT, wgpu::BlendOperation::Max)],
Stage::Transmittance => vec![
target(COEFFICIENT_FORMAT, add),
target(COEFFICIENT_FORMAT, add),
],
Stage::Accumulate => vec![target(COEFFICIENT_FORMAT, add)],
}
}
/// `ProjMat[2][2]` and `ProjMat[3][2]`: a device depth `d` is `y / (d + x)`
/// blocks along the view axis (`oit_linear_depth`).
fn projection_terms(projection: &Mat4) -> [f32; 2] {
[projection.z_axis.z, projection.w_axis.z]
}
struct Targets {
size: [u32; 2],
bounds: wgpu::Texture,
culled: wgpu::Texture,
views: Views,
/// The uniform alone, with the culled bounds, with those and the
/// coefficients, for the cull with the unculled bounds, and the composite.
alone: wgpu::BindGroup,
sampled: wgpu::BindGroup,
accumulate: wgpu::BindGroup,
cull: wgpu::BindGroup,
composite: wgpu::BindGroup,
/// The depth the terrain's stages test against while a water mask is
/// drawn (a copy of the world's, plus the masks), and the clouds' own.
terrain_depth: Option<Depth>,
cloud_depth: Option<Depth>,
}
struct Depth {
texture: wgpu::Texture,
view: wgpu::TextureView,
}
impl Depth {
fn new(device: &wgpu::Device, label: &str, size: [u32; 2]) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: extent(size),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
Self {
view: texture.create_view(&Default::default()),
texture,
}
}
}
fn extent(size: [u32; 2]) -> wgpu::Extent3d {
wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
}
}
/// What the translucent layer's other drawers add to a frame: each draws with
/// a pipeline per stage where it has one.
#[derive(Default)]
pub struct Extras<'a> {
/// `executeOitWaterMask`: the boats' water patches, depth only.
pub water_mask: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>)>,
/// `CloudRenderer.renderOit`: draws the clouds with the pipeline given.
pub clouds: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline)>,
/// "Features, World Border, Weather": draws what the entity, weather and
/// border shaders put after the opaque world, with `Oit::features`'s
/// pipelines of the stage.
pub features: Option<&'a dyn Fn(&mut wgpu::RenderPass<'_>, Stage)>,
}
struct Views {
bounds: wgpu::TextureView,
culled: wgpu::TextureView,
coefficients: [wgpu::TextureView; 2],
accumulated: wgpu::TextureView,
}
pub struct Oit {
uniform: wgpu::Buffer,
/// Layouts of the groups at `GROUP`: the uniform; with the bounds; with the
/// coefficients; and with the accumulation too.
layouts: [wgpu::BindGroupLayout; 4],
/// One per `Stage`.
terrain: [wgpu::RenderPipeline; 3],
/// The terrain's flat section draws (`Flat`), where the device has them.
flat: Option<[wgpu::RenderPipeline; 3]>,
features: Features,
clouds: [wgpu::RenderPipeline; 3],
cull: wgpu::RenderPipeline,
composite: wgpu::RenderPipeline,
targets: Option<Targets>,
}
impl Oit {
/// `terrain`: the terrain bind group layout that `vs_terrain` reads,
/// `flat` the flat draws' second group, `features` the entity, weather
/// and world border layouts,
/// `clouds` the cloud pipeline's two, `sprites` the terrain's override
/// constants, `format` the world target's.
pub fn new(
device: &wgpu::Device,
terrain: &wgpu::BindGroupLayout,
flat: Option<&wgpu::BindGroupLayout>,
features: [&wgpu::BindGroupLayout; 3],
clouds: [&wgpu::BindGroupLayout; 2],
sprites: [f64; 2],
format: wgpu::TextureFormat,
) -> Self {
let instance = wgpu::vertex_attr_array![5=>Float32x4];
let buffers = [
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &super::TERRAIN_ATTRIBUTES,
}),
Some(wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance,
}),
];
let buffers = &buffers[..];
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency"),
source: wgpu::ShaderSource::Wgsl(
(include_str!("../terrain.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl"))
.into(),
),
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Improved transparency"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let entry = |binding: u32, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty,
count: None,
};
let texture = |binding| {
entry(
binding,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
)
};
let params = entry(
0,
wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
);
let layout = |label, entries: &[wgpu::BindGroupLayoutEntry]| {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some(label),
entries,
})
};
let layouts = [
layout("Improved transparency", &[params]),
layout("Improved transparency bounds", &[params, texture(1)]),
layout(
"Improved transparency coefficients",
&[params, texture(1), texture(2), texture(3)],
),
layout(
"Improved transparency composite",
&[params, texture(1), texture(2), texture(3), texture(4)],
),
];
let pipeline_layout = |first: Option<&wgpu::BindGroupLayout>, group: usize| {
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Improved transparency"),
bind_group_layouts: &[first, None, Some(&layouts[group])],
..Default::default()
})
};
let constants = [("SPRITE_ARRAYS", sprites[0]), ("SPRITE_SPLIT", sprites[1])];
let add = wgpu::BlendOperation::Add;
let depth = |write| {
Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(write),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
})
};
let pipeline = |label,
layout: &wgpu::PipelineLayout,
vertex: &str,
fragment: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
targets: &[Option<wgpu::ColorTargetState>],
depth_stencil| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some(vertex),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: vertex.starts_with("vs_terrain").then_some(wgpu::Face::Back),
..Default::default()
},
depth_stencil,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
targets,
}),
multiview_mask: None,
cache: None,
})
};
let terrain_pipeline = |label, fragment, stage| {
pipeline(
label,
&pipeline_layout(Some(terrain), stage as usize),
"vs_terrain",
fragment,
buffers,
&stage_targets(stage),
depth(false),
)
};
let terrain_pipelines = [
terrain_pipeline("OIT depth bounds", "fs_oit_bounds", Stage::DepthBounds),
terrain_pipeline(
"OIT transmittance",
"fs_oit_transmittance",
Stage::Transmittance,
),
terrain_pipeline("OIT accumulate", "fs_oit_accumulate", Stage::Accumulate),
];
// The same fragments over `vs_terrain_flat`: one vertex buffer, and
// the draw table at group 1.
let flat_pipelines = flat.map(|draw| {
let flat_buffers = &buffers[..1];
[
(
"OIT depth bounds (flat)",
"fs_oit_bounds",
Stage::DepthBounds,
),
(
"OIT transmittance (flat)",
"fs_oit_transmittance",
Stage::Transmittance,
),
(
"OIT accumulate (flat)",
"fs_oit_accumulate",
Stage::Accumulate,
),
]
.map(|(label, fragment, stage)| {
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Improved transparency flat terrain"),
bind_group_layouts: &[
Some(terrain),
Some(draw),
Some(&layouts[stage as usize]),
],
..Default::default()
});
pipeline(
label,
&layout,
"vs_terrain_flat",
fragment,
flat_buffers,
&stage_targets(stage),
depth(false),
)
})
});
let screen = |label, fragment, group, targets: &[_], depth_stencil| {
pipeline(
label,
&pipeline_layout(None, group),
"vs_sky",
fragment,
&[],
targets,
depth_stencil,
)
};
let replace = |format| {
Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})
};
let cull = screen(
"OIT cull depth bounds",
"fs_oit_cull",
1,
&[replace(BOUNDS_FORMAT)],
depth(true),
);
// `RenderPipelines.OIT_COMPOSITE`: premultiplied alpha over the world,
// the nearest see-through depth written.
let premultiplied = wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: add,
};
let composite = screen(
"OIT composite",
"fs_oit_composite",
3,
&[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: premultiplied,
alpha: premultiplied,
}),
write_mask: wgpu::ColorWrites::ALL,
})],
Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::Always),
stencil: Default::default(),
bias: Default::default(),
}),
);
// The clouds: entity.wgsl's vertex shader and vertex format, with the
// stages' fragments.
let cloud_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency clouds"),
source: wgpu::ShaderSource::Wgsl(
(super::entity_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_clouds.wgsl"))
.into(),
),
});
let cloud_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
let cloud_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &cloud_attributes,
})];
let cloud_pipeline = |label, fragment, stage: Stage| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Improved transparency clouds"),
bind_group_layouts: &[
Some(clouds[0]),
Some(clouds[1]),
Some(&layouts[stage as usize]),
],
..Default::default()
}),
),
vertex: wgpu::VertexState {
module: &cloud_shader,
entry_point: Some("vs_cloud"),
compilation_options: Default::default(),
buffers: &cloud_buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
// `OIT_CLOUDS`: only the bounds stage writes the cloud depth,
// so the later stages keep the nearest face of each cloud.
depth_stencil: depth(stage == Stage::DepthBounds),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &cloud_shader,
entry_point: Some(fragment),
compilation_options: Default::default(),
targets: &stage_targets(stage),
}),
multiview_mask: None,
cache: None,
})
};
let cloud_pipelines = [
cloud_pipeline(
"OIT cloud depth bounds",
"fs_oit_cloud_bounds",
Stage::DepthBounds,
),
cloud_pipeline(
"OIT cloud transmittance",
"fs_oit_cloud_transmittance",
Stage::Transmittance,
),
cloud_pipeline(
"OIT cloud accumulate",
"fs_oit_cloud_accumulate",
Stage::Accumulate,
),
];
let features = Features::new(device, &layouts, features[0], features[1], features[2]);
Self {
uniform,
layouts,
terrain: terrain_pipelines,
flat: flat_pipelines,
features,
clouds: cloud_pipelines,
cull,
composite,
targets: None,
}
}
/// The pipeline the terrain's translucent layer draws with in `stage`.
pub fn terrain(&self, stage: Stage) -> &wgpu::RenderPipeline {
&self.terrain[stage as usize]
}
/// The pipeline the terrain's translucent layer draws with in `stage`
/// as flat section draws, where they run.
pub fn flat(&self, stage: Stage) -> Option<&wgpu::RenderPipeline> {
self.flat.as_ref().map(|f| &f[stage as usize])
}
pub fn features(&self) -> &Features {
&self.features
}
/// Sizes the targets to the world's and writes this frame's projection.
pub fn prepare(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
size: [u32; 2],
projection: &Mat4,
wants: [bool; 2],
) {
queue.write_buffer(
&self.uniform,
0,
bytemuck::cast_slice(&[projection_terms(projection), [0.0; 2]].concat()),
);
let [mask, clouds] = wants;
if let Some(t) = self.targets.as_mut().filter(|t| t.size == size) {
if mask && t.terrain_depth.is_none() {
t.terrain_depth = Some(Depth::new(device, "OIT terrain depth", size));
}
if clouds && t.cloud_depth.is_none() {
t.cloud_depth = Some(Depth::new(device, "OIT cloud depth", size));
}
return;
}
let make = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: extent(size),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let attachment =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let bounds = make(
"OIT depth bounds",
BOUNDS_FORMAT,
attachment | wgpu::TextureUsages::COPY_SRC,
);
let culled = make(
"OIT culled depth bounds",
BOUNDS_FORMAT,
attachment | wgpu::TextureUsages::COPY_DST,
);
let coefficients =
[0, 1].map(|_| make("OIT transmittance", COEFFICIENT_FORMAT, attachment));
let accumulated = make("OIT accumulate", COEFFICIENT_FORMAT, attachment);
let views = Views {
bounds: bounds.create_view(&Default::default()),
culled: culled.create_view(&Default::default()),
coefficients: coefficients.map(|t| t.create_view(&Default::default())),
accumulated: accumulated.create_view(&Default::default()),
};
let group = |layout: &wgpu::BindGroupLayout, views: &[&wgpu::TextureView]| {
let mut entries = vec![wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform.as_entire_binding(),
}];
entries.extend(views.iter().enumerate().map(|(i, v)| wgpu::BindGroupEntry {
binding: i as u32 + 1,
resource: wgpu::BindingResource::TextureView(v),
}));
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Improved transparency"),
layout,
entries: &entries,
})
};
let [c0, c1] = &views.coefficients;
self.targets = Some(Targets {
size,
alone: group(&self.layouts[0], &[]),
sampled: group(&self.layouts[1], &[&views.culled]),
accumulate: group(&self.layouts[2], &[&views.culled, c0, c1]),
cull: group(&self.layouts[1], &[&views.bounds]),
composite: group(
&self.layouts[3],
&[&views.bounds, c0, c1, &views.accumulated],
),
terrain_depth: mask.then(|| Depth::new(device, "OIT terrain depth", size)),
cloud_depth: clouds.then(|| Depth::new(device, "OIT cloud depth", size)),
bounds,
culled,
views,
});
}
/// `LevelRenderer.executeOit`: `draw` records the terrain's translucent
/// layer into each stage's pass with that stage's pipeline, `extras` the
/// water masks and clouds, then the composite goes over `color`. `depth`
/// is the opaque world's: the stages only read it; the cull and the
/// composite write it.
pub fn run(
&self,
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
draw: &dyn Fn(&mut wgpu::RenderPass<'_>, Stage),
extras: &Extras<'_>,
) {
let Some(t) = &self.targets else { return };
let attachment = |view, load| {
Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
},
})
};
let clear = wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT);
let load = wgpu::LoadOp::Load;
let read_only = |view| {
Some(wgpu::RenderPassDepthStencilAttachment {
view,
depth_ops: None,
stencil_ops: None,
})
};
let water_mask = extras.water_mask.zip(t.terrain_depth.as_ref());
let cloud_depth = extras.clouds.zip(t.cloud_depth.as_ref());
if let Some((mask, _)) = water_mask {
self.execute_water_mask(encoder, t, color, depth, mask);
}
let terrain_depth = water_mask.map_or(depth, |(_, d)| &d.view);
let [c0, c1] = &t.views.coefficients;
let culled = &t.views.culled;
for (stage, label, group, first, then) in [
(
Stage::DepthBounds,
"OIT depth bounds",
&t.alone,
vec![attachment(
&t.views.bounds,
wgpu::LoadOp::Clear(BOUNDS_CLEAR),
)],
// The clouds' bounds join the culled ones.
vec![attachment(culled, load)],
),
(
Stage::Transmittance,
"OIT transmittance",
&t.sampled,
vec![attachment(c0, clear), attachment(c1, clear)],
vec![attachment(c0, load), attachment(c1, load)],
),
(
Stage::Accumulate,
"OIT accumulate",
&t.accumulate,
vec![attachment(&t.views.accumulated, clear)],
vec![attachment(&t.views.accumulated, load)],
),
] {
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &first,
depth_stencil_attachment: read_only(terrain_depth),
..Default::default()
});
pass.set_bind_group(GROUP, group, &[]);
draw(&mut pass, stage);
}
if let Some(features) = extras.features {
// Over the world's own depth, even where a water mask patched
// the terrain's: the masks only hide water.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[attachment(
match stage {
Stage::DepthBounds => &t.views.bounds,
Stage::Transmittance => c0,
Stage::Accumulate => &t.views.accumulated,
},
load,
)]
.into_iter()
.chain((stage == Stage::Transmittance).then(|| attachment(c1, load)))
.collect::<Vec<_>>(),
depth_stencil_attachment: read_only(depth),
..Default::default()
});
pass.set_bind_group(GROUP, group, &[]);
features(&mut pass, stage);
}
if stage == Stage::DepthBounds {
self.execute_depth_bounds_cull(encoder, t, depth);
}
if let Some((clouds, cloud_depth)) = cloud_depth {
self.render_clouds(
encoder,
t,
stage,
depth,
(label, group, &then),
(clouds, cloud_depth),
);
}
if stage == Stage::DepthBounds
&& let Some((mask, _)) = water_mask
{
self.execute_water_mask(encoder, t, color, depth, mask);
}
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT composite"),
color_attachments: &[attachment(color, wgpu::LoadOp::Load)],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&self.composite);
pass.set_bind_group(GROUP, &t.composite, &[]);
pass.draw(0..3, 0..1);
}
/// `CloudRenderer.renderOit` for one stage. The bounds stage first copies
/// the world's depth (after the cull) and draws into it, so the later
/// stages, which only read it, keep each pixel's nearest cloud face.
fn render_clouds(
&self,
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
stage: Stage,
depth: &wgpu::TextureView,
(label, group, targets): (
&str,
&wgpu::BindGroup,
&[Option<wgpu::RenderPassColorAttachment<'_>>],
),
(draw, cloud_depth): (
&dyn Fn(&mut wgpu::RenderPass<'_>, &wgpu::RenderPipeline),
&Depth,
),
) {
if stage == Stage::DepthBounds {
encoder.copy_texture_to_texture(
depth.texture().as_image_copy(),
cloud_depth.texture.as_image_copy(),
extent(t.size),
);
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: targets,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &cloud_depth.view,
depth_ops: (stage == Stage::DepthBounds).then_some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_bind_group(GROUP, group, &[]);
draw(&mut pass, &self.clouds[stage as usize]);
}
/// `LevelRenderer.executeOitWaterMask`: the terrain's depth is the world's
/// again, with the boats' water patches on top.
fn execute_water_mask(
&self,
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
mask: &dyn Fn(&mut wgpu::RenderPass<'_>),
) {
let Some(terrain) = &t.terrain_depth else {
return;
};
encoder.copy_texture_to_texture(
depth.texture().as_image_copy(),
terrain.texture.as_image_copy(),
extent(t.size),
);
// The mask pipeline writes depth only but has a colour target.
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT water mask"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &terrain.view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
mask(&mut pass);
}
/// `LevelRenderer.executeDepthBoundsCull`: the bounds are copied, then where
/// the nearest fully opaque see-through fragment is in front of the world,
/// the far bound stops there and its depth replaces the world's.
fn execute_depth_bounds_cull(
&self,
encoder: &mut wgpu::CommandEncoder,
t: &Targets,
depth: &wgpu::TextureView,
) {
encoder.copy_texture_to_texture(
t.bounds.as_image_copy(),
t.culled.as_image_copy(),
wgpu::Extent3d {
width: t.size[0].max(1),
height: t.size[1].max(1),
depth_or_array_layers: 1,
},
);
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("OIT cull depth bounds"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &t.views.culled,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&self.cull);
pass.set_bind_group(GROUP, &t.cull, &[]);
pass.draw(0..3, 0..1);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn validate(source: &str, entries: &[&str]) {
let module = naga::front::wgsl::parse_str(source).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
for entry in entries {
assert!(
module.entry_points.iter().any(|e| e.name == *entry),
"{entry}"
);
}
}
#[test]
fn the_terrain_shader_validates() {
validate(
&(include_str!("../terrain.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_world.wgsl")),
&[
"fs_oit_bounds",
"fs_oit_transmittance",
"fs_oit_accumulate",
"fs_oit_cull",
"fs_oit_composite",
],
);
}
#[test]
fn the_cloud_shader_validates() {
validate(
&(super::super::entity_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_clouds.wgsl")),
&[
"fs_oit_cloud_bounds",
"fs_oit_cloud_transmittance",
"fs_oit_cloud_accumulate",
],
);
}
#[test]
fn the_feature_shaders_validate() {
let stages = |name: &str| {
["bounds", "transmittance", "accumulate"].map(|s| format!("fs_oit_{name}_{s}"))
};
let entity = ["entity", "shadow", "weather", "swirl"]
.map(stages)
.concat();
validate(
&(super::super::entity_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_features.wgsl")),
&entity.iter().map(String::as_str).collect::<Vec<_>>(),
);
let border = stages("border");
validate(
&(include_str!("../border.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl")),
&border.iter().map(String::as_str).collect::<Vec<_>>(),
);
}
#[test]
fn a_device_depth_gives_the_distance_along_the_view_axis() {
// The world's reversed-Z projection: near plane at depth 1.
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
let [x, y] = projection_terms(&projection);
for distance in [0.1_f32, 1.0, 7.5, 64.0, 500.0] {
let clip = projection * glam::Vec4::new(0.0, 0.0, -distance, 1.0);
let device = clip.z / clip.w;
let linear = y / (device + x);
assert!(
(linear - distance).abs() < distance * 2e-3,
"{distance}: {linear}"
);
}
}
}
@@ -0,0 +1,248 @@
//! Improved transparency for what is drawn after the opaque world besides the
//! terrain and the clouds: `LevelRenderer.executeOit`'s "Features, World
//! Border, Weather" pass. Each kind keeps its classic vertex shader, vertex
//! format and depth state (minus the depth writes, as every `OIT_*` pipeline),
//! with the three stages' fragments (`oit_features.wgsl`, `oit_border.wgsl`).
use super::oit::{Stage, stage_targets};
/// One pipeline per stage.
type Set = [wgpu::RenderPipeline; 3];
pub struct Features {
/// `OIT_ENTITY`/`OIT_ITEM`: translucent items and particles.
pub entity: Set,
/// `OIT_TEXT_POLYGON_OFFSET`.
pub text: Set,
/// `OIT_CRUMBLING`.
pub crumbling: Set,
/// `OIT_ENTITY_SHADOW`.
pub shadow: Set,
/// `OIT_ENERGY_SWIRL`.
pub swirl: Set,
/// `OIT_LIGHTNING`: the retained bolts.
pub lightning: Set,
/// `OIT_DRAGON_RAYS`: the additive geometry after the bolts.
pub additive: Set,
/// `OIT_WEATHER`.
pub weather: Set,
/// `OIT_WORLD_BORDER`.
pub border: Set,
}
/// What a kind differs in.
struct Kind<'a> {
label: &'a str,
/// The entry points' common prefix: `fs_oit_<name>_<stage>`.
name: &'a str,
vertex: &'a str,
cull: Option<wgpu::Face>,
bias: wgpu::DepthBiasState,
}
const PLAIN: wgpu::DepthBiasState = wgpu::DepthBiasState {
constant: 0,
slope_scale: 0.0,
clamp: 0.0,
};
impl Features {
/// `layouts`: `Oit`'s stage groups (at group 2); `entity`, `weather` and
/// `border` the bind group layouts of the classic pipelines.
pub fn new(
device: &wgpu::Device,
layouts: &[wgpu::BindGroupLayout; 4],
entity: &wgpu::BindGroupLayout,
weather: &wgpu::BindGroupLayout,
border: &wgpu::BindGroupLayout,
) -> Self {
let entity_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency features"),
source: wgpu::ShaderSource::Wgsl(
(super::entity_shader()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_features.wgsl"))
.into(),
),
});
let border_module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon improved transparency world border"),
source: wgpu::ShaderSource::Wgsl(
(include_str!("../border.wgsl").to_owned()
+ include_str!("../oit.wgsl")
+ include_str!("../oit_border.wgsl"))
.into(),
),
});
let entity_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Uint32,3=>Unorm8x4,4=>Uint8x4,5=>Snorm8x4,6=>Float32x2];
let weather_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Float32x4,
3=>Float32x4,4=>Uint32x4,5=>Unorm8x4,6=>Uint8x4,7=>Uint32];
let border_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2];
let entity_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &entity_attributes,
})];
let weather_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<crate::weather::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &weather_attributes,
})];
let border_buffers = [Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<crate::border_render::BorderVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &border_attributes,
})];
let lightning_attributes = wgpu::vertex_attr_array![7=>Float32x3];
let lightning_buffers = [
entity_buffers[0].clone(),
Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<[f32; 3]>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &lightning_attributes,
}),
];
let set = |module: &wgpu::ShaderModule,
groups: [Option<&wgpu::BindGroupLayout>; 2],
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
kind: Kind<'_>| {
[Stage::DepthBounds, Stage::Transmittance, Stage::Accumulate].map(|stage| {
let suffix = ["bounds", "transmittance", "accumulate"][stage as usize];
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Improved transparency features"),
bind_group_layouts: &[groups[0], groups[1], Some(&layouts[stage as usize])],
..Default::default()
});
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&format!("OIT {} {suffix}", kind.label)),
layout: Some(&layout),
vertex: wgpu::VertexState {
module,
entry_point: Some(kind.vertex),
compilation_options: Default::default(),
buffers,
},
primitive: wgpu::PrimitiveState {
cull_mode: kind.cull,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: kind.bias,
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module,
entry_point: Some(&format!("fs_oit_{}_{suffix}", kind.name)),
compilation_options: Default::default(),
targets: &stage_targets(stage),
}),
multiview_mask: None,
cache: None,
})
})
};
let entities = [Some(entity), None];
let kind = |label, name, cull, bias| Kind {
label,
name,
vertex: "vs_entity",
cull,
bias,
};
// The classic pipelines' biases: text and cracks (10, 1), shadows
// (128 ulps and a slope, over the block tops they lie on).
let offset = wgpu::DepthBiasState {
constant: 10,
slope_scale: 1.0,
clamp: 0.0,
};
let shadow_bias = wgpu::DepthBiasState {
constant: 128,
slope_scale: 0.015,
clamp: 0.0,
};
Self {
entity: set(
&entity_module,
entities,
&entity_buffers,
kind("entities", "entity", None, PLAIN),
),
text: set(
&entity_module,
entities,
&entity_buffers,
kind("text", "entity", None, offset),
),
crumbling: set(
&entity_module,
entities,
&entity_buffers,
kind("breaking cracks", "entity", Some(wgpu::Face::Back), offset),
),
shadow: set(
&entity_module,
entities,
&entity_buffers,
kind("entity shadows", "shadow", None, shadow_bias),
),
swirl: set(
&entity_module,
entities,
&entity_buffers,
kind("energy swirls", "swirl", None, PLAIN),
),
lightning: set(
&entity_module,
entities,
&lightning_buffers,
Kind {
label: "lightning",
name: "lightning",
vertex: "vs_lightning",
cull: None,
bias: PLAIN,
},
),
additive: set(
&entity_module,
entities,
&entity_buffers,
kind("additive entities", "lightning", None, PLAIN),
),
weather: set(
&entity_module,
[Some(entity), Some(weather)],
&weather_buffers,
Kind {
label: "weather",
name: "weather",
vertex: "vs_weather",
cull: None,
bias: PLAIN,
},
),
// `OIT_WORLD_BORDER`: a polygon offset of 3 towards the camera.
border: set(
&border_module,
[Some(border), None],
&border_buffers,
Kind {
label: "world border",
name: "border",
vertex: "vs_border",
cull: None,
bias: wgpu::DepthBiasState {
constant: 3,
slope_scale: 3.0,
clamp: 0.0,
},
},
),
}
}
}
@@ -349,7 +349,10 @@ impl PackEntities {
bytes[0..4].copy_from_slice(&(span.stage as i32).to_le_bytes());
bytes[4..8].copy_from_slice(&entity_id.to_le_bytes());
bytes[8..12].copy_from_slice(&block_id.to_le_bytes());
for (i, c) in entity_color(run[0].layer).iter().enumerate() {
for (i, c) in entity_color(run[0].layer & !super::entity_batch::OIT_ENTITY)
.iter()
.enumerate()
{
bytes[16 + 4 * i..20 + 4 * i].copy_from_slice(&c.to_le_bytes());
}
self.uniforms.extend_from_slice(&bytes);
@@ -408,9 +411,12 @@ pub(super) fn quad_indices(device: &wgpu::Device, quads: u32) -> wgpu::Buffer {
/// celestial body, cloud, beam, End portal, crack or energy swirl (overlay codes Horizon's entity
/// shader draws in its own way; entity.wgsl).
fn plain(run: &[Vertex]) -> bool {
run.iter()
.step_by(4)
.all(|v| !matches!(v.layer, 2 | 4 | 6 | 9 | 10 | 11 | 12 | 34))
run.iter().step_by(4).all(|v| {
!matches!(
v.layer & !super::entity_batch::OIT_ENTITY,
2 | 4 | 6 | 9 | 10 | 11 | 12 | 34
)
})
}
/// The programs of entity runs, in the order they draw.
@@ -203,9 +203,9 @@ fn body(angle: f32, tilt: f32) -> Vec3 {
}
/// `sunPathRotation`'s turn of the sky's bodies: `tilt` degrees about the world's X axis, so a
/// positive angle moves the noon sun toward +Z (south).
/// positive angle moves the noon sun toward -Z (north), as Iris does.
pub fn tilt_matrix(tilt: f32) -> Mat4 {
Mat4::from_rotation_x(tilt.to_radians())
Mat4::from_rotation_x(-tilt.to_radians())
}
/// The world direction of the shadow light (7.1): the sun by day, the moon by night.
@@ -232,38 +232,39 @@ pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
depth * gl * view
}
/// Distance from the snapped point to the shadow camera, toward the light: the middle of the
/// default near and far planes (OQ-6).
const SHADOW_EYE: f64 = 128.0;
/// How far toward the light the shadow projection starts, in blocks: part of the projection,
/// not of `shadowModelView` (Iris's matrices, measured).
const SHADOW_EYE: f32 = 100.0;
/// The shadow camera (7.1; OQ-6, impl-plan.md): its view (player space to shadow view space)
/// and GL projection. The camera looks from the light, world +Z up (+Y when the light is near
/// Z); the point it centres moves in steps of `shadowIntervalSize` along the shadow view's
/// own axes, and it sits `SHADOW_EYE` blocks toward the light from it. Orthographic over
/// `shadowDistance` each side, or perspective with `shadowMapFov`.
/// The shadow camera as Iris places it (7.1; measured from the outside): its view (player
/// space to shadow view space) and GL projection. The view undoes `sunPathRotation`, turns the
/// light's path about Z by the light's angle from the zenith, then X by 90 degrees, so the
/// light lies on the view's +Z. Its origin is `shadowIntervalSize / 2 - camera % interval`
/// (the remainder takes the camera's sign) from the player on each world axis, so the map moves
/// in whole steps. The orthographic projection covers `shadowDistance` each side and depths
/// from `-near` to `far`, counted from `SHADOW_EYE` blocks toward the light; or perspective
/// with `shadowMapFov`.
pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4) {
let light = light.as_dvec3();
let up = if light.z.abs() > 0.99 {
DVec3::Y
} else {
DVec3::Z
};
let rotation = DMat4::look_to_rh(DVec3::ZERO, -light, up);
let at = rotation.transform_vector3(camera);
let tilt = f64::from(shadow.sun_path_rotation).to_radians();
let untilted = DMat4::from_rotation_x(tilt).transform_vector3(light.as_dvec3());
let zenith = untilted.x.atan2(untilted.y);
let rotation = DMat4::from_rotation_x(std::f64::consts::FRAC_PI_2)
* DMat4::from_rotation_z(zenith)
* DMat4::from_rotation_x(tilt);
let step = f64::from(shadow.interval);
let snapped = if step > 0.0 {
(at / step).floor() * step
let origin = if step > 0.0 {
DVec3::splat(step / 2.0) - camera % step
} else {
at
DVec3::ZERO
};
let offset = at - snapped - DVec3::new(0.0, 0.0, SHADOW_EYE);
let view = (DMat4::from_translation(offset) * rotation).as_mat4();
let view = (rotation * DMat4::from_translation(-origin)).as_mat4();
let (near, far) = (shadow.near, shadow.far);
let eye = Mat4::from_translation(Vec3::new(0.0, 0.0, -SHADOW_EYE));
let projection = match shadow.fov {
Some(fov) => Mat4::perspective_rh_gl(fov.to_radians(), 1.0, near, far),
Some(fov) => Mat4::perspective_rh_gl(fov.to_radians(), 1.0, near, far) * eye,
None => {
let d = shadow.distance;
Mat4::orthographic_rh_gl(-d, d, -d, d, near, far)
Mat4::orthographic_rh_gl(-d, d, -d, d, -near, far) * eye
}
};
(view, projection)
@@ -360,7 +361,8 @@ impl FrameUniforms {
"viewHeight" => Value::f(&[size[1] as f32]),
"aspectRatio" => Value::f(&[aspect]),
"near" => Value::f(&[0.05]),
"far" => Value::f(&[frame.distance]),
// The chunk render distance, not the distant terrain's reach.
"far" => Value::f(&[frame.detail_distance]),
"rainStrength" => Value::f(&[inputs.rain]),
"wetness" => Value::f(&[self.wetness]),
"thunderStrength" => Value::f(&[inputs.thunder]),
@@ -486,8 +488,8 @@ mod tests {
fn noon_is_straight_up_and_the_sun_sets_in_the_west() {
assert!(body(0.0, 0.0).abs_diff_eq(Vec3::new(0.0, 100.0, 0.0), 1e-3));
assert!(body(90.0, 0.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3));
// sunPathRotation tilts noon toward +Z and leaves sunset on the horizon.
assert!(body(0.0, 30.0).abs_diff_eq(Vec3::new(0.0, 86.60254, 50.0), 1e-3));
// sunPathRotation tilts noon toward -Z and leaves sunset on the horizon.
assert!(body(0.0, 30.0).abs_diff_eq(Vec3::new(0.0, 86.60254, -50.0), 1e-3));
assert!(body(90.0, 30.0).abs_diff_eq(Vec3::new(-100.0, 0.0, 0.0), 1e-3));
}
@@ -500,10 +502,13 @@ mod tests {
let at = |p: Vec3| view.transform_point3(p);
// Straight down: a block below the player is farther from the light.
assert!(at(Vec3::new(0.0, -1.0, 0.0)).z < at(Vec3::ZERO).z);
// The player is less than a step from the centre, 128 blocks from the light.
let centre = at(Vec3::ZERO);
assert!(centre.x.abs() <= 2.0 && centre.y.abs() <= 2.0, "{centre}");
assert!((centre.z + 128.0).abs() < 1e-3, "{centre}");
// The view's origin is interval / 2 - camera % interval from the player on each axis,
// the remainder with the camera's sign: up to a step and a half on negative axes.
let origin = view.inverse().w_axis.truncate();
assert!(
origin.abs_diff_eq(Vec3::new(0.7, 1.0, 1.7), 1e-3),
"{origin}"
);
// Moving within a step keeps the world where it was in the map.
camera.x = 1000.6;
let (moved, _) = shadow_camera(Vec3::Y, camera, &shadow);
@@ -515,7 +520,38 @@ mod tests {
.abs_diff_eq(at(world), 1e-3)
);
// shadowDistance blocks reach the map's edge.
let edge = projection.project_point3(Vec3::new(160.0, 0.0, -128.0));
let edge = projection.project_point3(Vec3::new(160.0, 0.0, 0.0));
assert!((edge.x.abs() - 1.0).abs() < 1e-4, "{edge}");
}
/// Iris's matrices at 3000 ticks with `sunPathRotation = 30` and the camera at
/// (0.5, -58.38, 0.5), read from its uniforms by a probe pack.
#[test]
fn the_shadow_camera_matches_iris() {
let shadow = Shadow {
sun_path_rotation: 30.0,
..Shadow::default()
};
let light = Vec3::new(0.56782, 0.71287, -0.41158).normalize();
let (view, projection) = shadow_camera(light, DVec3::new(0.5, -58.38, 0.5), &shadow);
let iris_view = Mat4::from_cols_array_2d(&[
[0.82315, 0.0, 0.56782, 0.0],
[-0.49175, -0.5, 0.71287, 0.0],
[0.28391, -0.86603, -0.41158, 0.0],
[0.12508, 1.12301, -1.06188, 1.0],
]);
assert!(view.abs_diff_eq(iris_view, 2e-4), "{view}");
assert!(
(projection.z_axis.z + 0.0078110).abs() < 1e-6,
"{projection}"
);
assert!((projection.w_axis.z + 0.21851).abs() < 1e-4, "{projection}");
// Elsewhere the origin keeps to the steps: Iris put it at (-0.5, 1.38, 2.5) here.
let (view, _) = shadow_camera(light, DVec3::new(3.5, -58.38, -1.5), &shadow);
let origin = view.inverse().w_axis.truncate();
assert!(
origin.abs_diff_eq(Vec3::new(-0.5, 1.38, 2.5), 1e-3),
"{origin}"
);
}
}
+251 -16
View File
@@ -30,6 +30,7 @@ pub struct Resident<T> {
/// The minimum Y of section index 0 (the level's column).
min_y: i32,
items: Vec<(SectionKey, T)>,
bounds: Vec<Option<[Vec3; 2]>>,
slots: FxHashMap<SectionKey, u32>,
columns: Vec<Column>,
column_index: FxHashMap<(i32, i32), u32>,
@@ -40,6 +41,7 @@ impl<T> Default for Resident<T> {
Self {
min_y: crate::chunk::MIN_Y,
items: Vec::new(),
bounds: Vec::new(),
slots: FxHashMap::default(),
columns: Vec::new(),
column_index: FxHashMap::default(),
@@ -78,18 +80,26 @@ impl<T> Resident<T> {
}
pub fn clear(&mut self) {
self.items.clear();
self.bounds.clear();
self.slots.clear();
self.columns.clear();
self.column_index.clear();
}
/// Adds or replaces `key`'s value.
/// Adds or replaces `key`'s value with default 16x16x16 bounds.
#[cfg(test)]
pub fn insert(&mut self, key: SectionKey, value: T) {
self.insert_with_bounds(key, value, None);
}
/// Adds or replaces `key`'s value with optional tight geometry bounds.
pub fn insert_with_bounds(&mut self, key: SectionKey, value: T, bounds: Option<[Vec3; 2]>) {
if let Some(&slot) = self.slots.get(&key) {
self.items[slot as usize].1 = value;
self.bounds[slot as usize] = bounds;
return;
}
let slot = self.items.len() as u32;
self.items.push((key, value));
self.bounds.push(bounds);
self.slots.insert(key, slot);
let column = match self.column_index.get(&(key.0, key.2)) {
Some(&c) => &mut self.columns[c as usize],
@@ -129,6 +139,7 @@ impl<T> Resident<T> {
} else {
column.bounds();
}
let _ = self.bounds.swap_remove(slot as usize);
let (_, value) = self.items.swap_remove(slot as usize);
// The last item now sits in `slot`: point its key and column at it.
if let Some(&(moved, _)) = self.items.get(slot as usize) {
@@ -175,31 +186,57 @@ fn camera_column(camera: DVec3) -> (i32, i32) {
)
}
/// The world AABB (min, max) of a section from its origin and optional tight bounds.
#[inline]
fn section_box(min: Vec3, bounds: Option<[Vec3; 2]>) -> (Vec3, Vec3) {
match bounds {
Some([bmin, bmax]) => (min + bmin, min + bmax),
None => (min, min + Vec3::splat(16.0)),
}
}
/// Whether the section at `key` is drawn: its column within the render
/// distance of the camera's (`SectionOcclusionGraph.isInViewDistance`), and
/// partly inside the frustum. `None` when it is not, else its squared centre
/// distance and origin. `Resident::visible` walks columns instead.
#[cfg(test)]
fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Option<(f64, Vec3)> {
in_view_with_bounds(key, camera, radius, frustum, None)
}
/// `in_view` with optional tight bounds.
#[cfg(test)]
fn in_view_with_bounds(
key: SectionKey,
camera: DVec3,
radius: i32,
frustum: &Frustum,
bounds: Option<[Vec3; 2]>,
) -> Option<(f64, Vec3)> {
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
return None;
}
in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false)
in_frustum(key, crate::chunk::MIN_Y, camera, frustum, false, bounds)
}
/// `in_view` without the distance test; `inside`: the section's column is
/// entirely inside the frustum, so the section is too.
#[cfg(test)]
fn in_frustum(
key: SectionKey,
min_y: i32,
camera: DVec3,
frustum: &Frustum,
inside: bool,
bounds: Option<[Vec3; 2]>,
) -> Option<(f64, Vec3)> {
let origin = origin(key, min_y, camera);
let min = origin.as_vec3();
if !inside && !frustum.intersects(min, min + Vec3::splat(16.0)) {
return None;
if !inside {
let (bmin, bmax) = section_box(min, bounds);
if !frustum.intersects(bmin, bmax) {
return None;
}
}
Some(((origin + DVec3::splat(8.0)).length_squared(), min))
}
@@ -239,17 +276,50 @@ impl<T> Resident<T> {
if !inside && !frustum.intersects(min, max) {
continue;
}
for &(y, slot) in &column.sections {
let key = (column.x, y, column.z);
if near && !reachable(key) {
continue;
}
if let Some((d, o)) = in_frustum(key, self.min_y, camera, frustum, inside) {
let ox = f64::from(column.x * 16) - camera.x;
let oz = f64::from(column.z * 16) - camera.z;
let min_x = ox as f32;
let min_z = oz as f32;
let dx8 = ox + 8.0;
let dz8 = oz + 8.0;
let xz_sq = dx8 * dx8 + dz8 * dz8;
if inside {
for &(y, slot) in &column.sections {
let key = (column.x, y, column.z);
if near && !reachable(key) {
continue;
}
let oy = f64::from(self.min_y + y * 16) - camera.y;
let min = Vec3::new(min_x, oy as f32, min_z);
let dy8 = oy + 8.0;
let distance = xz_sq + dy8 * dy8;
out.push(Visible {
distance: d.to_bits(),
distance: distance.to_bits(),
key,
slot,
origin: o.to_array(),
origin: min.to_array(),
});
}
} else {
for &(y, slot) in &column.sections {
let key = (column.x, y, column.z);
if near && !reachable(key) {
continue;
}
let oy = f64::from(self.min_y + y * 16) - camera.y;
let min = Vec3::new(min_x, oy as f32, min_z);
let (bmin, bmax) = section_box(min, self.bounds[slot as usize]);
if !frustum.intersects(bmin, bmax) {
continue;
}
let dy8 = oy + 8.0;
let distance = xz_sq + dy8 * dy8;
out.push(Visible {
distance: distance.to_bits(),
key,
slot,
origin: min.to_array(),
});
}
}
@@ -271,7 +341,15 @@ mod tests {
for y in 0..24 {
// Leave holes so columns have uneven bounds.
if (x * 7 + y * 3 + z * 5).rem_euclid(4) != 0 {
r.insert((x, y, z), ());
// Mix of sections with fallback None and randomized tight bounds.
let bounds = if (x + y + z).rem_euclid(2) == 0 {
let y_min = ((x + z).rem_euclid(4)) as f32;
let y_max = y_min + 2.0 + ((y.rem_euclid(5)) as f32);
Some([Vec3::new(0.0, y_min, 0.0), Vec3::new(16.0, y_max, 16.0)])
} else {
None
};
r.insert_with_bounds((x, y, z), (), bounds);
}
}
}
@@ -297,12 +375,21 @@ mod tests {
let mut slow: Vec<(SectionKey, f64, [f32; 3])> = r
.items
.iter()
.filter_map(|&(k, _)| {
.enumerate()
.filter_map(|(slot, &(k, _))| {
let near = in_view_distance(camera_column(camera), radius, k.0, k.2);
if near {
in_view(k, camera, radius, &frustum).filter(|_| reachable(k))
in_view_with_bounds(k, camera, radius, &frustum, r.bounds[slot])
.filter(|_| reachable(k))
} else if beyond(k.0, k.2) {
in_frustum(k, crate::chunk::MIN_Y, camera, &frustum, false)
in_frustum(
k,
crate::chunk::MIN_Y,
camera,
&frustum,
false,
r.bounds[slot],
)
} else {
None
}
@@ -471,4 +558,152 @@ mod tests {
list.len()
);
}
#[test]
fn tight_mesh_bounds_culls_section_outside_frustum() {
let mut r = Resident::default();
let key = (0, 0, 0);
// Section geometry only occupies local Y in [0.0, 2.0]
let tight = Some([Vec3::new(0.0, 0.0, 0.0), Vec3::new(16.0, 2.0, 16.0)]);
r.insert_with_bounds(key, (), tight);
let camera = DVec3::new(8.0, f64::from(crate::chunk::MIN_Y + 15), -2.0);
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
let view = crate::camera::view_matrix(0.0, -60.0);
let frustum = Frustum {
planes: super::super::frustum(projection * view),
};
// Tight bounds culls the section whose geometry is entirely outside the frustum
let mut out = Vec::new();
r.visible(camera, 4, &frustum, |_| true, |_, _| false, &mut out);
assert!(
out.is_empty(),
"tight bounds should cull section whose geometry is below frustum"
);
// With fallback 16x16x16 bounds, the section intersects the frustum and is visible
r.insert_with_bounds(key, (), None);
out.clear();
r.visible(camera, 4, &frustum, |_| true, |_, _| false, &mut out);
assert_eq!(
out.len(),
1,
"full 16x16x16 bounds intersect frustum and are accepted"
);
}
#[test]
#[ignore = "benchmark"]
fn tight_bounds_benchmark() {
use std::time::Instant;
let mut r_fallback = Resident::default();
let mut r_tight = Resident::default();
let radius = 16;
for x in -radius..=radius {
for z in -radius..=radius {
// Section 0..4: underground rock
for y in 0..4 {
r_fallback.insert((x, y, z), ());
r_tight.insert_with_bounds((x, y, z), (), None);
}
// Section 4: surface terrain with grass / hills (e.g. Y in [0, 4] or [0, 6])
let height = 2.0 + (((x * 3 + z * 5).rem_euclid(5)) as f32) * 1.5;
let surface_bounds =
Some([Vec3::new(0.0, 0.0, 0.0), Vec3::new(16.0, height, 16.0)]);
r_fallback.insert((x, 4, z), ());
r_tight.insert_with_bounds((x, 4, z), (), surface_bounds);
}
}
println!("\n=== Tight Bounds Culling Benchmark ===");
println!("Total Resident Sections: {}", r_tight.len());
let test_views = [
("Looking level across terrain (pitch 0°)", 0.0, 0.0),
("Looking slightly up (pitch -15°)", 45.0, -15.0),
("Looking up towards sky (pitch -35°)", 90.0, -35.0),
("Looking down at ground (pitch 25°)", 180.0, 25.0),
("Diagonal view (yaw 135°, pitch -10°)", 135.0, -10.0),
];
let camera = DVec3::new(8.0, f64::from(crate::chunk::MIN_Y + 4 * 16 + 10), 8.0);
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
for (desc, yaw, pitch) in test_views {
let view = crate::camera::view_matrix(yaw, pitch);
let frustum = Frustum {
planes: super::super::frustum(projection * view),
};
let mut out_fallback = Vec::new();
let mut out_tight = Vec::new();
r_fallback.visible(
camera,
radius,
&frustum,
|_| true,
|_, _| false,
&mut out_fallback,
);
r_tight.visible(
camera,
radius,
&frustum,
|_| true,
|_, _| false,
&mut out_tight,
);
let culled_extra = out_fallback.len().saturating_sub(out_tight.len());
let pct_saved = if !out_fallback.is_empty() {
(culled_extra as f64 / out_fallback.len() as f64) * 100.0
} else {
0.0
};
// Timing over 1,000 passes
let iters = 1_000;
let t0 = Instant::now();
for _ in 0..iters {
out_fallback.clear();
r_fallback.visible(
camera,
radius,
&frustum,
|_| true,
|_, _| false,
&mut out_fallback,
);
}
let dur_fallback = t0.elapsed();
let t1 = Instant::now();
for _ in 0..iters {
out_tight.clear();
r_tight.visible(
camera,
radius,
&frustum,
|_| true,
|_, _| false,
&mut out_tight,
);
}
let dur_tight = t1.elapsed();
println!(
"{desc}\n Fallback 16x16: {} sections | {:?}\n Tight Bounds: {} sections | {:?}\n Reduction: -{} sections ({:.1}% culled)\n",
out_fallback.len(),
dur_fallback / iters,
out_tight.len(),
dur_tight / iters,
culled_extra,
pct_saved
);
}
}
}
@@ -55,9 +55,9 @@ mod tests {
let n = |i: usize| Vec3::from_slice(&normals[i][..3]);
let mut best = 0.0f32;
let mut k0 = d.dot(n(TRIANGLES));
for i in 0..TRIANGLES {
for (i, triangle) in normals.iter().take(TRIANGLES).enumerate() {
let k1 = d.dot(n(TRIANGLES + (i + 1) % TRIANGLES));
let w = normals[i][3];
let w = triangle[3];
if k0 * w <= 0.0 && k1 * w >= 0.0 {
let apex = d.dot(n(i));
let s = apex + k1 - k0;
@@ -75,6 +75,115 @@ mod tests {
best
}
// Projecting the fan onto the YZ plane leaves uniformly spaced rim
// angles after undoing its Y squash. Test both ray directions because
// the projection plane goes through the camera.
fn sector_weight(alpha: f32, sun: Vec3, d: Vec3) -> f32 {
let normals = normals(alpha, sun);
if d.y * d.y + d.z * d.z < 1e-10 {
return weight(&normals, d);
}
let n = |i: usize| Vec3::from_slice(&normals[i][..3]);
let apex = d.dot(n(0));
if apex * normals[0][3] < -1e-4 {
return 0.0;
}
let y = d.dot(n(16));
let x = -d.dot(n(20));
let ax = x.abs();
let ay = y.abs();
let t = std::f32::consts::SQRT_2 - 1.0;
let q = if ay > ax {
if ax < ay * t { 3 } else { 2 }
} else if ay > ax * t {
1
} else {
0
};
let sector = if x >= 0.0 {
if y >= 0.0 { q } else { 15 - q }
} else if y >= 0.0 {
7 - q
} else {
8 + q
};
let mut best = 0.0f32;
let k0 = d.dot(n(16 + sector as usize));
let k1 = d.dot(n(16 + ((sector + 1) % 16) as usize));
let offsets: &[i32] = if k0.abs().min(k1.abs()) < ax.max(ay) * 0.001 {
&[0, 8, -1, 1, 7, 9]
} else {
&[0, 8]
};
for &offset in offsets {
let i = (sector + offset).rem_euclid(16) as usize;
let k0 = d.dot(n(16 + i));
let k1 = d.dot(n(16 + (i + 1) % 16));
let w = normals[i][3];
let apex = d.dot(n(i));
let sum = apex + k1 - k0;
if sum.abs() >= 1e-6
&& sum * w > 0.0
&& apex * sum >= 0.0
&& k1 * sum >= 0.0
&& k0 * sum <= 0.0
{
best = best.max(apex / sum);
}
}
best
}
#[test]
fn angular_sector_matches_random_and_exact_rim_directions() {
let mut state = 19u32;
let mut random = || {
state = state.wrapping_mul(1664525).wrapping_add(1013904223);
(state >> 8) as f32 / 16777216.0
};
for _ in 0..1000 {
let alpha = 0.0011 + random() * 0.9989;
let z = if random() > 0.5 { 0.3 } else { -0.2 };
let sun = Vec3::new(0.0, (1.0f32 - z * z).sqrt(), z);
let normals = normals(alpha, sun);
let (_, rim) = vertices(alpha, sun);
let random_ray = Vec3::new(
random() * 2.0 - 1.0,
random() * 2.0 - 1.0,
random() * 2.0 - 1.0,
)
.normalize();
for ray in rim.into_iter().chain([random_ray]) {
let d = ray.normalize();
let (got, want) = (sector_weight(alpha, sun, d), weight(&normals, d));
assert!(
(got - want).abs() < 1e-4,
"{alpha} {sun} {d}: {got} != {want}"
);
}
}
}
#[test]
fn angular_sector_matches_all_triangle_search() {
for alpha in [0.0011f32, 0.05, 0.6, 1.0] {
for z in [-0.2f32, 0.3] {
let sun = Vec3::new(0.0, (1.0 - z * z).sqrt(), z);
let normals = normals(alpha, sun);
for yaw in 0..360 {
for pitch in -89..90 {
let (y, p) = ((yaw as f32).to_radians(), (pitch as f32).to_radians());
let d = Vec3::new(y.cos() * p.cos(), p.sin(), y.sin() * p.cos());
let (got, want) = (sector_weight(alpha, sun, d), weight(&normals, d));
assert!(
(got - want).abs() < 1e-4,
"alpha={alpha} z={z} d={d} {got} != {want}"
);
}
}
}
}
}
/// The shader's former per-pixel Möller-Trumbore over the same vertices.
fn reference(alpha: f32, sun: Vec3, d: Vec3) -> f32 {
let (apex, rim) = vertices(alpha, sun);
@@ -124,6 +124,26 @@ impl Weather {
uploads.buffer(encoder, &self.vertices, 0, bytemuck::cast_slice(vertices));
uploads.buffer(encoder, &self.uniform, 0, bytemuck::bytes_of(&time));
}
/// The classic pipeline, for its layouts.
pub fn pipeline(&self) -> &wgpu::RenderPipeline {
&self.pipeline
}
/// `draw` with another pipeline of the same layouts and vertex format.
pub fn draw_with(
&self,
pass: &mut wgpu::RenderPass<'_>,
pipeline: &wgpu::RenderPipeline,
entities: &wgpu::BindGroup,
) {
if self.count == 0 {
return;
}
pass.set_pipeline(pipeline);
pass.set_bind_group(0, entities, &[]);
pass.set_bind_group(1, &self.group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.count / 4 * 6, 0, 0..1);
}
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, entities: &wgpu::BindGroup) {
if self.count == 0 {
return;
@@ -344,7 +344,7 @@ impl Post {
"Scaled world depth",
size,
wgpu::TextureFormat::Depth32Float,
sampled,
sampled | wgpu::TextureUsages::COPY_SRC,
)
.create_view(&Default::default());
let group = self.bind(device, &color);
+28 -7
View File
@@ -311,6 +311,8 @@ pub fn reduced_far_detail_suggested() -> bool {
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The name the options screen waits on to bind `Graphics::hdr_key`.
pub const HDR_KEY: &str = "horizon.hdrSettings";
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
/// opens its surface; the settings open HDR's screen only then.
@@ -332,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 {
@@ -391,6 +393,8 @@ pub struct Graphics {
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
/// first-person hand stays at the world's.
pub hdr_ui_brightness: u32,
/// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default.
pub hdr_key: String,
/// Battery mode: at most 60 frames per second in a world and 30 in menus
/// or behind other windows, and half the worker threads. Off by default;
/// it changes when frames are drawn, never what they show or the ticks.
@@ -429,6 +433,7 @@ impl Default for Graphics {
hdr_peak: 0,
hdr_black_level: 0,
hdr_ui_brightness: 100,
hdr_key: "key.keyboard.unknown".into(),
battery: false,
api: GraphicsApi::Auto,
shader_pack: String::new(),
@@ -467,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),
@@ -494,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" => {
@@ -507,6 +514,7 @@ impl Graphics {
"hdrUiBrightness" => {
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
}
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"batteryMode" => g.battery = flag(g.battery),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
@@ -524,7 +532,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -556,11 +564,20 @@ impl Graphics {
self.hdr_peak,
self.hdr_black_level,
self.hdr_ui_brightness,
self.hdr_key,
bool_text(self.battery),
self.api.name(),
self.shader_pack,
)
}
/// Binds Horizon's graphics key `name` (`HDR_KEY`); false for another name.
pub fn set_key(&mut self, name: &str, value: String) -> bool {
match name {
HDR_KEY => self.hdr_key = value,
_ => return false,
}
true
}
pub fn save(&self, dir: &Path) {
let _ = fs::write(dir.join(FILE), self.serialize());
}
@@ -691,6 +708,7 @@ mod tests {
hdr_peak: 1400,
hdr_black_level: 12,
hdr_ui_brightness: 60,
hdr_key: "key.keyboard.h".into(),
battery: true,
api: GraphicsApi::Dx12,
shader_pack: "Golden Hour".into(),
@@ -701,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());
@@ -758,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);
}
@@ -3,6 +3,7 @@
//! lay out rows and handle input; a menu theme replaces this module.
use crate::{
hud::{HudAtlas, HudBatch},
lang,
text::Text,
ui,
};
@@ -156,10 +157,11 @@ impl Style {
cost: Cost,
text: &str,
) {
let tag = Text::literal(cost.label());
let tag = Text::literal(lang::get(cost.label()));
batch.text_styled(atlas, &tag, x, y, self.cost_colors[cost as usize], true);
let indent = atlas.font.width_text(&tag) + 4.0;
let lines = wrap(atlas, text, self.text_width(batch.width), indent);
let text = lang::get(text);
let lines = wrap(atlas, &text, self.text_width(batch.width), indent);
for (i, line) in lines.iter().take(2).enumerate() {
let lx = if i == 0 { x + indent } else { x };
batch.text_styled(
+195 -41
View File
@@ -5,6 +5,7 @@ use crate::{
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset, Upscaling},
graphics_style::{Cost, STYLE},
hud::{HudAtlas, HudBatch},
lang,
settings::{Opt, Settings},
text::Text,
ui,
@@ -43,6 +44,10 @@ pub enum H {
/// Opens `Level::Hdr`.
HdrSettings,
HdrBlackLevel,
/// Binds `Graphics::hdr_key`.
HdrKey,
/// Starts HDR calibration (`crate::hdr_calibration`).
HdrCalibrate,
HdrPeak,
HdrPaperWhite,
HdrUiBrightness,
@@ -133,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,
@@ -153,6 +163,11 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"How bright the HUD and menus are, against paper white.",
),
h(
H::HdrKey,
None,
"A key that opens this screen from the game. Not bound at first.",
),
],
Level::Advanced => vec![
Row::Header("Rendering"),
@@ -331,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.
@@ -354,11 +391,54 @@ fn extra_rows(level: Level, settings: &Settings) -> Vec<Row> {
rows
}
fn on_off(on: bool) -> &'static str {
if on { "On" } else { "Off" }
/// 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" })
}
/// A control's caption and its value, both through the language table: Horizon's captions are
/// English sentences, and some of the values ("Off", "12 chunks") are words too.
fn labelled(caption: &str, value: impl std::fmt::Display) -> Text {
Text::literal(format!("{caption}: {value}"))
let value = value.to_string();
Text::literal(format!("{}: {}", lang::get(caption), lang::get(&value)))
}
impl H {
@@ -373,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,
@@ -396,19 +480,21 @@ impl H {
H::Preset => labelled("Preset", g.preset.label()),
H::LodDistance => match g.lod_distance {
0 => labelled("LOD Distance", "Off"),
n => labelled("LOD Distance", format!("{n} chunks")),
n => labelled("LOD Distance", lang::tr("%s chunks", &[&n])),
},
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
H::ChunkUpdates => match g.chunk_updates {
0 => labelled("Chunk Updates", "Unlimited"),
n => labelled("Chunk Updates", format!("{n} per frame")),
n => labelled("Chunk Updates", lang::tr("%s per frame", &[&n])),
},
H::UploadBudget => match g.upload_budget_kib {
0 => labelled("Upload Budget", "Unlimited"),
k if k >= 1024 => labelled("Upload Budget", format!("{} MiB/frame", k / 1024)),
k => labelled("Upload Budget", format!("{k} KiB/frame")),
k if k >= 1024 => {
labelled("Upload Budget", lang::tr("%s MiB/frame", &[&(k / 1024)]))
}
k => labelled("Upload Budget", lang::tr("%s KiB/frame", &[&k])),
},
H::Sky => labelled("Sky", on_off(g.sky)),
H::Fog => labelled("Fog", on_off(g.fog)),
@@ -423,7 +509,10 @@ impl H {
},
),
H::Workers => match g.workers {
0 => labelled("Worker Threads", format!("Auto ({})", g.worker_threads())),
0 => labelled(
"Worker Threads",
lang::tr("Auto (%s)", &[&g.worker_threads()]),
),
n => labelled("Worker Threads", n),
},
H::ResolutionScale => labelled("Resolution Scale", format!("{}%", g.resolution_scale)),
@@ -437,23 +526,37 @@ impl H {
),
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrSettings => Text::literal("HDR Settings..."),
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",
format!("Auto ({} nits)", g.paper_white_nits()),
lang::tr("Auto (%s nits)", &[&g.paper_white_nits()]),
),
n => labelled("Paper White", format!("{n} nits")),
n => labelled("Paper White", lang::tr("%s nits", &[&n])),
},
H::HdrPeak => match g.hdr_peak {
0 => labelled("Peak Brightness", format!("Auto ({} nits)", g.peak_nits())),
n => labelled("Peak Brightness", format!("{n} nits")),
0 => labelled(
"Peak Brightness",
lang::tr("Auto (%s nits)", &[&g.peak_nits()]),
),
n => labelled("Peak Brightness", lang::tr("%s nits", &[&n])),
},
H::HdrBlackLevel => labelled(
"Black Level",
format!("{:.2} nits", g.hdr_black_level as f32 / 100.0),
lang::tr(
"%s nits",
&[&format!("{:.2}", g.hdr_black_level as f32 / 100.0)],
),
),
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
H::HdrKey => {
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
}
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
H::Overlay => labelled(
@@ -474,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,
@@ -507,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)
}
@@ -579,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,
}
@@ -618,7 +720,7 @@ impl Item {
match self {
Item::Vanilla(Opt::RenderDistance) if s.horizon.lod_distance > 0 => labelled(
"Full Detail Distance",
format!("{} chunks", s.render_distance),
lang::tr("%s chunks", &[&s.render_distance]),
),
Item::Vanilla(o) => o.label(s),
Item::Horizon(h) => h.label(s),
@@ -686,6 +788,10 @@ pub enum Action {
OpenAdvanced,
OpenSimple,
OpenHdr,
/// Wait for a key or mouse button to bind to this name.
BindKey(&'static str),
/// Start HDR calibration.
Calibrate,
Done,
}
@@ -708,6 +814,8 @@ pub struct GraphicsUi {
/// The list's scroll amount and its held scrollbar (`AbstractScrollArea`).
scroll: ui::Scroll,
dragging: Option<Item>,
/// The key binding the options screen is waiting on (`Action::BindKey`).
pub binding: Option<&'static str>,
}
impl GraphicsUi {
@@ -818,7 +926,7 @@ impl GraphicsUi {
let x = STYLE.row_x(w);
for (y, name) in headers {
if shown(y, STYLE.header_height) {
STYLE.header(batch, atlas, &Text::literal(name), x, y);
STYLE.header(batch, atlas, &Text::literal(lang::get(name)), x, y);
}
}
let descriptions: Vec<(Cost, &'static str)> = extra_rows(level, s)
@@ -840,11 +948,17 @@ impl GraphicsUi {
if !shown(wd.y, 20.0) {
continue;
}
let label = item.label(s);
let mut label = item.label(s);
if item == Item::Horizon(H::HdrKey) && self.binding.is_some() {
// KeyBindsScreen: "> key <" while it waits.
label = Text::literal("Shortcut: > ")
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
.append(" <");
}
let blocked = item.blocked(s);
let live = blocked.is_none();
if hover && let Some(why) = blocked {
tooltip = Some(atlas.font.split(&Text::literal(why), 170.0));
tooltip = Some(atlas.font.split(&Text::literal(lang::get(why)), 170.0));
}
match item.slider(s) {
Some(value) => STYLE.slider(
@@ -872,7 +986,7 @@ impl GraphicsUi {
wd.x + wd.w + STYLE.gap,
wd.y + 1.0,
cost,
text,
description(item, s, text),
);
}
Target::Button(action) => {
@@ -885,7 +999,7 @@ impl GraphicsUi {
batch,
atlas,
(wd.x, wd.y, wd.w),
&Text::literal(label),
&Text::literal(lang::get(label)),
hover,
true,
);
@@ -907,7 +1021,7 @@ impl GraphicsUi {
Level::Advanced => "Graphics: Advanced",
Level::Hdr => "Graphics: HDR",
};
STYLE.title(batch, atlas, &Text::literal(title));
STYLE.title(batch, atlas, &Text::literal(lang::get(title)));
if let Some(lines) = tooltip {
ui::tooltip(batch, atlas, &lines, cursor);
}
@@ -965,6 +1079,20 @@ 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,
Action::BindKey(crate::graphics::HDR_KEY),
]
}
Target::Item(item) => {
if item.slider(s).is_some() {
self.dragging = Some(item);
@@ -1216,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);
@@ -1229,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()
@@ -1248,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)]
);
}
}
@@ -93,7 +93,7 @@ pub fn java_sort<T>(a: &mut [T], cmp: impl Fn(&T, &T) -> Ordering) {
/// A `MobEffectInstance` as `extractEffects` sees it.
#[derive(Clone, Debug, PartialEq)]
pub struct EffectIcon {
/// The effect id without namespace (`speed`): the sprite is `mob_effect/<id>`.
/// The effect id: vanilla's without namespace (`speed`), a mod's `<ns>:<path>`.
pub id: String,
/// `MobEffect.isBeneficial`.
pub beneficial: bool,
@@ -47,7 +47,7 @@ pub fn panels(
.into_iter()
.enumerate()
.map(|(i, (effect, icon))| {
let mut name = translate(&format!("effect.minecraft.{}", effect.id))
let mut name = translate(&horizon_client_core::potions::description_id(effect.id))
.unwrap_or_else(|| effect.id.to_owned());
if (1..=9).contains(&effect.amplifier) {
let level = effect.amplifier + 1;
@@ -321,10 +321,25 @@ pub fn smooth(smoothed: f32, progress: f32) -> f32 {
}
/// `ARGB.opaque` as the batch's RGBA.
fn opaque(rgb: u32) -> u32 {
pub const fn opaque(rgb: u32) -> u32 {
0xFF00_0000 | (rgb & 0xFF) << 16 | (rgb & 0xFF00) | (rgb >> 16 & 0xFF)
}
/// `LoadingOverlay.LOGO_BACKGROUND_COLOR` (RGB: 239, 50, 61).
pub const LOGO_BACKGROUND_COLOR: u32 = opaque(0xEF323D);
/// `LoadingOverlay.LOGO_BACKGROUND_COLOR_DARK` (black).
pub const LOGO_BACKGROUND_COLOR_DARK: u32 = opaque(0x000000);
/// `LoadingOverlay.BRAND_BACKGROUND`: black when `darkMojangStudiosBackground` is set,
/// Mojang red otherwise.
pub fn brand_background(dark: bool) -> u32 {
if dark {
LOGO_BACKGROUND_COLOR_DARK
} else {
LOGO_BACKGROUND_COLOR
}
}
/// `LevelLoadingScreen.extractRenderState` over its background: the text,
/// the progress bar and the chunk map, `status(x, z)` for x and z in
/// `0..=2 * STATUS_RADIUS` from the map's north-west corner.
@@ -370,6 +385,27 @@ pub fn draw(
}
}
/// `LoadingOverlay.extractRenderState`: fills the frame with `BRAND_BACKGROUND`
/// (Mojang red, or black when `darkMojangStudiosBackground` is set) and draws the
/// progress line and bar.
pub fn draw_loading_overlay(
batch: &mut HudBatch,
atlas: &HudAtlas,
line: &str,
progress: f32,
dark: bool,
) {
batch.fill(
atlas,
0.0,
0.0,
batch.width,
batch.height,
brand_background(dark),
);
draw_progress(batch, atlas, line, progress);
}
/// The startup loading screen: the stage's line and a progress bar, centred
/// like `LevelLoadingScreen`'s text and bar.
pub fn draw_progress(batch: &mut HudBatch, atlas: &HudAtlas, line: &str, progress: f32) {
@@ -546,6 +582,14 @@ mod tests {
assert!((smooth(eased, 1.0) - 0.36).abs() < 1e-6);
}
#[test]
fn brand_background_matches_dark_mojang_studios_setting() {
assert_eq!(brand_background(true), LOGO_BACKGROUND_COLOR_DARK);
assert_eq!(brand_background(false), LOGO_BACKGROUND_COLOR);
assert_eq!(LOGO_BACKGROUND_COLOR_DARK, 0xFF00_0000);
assert_eq!(LOGO_BACKGROUND_COLOR, crate::hud::rgba(239, 50, 61, 255));
}
#[test]
fn file_fixer_cancel_sits_below_the_centre() {
assert_eq!(file_fixer_cancel(427.0, 240.0), (113.0, 220.0));
+4
View File
@@ -5,6 +5,7 @@ pub mod assets;
pub mod atlas_sources;
pub mod audio;
pub mod auth;
pub mod ban_notice;
pub mod bidi;
pub mod biome;
pub mod block_entities;
@@ -34,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;
@@ -62,7 +64,9 @@ pub mod par;
pub mod particles;
pub mod poi_sections;
pub mod post_chain;
pub mod post_glsl;
pub mod potent_sulfur;
pub mod preedit;
pub mod providers;
pub mod screen_draw;
pub mod screen_effects;
+525 -85
View File
@@ -22,6 +22,7 @@ use crate::{
use horizon_core::geometry::{Direction, ShapeOp, VoxelShape};
use horizon_world::registry::{BlockRegistry, BlockStateId};
use std::{
cell::RefCell,
collections::{HashMap, VecDeque},
path::Path,
sync::Arc,
@@ -240,7 +241,8 @@ impl LightTables {
/// `edge_occluded` for two packed cells.
#[inline]
fn packed_edge_occluded(&self, top: u16, bottom: u16) -> bool {
bottom & 0xF != 0 || self.packed_blocked(top, bottom, Direction::Down as usize)
bottom & 0xF != 0
|| ((top | bottom) != 0 && self.packed_blocked(top, bottom, Direction::Down as usize))
}
pub fn emission(&self, state: u32) -> u8 {
self.cells[state as usize].emission
@@ -266,12 +268,64 @@ const OFFSETS: [(i32, i32, i32); 6] = [
(1, 0, 0),
];
struct LightScratch {
states: Vec<u16>,
sky: Vec<u8>,
block: Vec<u8>,
queue: Vec<u32>,
sky_queue: Vec<u32>,
present: Vec<bool>,
lowest: Vec<i32>,
ids: Box<[u32; 4096]>,
}
impl LightScratch {
fn new() -> Self {
Self {
states: Vec::new(),
sky: Vec::new(),
block: Vec::new(),
queue: Vec::new(),
sky_queue: Vec::new(),
present: vec![false; W * W],
lowest: vec![0; W * W],
ids: Box::new([0u32; 4096]),
}
}
/// Clear queues and shrink them if capacity exceeds the retention cap.
fn clear_queues(&mut self) {
const MAX_QUEUE_CAPACITY: usize = 65_536;
self.queue.clear();
if self.queue.capacity() > MAX_QUEUE_CAPACITY {
self.queue.shrink_to(MAX_QUEUE_CAPACITY);
}
self.sky_queue.clear();
if self.sky_queue.capacity() > MAX_QUEUE_CAPACITY {
self.sky_queue.shrink_to(MAX_QUEUE_CAPACITY);
}
}
}
thread_local! {
static SCRATCH: RefCell<LightScratch> = RefCell::new(LightScratch::new());
}
/// `region[dz][dx]` holds the chunks around the centre (index 1,1).
/// `sky` is false in dimensions without skylight (the Nether).
pub fn light_chunk(
region: &[[Option<&Chunk>; 3]; 3],
tables: &LightTables,
sky_light: bool,
) -> Vec<Light> {
SCRATCH.with_borrow_mut(|scratch| light_chunk_with_scratch(region, tables, sky_light, scratch))
}
fn light_chunk_with_scratch(
region: &[[Option<&Chunk>; 3]; 3],
tables: &LightTables,
sky_light: bool,
scratch: &mut LightScratch,
) -> Vec<Light> {
let sections = region[1][1].expect("centre chunk").sections.len();
// Highest occupied section in the region bounds the work.
@@ -287,142 +341,194 @@ pub fn light_chunk(
// One extra section above the highest occupied one: block light rises
// up to 14 blocks into it (sky light there is always 15).
let h = ((top_section + 1) * 16).min(sections * 16);
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
// Packed cells (`type << 4 | dampening`), not state ids: the flood fill
// then never touches the large `LightTables::cells` table.
let mut states = vec![0u16; W * W * h];
let mut sky = vec![0u8; W * W * h];
let mut block = vec![0u8; W * W * h];
let mut queue = VecDeque::new();
let mut sky_queue = VecDeque::new();
let mut ids = Box::new([0u32; 4096]);
let total = W * W * h;
scratch.states.resize(total, 0);
scratch.states.fill(0);
scratch.sky.resize(total, 0);
scratch.sky.fill(0);
scratch.block.resize(total, 0);
scratch.block.fill(0);
scratch.clear_queues();
let states = &mut scratch.states[..total];
let sky = &mut scratch.sky[..total];
let block = &mut scratch.block[..total];
let queue = &mut scratch.queue;
let sky_queue = &mut scratch.sky_queue;
let ids = &mut scratch.ids;
for (dz, row) in region.iter().enumerate() {
let z_base = dz * 16;
for (dx, chunk) in row.iter().enumerate() {
let Some(chunk) = chunk else { continue };
let x_base = dx * 16;
for (si, section) in chunk.sections.iter().enumerate().take(top_section) {
if section.is_uniform(0) {
continue;
}
section.unpack(&mut ids);
for (i, &id) in ids.iter().enumerate() {
let (lx, lz, ly) = (i & 15, (i >> 4) & 15, i >> 8);
let p = at(dx * 16 + lx, si * 16 + ly, dz * 16 + lz);
let info = tables.info[id as usize];
states[p] = info as u16;
let emission = info >> 16;
if emission > 0 {
block[p] = emission as u8;
queue.push_back(p as u32);
section.unpack(ids);
let y_base = si * 16;
for ly in 0..16 {
let y = y_base + ly;
let y_offset = y * (W * W);
for lz in 0..16 {
let z = z_base + lz;
let p_row = y_offset + z * W + x_base;
let i_row = (ly << 8) | (lz << 4);
for lx in 0..16 {
let id = ids[i_row + lx];
if id == 0 {
continue;
}
let p = p_row + lx;
let info = tables.info[id as usize];
states[p] = info as u16;
let emission = info >> 16;
if emission > 0 {
block[p] = emission as u8;
queue.push(p as u32);
}
}
}
}
}
}
}
// Columns of missing neighbour chunks neither hold nor pass light.
let mut present = vec![false; W * W];
scratch.present.resize(W * W, false);
for z in 0..W {
for x in 0..W {
present[z * W + x] = region[z / 16][x / 16].is_some();
scratch.present[z * W + x] = region[z / 16][x / 16].is_some();
}
}
let mut lowest = vec![h as i32; W * W];
let present = &scratch.present[..W * W];
scratch.lowest.resize(W * W, h as i32);
let lowest = &mut scratch.lowest[..W * W];
for z in 0..W {
for x in 0..W {
if !present[z * W + x] || !sky_light {
let col = z * W + x;
if !present[col] || !sky_light {
lowest[col] = h as i32;
continue;
}
// The top layer (above the occupied range) is always open sky.
let mut y = h as i32 - 1;
sky[at(x, y as usize, z)] = 15;
while y > 0
&& !tables.packed_edge_occluded(
states[at(x, y as usize, z)],
states[at(x, y as usize - 1, z)],
)
{
let mut p = (h - 1) * (W * W) + col;
sky[p] = 15;
while y > 0 {
let next_p = p - (W * W);
if tables.packed_edge_occluded(states[p], states[next_p]) {
break;
}
y -= 1;
sky[at(x, y as usize, z)] = 15;
p = next_p;
sky[p] = 15;
}
lowest[z * W + x] = y;
lowest[col] = y;
}
}
for z in 0..W {
for x in 0..W {
if !present[z * W + x] || !sky_light {
continue;
}
let own = lowest[z * W + x];
let mut deepest = own;
for (nx, nz) in [
(x.wrapping_sub(1), z),
(x + 1, z),
(x, z.wrapping_sub(1)),
(x, z + 1),
] {
if nx < W && nz < W && present[nz * W + nx] {
deepest = deepest.max(lowest[nz * W + nx]);
if sky_light {
for z in 0..W {
for x in 0..W {
let col = z * W + x;
if !present[col] {
continue;
}
}
// Seed propagation from the source cells that border darker cells.
let from = own.max(0) as usize;
let to = (deepest as usize).min(h - 1);
for y in from..=to {
if sky[at(x, y, z)] == 15 {
sky_queue.push_back(at(x, y, z) as u32);
let own = lowest[col];
let mut deepest = own;
for (nx, nz) in [
(x.wrapping_sub(1), z),
(x + 1, z),
(x, z.wrapping_sub(1)),
(x, z + 1),
] {
if nx < W && nz < W && present[nz * W + nx] {
deepest = deepest.max(lowest[nz * W + nx]);
}
}
// Seed propagation from the source cells that border darker cells.
let from = own.max(0) as usize;
let to = (deepest as usize).min(h.saturating_sub(1));
if from <= to {
let mut p = from * (W * W) + col;
for _ in from..=to {
if sky[p] == 15 {
sky_queue.push(p as u32);
}
p += W * W;
}
}
}
}
}
let spread = |light: &mut [u8], queue: &mut VecDeque<u32>| {
while let Some(p) = queue.pop_front() {
let p = p as usize;
let block_masks = &tables.block_masks;
let types = tables.types;
let spread = |light: &mut [u8], queue: &mut Vec<u32>| {
let mut head = 0;
while head < queue.len() {
let p = queue[head] as usize;
head += 1;
let level = light[p];
if level <= 1 {
continue;
}
let x = p % W;
let z = (p / W) % W;
let min_threshold = level - 1;
let y = p / (W * W);
let col = p % (W * W);
let z = col / W;
let x = col % W;
let from = states[p];
let col = z * W + x;
let from_row = (from >> 4) as usize * types;
// Neighbours by linear offset; `present` only depends on the
// column, and the axis bounds rule out wrapping.
macro_rules! neighbour {
($d:expr, $q:expr, $ncol:expr) => {{
if present[$ncol] {
let q = $q;
macro_rules! step_neighbour {
($d:expr, $q:expr) => {{
let q = $q;
if light[q] < min_threshold {
let to = states[q];
let next = level.saturating_sub((to & 0xF).max(1) as u8);
if next > light[q] && !tables.packed_blocked(from, to, $d) {
light[q] = next;
queue.push_back(q as u32);
let damp = (to & 0xF).max(1) as u8;
if level > light[q] + damp {
if block_masks[from_row + (to >> 4) as usize] & (1 << $d) == 0 {
light[q] = level - damp;
queue.push(q as u32);
}
}
}
}};
}
if y > 0 {
neighbour!(0, p - W * W, col);
step_neighbour!(0, p - W * W);
}
if y + 1 < h {
neighbour!(1, p + W * W, col);
step_neighbour!(1, p + W * W);
}
if z > 0 {
neighbour!(2, p - W, col - W);
if z > 0 && present[col - W] {
step_neighbour!(2, p - W);
}
if z + 1 < W {
neighbour!(3, p + W, col + W);
if z + 1 < W && present[col + W] {
step_neighbour!(3, p + W);
}
if x > 0 {
neighbour!(4, p - 1, col - 1);
if x > 0 && present[col - 1] {
step_neighbour!(4, p - 1);
}
if x + 1 < W {
neighbour!(5, p + 1, col + 1);
if x + 1 < W && present[col + 1] {
step_neighbour!(5, p + 1);
}
}
};
spread(&mut sky, &mut sky_queue);
spread(&mut block, &mut queue);
pack_lighting(sections, h, &sky, &block, sky_light)
spread(sky, sky_queue);
spread(block, queue);
let result = pack_lighting(sections, h, sky, block, sky_light);
scratch.clear_queues();
result
}
/// Extract just the centre column's light (`sections` of it); never copy
@@ -434,7 +540,6 @@ fn pack_lighting(
block: &[u8],
sky_light: bool,
) -> Vec<Light> {
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
let open = if sky_light { 0xF0 } else { 0x00 };
(0..sections)
.map(|si| {
@@ -447,10 +552,13 @@ fn pack_lighting(
if y >= h {
continue;
}
let y_offset = y * (W * W);
for lz in 0..16 {
let p_row = y_offset + (16 + lz) * W + 16;
let out_row = (ly << 8) | (lz << 4);
for lx in 0..16 {
let p = at(16 + lx, y, 16 + lz);
data[index(lx as i32, ly as i32, lz as i32)] = sky[p] << 4 | block[p];
let p = p_row + lx;
data[out_row + lx] = sky[p] << 4 | block[p];
}
}
}
@@ -1832,4 +1940,336 @@ mod tests {
changed.sections().count()
);
}
#[test]
#[ignore = "benchmark"]
fn benchmark_light_chunk() {
use std::time::Instant;
let t = tables();
let layout = ColumnLayout::VANILLA;
let mut chunks = Vec::new();
for cz in -1..=1 {
for cx in -1..=1 {
let mut c = Chunk::with_layout(cx, cz, layout);
for y in layout.min_y()..64 {
for i in 0..256 {
c.set(i & 15, y, i >> 4, STONE);
}
}
for x in 2..14 {
for z in 2..14 {
c.set(x, 64, z, SLAB);
}
}
c.set(8, 65, 8, TORCH);
c.set(4, 30, 4, TORCH);
chunks.push(c);
}
}
let region = [
[Some(&chunks[0]), Some(&chunks[1]), Some(&chunks[2])],
[Some(&chunks[3]), Some(&chunks[4]), Some(&chunks[5])],
[Some(&chunks[6]), Some(&chunks[7]), Some(&chunks[8])],
];
for _ in 0..5 {
let _ = light_chunk(&region, &t, true);
}
let start = Instant::now();
let iters = 200;
for _ in 0..iters {
let _ = std::hint::black_box(light_chunk(std::hint::black_box(&region), &t, true));
}
let elapsed = start.elapsed();
println!(
"benchmark_light_chunk: {:?} total, {:.2} µs/chunk",
elapsed,
elapsed.as_micros() as f64 / iters as f64
);
}
#[test]
fn scratch_queues_shrink_above_capacity_cap() {
let mut scratch = LightScratch::new();
scratch.queue.resize(100_000, 0);
scratch.sky_queue.resize(100_000, 0);
assert!(scratch.queue.capacity() >= 100_000);
assert!(scratch.sky_queue.capacity() >= 100_000);
scratch.clear_queues();
assert_eq!(scratch.queue.len(), 0);
assert_eq!(scratch.sky_queue.len(), 0);
assert!(scratch.queue.capacity() < 100_000);
assert!(scratch.sky_queue.capacity() < 100_000);
}
/// The baseline flood-fill lighting algorithm before the scratch and wavefront optimizations.
fn old_light_chunk(
region: &[[Option<&Chunk>; 3]; 3],
tables: &LightTables,
sky_light: bool,
) -> Vec<Light> {
let sections = region[1][1].expect("centre chunk").sections.len();
let mut top_section = 0;
for chunk in region.iter().flatten().flatten() {
for (i, s) in chunk.sections.iter().enumerate().rev() {
if !s.is_uniform(0) {
top_section = top_section.max(i + 1);
break;
}
}
}
let h = ((top_section + 1) * 16).min(sections * 16);
let at = |x: usize, y: usize, z: usize| (y * W + z) * W + x;
let mut states = vec![0u16; W * W * h];
let mut sky = vec![0u8; W * W * h];
let mut block = vec![0u8; W * W * h];
let mut queue = VecDeque::new();
let mut sky_queue = VecDeque::new();
let mut ids = Box::new([0u32; 4096]);
for (dz, row) in region.iter().enumerate() {
for (dx, chunk) in row.iter().enumerate() {
let Some(chunk) = chunk else { continue };
for (si, section) in chunk.sections.iter().enumerate().take(top_section) {
if section.is_uniform(0) {
continue;
}
section.unpack(&mut ids);
for (i, &id) in ids.iter().enumerate() {
let (lx, lz, ly) = (i & 15, (i >> 4) & 15, i >> 8);
let p = at(dx * 16 + lx, si * 16 + ly, dz * 16 + lz);
let info = tables.info[id as usize];
states[p] = info as u16;
let emission = info >> 16;
if emission > 0 {
block[p] = emission as u8;
queue.push_back(p as u32);
}
}
}
}
}
let mut present = vec![false; W * W];
for z in 0..W {
for x in 0..W {
present[z * W + x] = region[z / 16][x / 16].is_some();
}
}
let mut lowest = vec![h as i32; W * W];
let old_packed_edge_occluded = |top: u16, bottom: u16| -> bool {
bottom & 0xF != 0 || tables.packed_blocked(top, bottom, Direction::Down as usize)
};
for z in 0..W {
for x in 0..W {
if !present[z * W + x] || !sky_light {
continue;
}
let mut y = h as i32 - 1;
sky[at(x, y as usize, z)] = 15;
while y > 0
&& !old_packed_edge_occluded(
states[at(x, y as usize, z)],
states[at(x, y as usize - 1, z)],
)
{
y -= 1;
sky[at(x, y as usize, z)] = 15;
}
lowest[z * W + x] = y;
}
}
for z in 0..W {
for x in 0..W {
if !present[z * W + x] || !sky_light {
continue;
}
let own = lowest[z * W + x];
let mut deepest = own;
for (nx, nz) in [
(x.wrapping_sub(1), z),
(x + 1, z),
(x, z.wrapping_sub(1)),
(x, z + 1),
] {
if nx < W && nz < W && present[nz * W + nx] {
deepest = deepest.max(lowest[nz * W + nx]);
}
}
let from = own.max(0) as usize;
let to = (deepest as usize).min(h.saturating_sub(1));
for y in from..=to {
if sky[at(x, y, z)] == 15 {
sky_queue.push_back(at(x, y, z) as u32);
}
}
}
}
let spread = |light: &mut [u8], queue: &mut VecDeque<u32>| {
while let Some(p) = queue.pop_front() {
let p = p as usize;
let level = light[p];
if level <= 1 {
continue;
}
let x = p % W;
let z = (p / W) % W;
let y = p / (W * W);
let from = states[p];
let col = z * W + x;
macro_rules! neighbour {
($d:expr, $q:expr, $ncol:expr) => {{
if present[$ncol] {
let q = $q;
let to = states[q];
let next = level.saturating_sub((to & 0xF).max(1) as u8);
if next > light[q] && !tables.packed_blocked(from, to, $d) {
light[q] = next;
queue.push_back(q as u32);
}
}
}};
}
if y > 0 {
neighbour!(0, p - W * W, col);
}
if y + 1 < h {
neighbour!(1, p + W * W, col);
}
if z > 0 {
neighbour!(2, p - W, col - W);
}
if z + 1 < W {
neighbour!(3, p + W, col + W);
}
if x > 0 {
neighbour!(4, p - 1, col - 1);
}
if x + 1 < W {
neighbour!(5, p + 1, col + 1);
}
}
};
spread(&mut sky, &mut sky_queue);
spread(&mut block, &mut queue);
pack_lighting(sections, h, &sky, &block, sky_light)
}
#[test]
fn light_chunk_matches_old_algorithm() {
let t = tables();
let layout = ColumnLayout::VANILLA;
let mut rng = 0x1234_5678_u64;
let mut next = |max: usize| -> usize {
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
((rng >> 32) as usize) % max
};
let cases = [
// (sky_light, missing_pattern)
// 0 = full 3x3
// 1 = missing corners
// 2 = missing edges
// 3 = random missing neighbours
// 4 = only centre and single neighbour
(true, 0),
(false, 0),
(true, 1),
(false, 1),
(true, 2),
(false, 2),
(true, 3),
(false, 3),
(true, 4),
(false, 4),
];
for (round, &(sky_light, pattern)) in cases.iter().enumerate() {
let mut chunks = Vec::new();
for cz in -1..=1 {
for cx in -1..=1 {
let mut c = Chunk::with_layout(cx, cz, layout);
let base_y = -64 + next(64) as i32;
for y in layout.min_y()..base_y {
for i in 0..256 {
c.set(i & 15, y, i >> 4, STONE);
}
}
let features = 10 + next(20);
for _ in 0..features {
let fx = next(16) as i32;
let fz = next(16) as i32;
let fy = base_y + next(32) as i32;
let block_type = match next(4) {
0 => AIR,
1 => STONE,
2 => TORCH,
_ => SLAB,
};
c.set(fx, fy, fz, block_type);
}
let roof_y = base_y + 10 + next(10) as i32;
for rx in 4..12 {
for rz in 4..12 {
if next(3) != 0 {
c.set(rx, roof_y, rz, SLAB);
}
}
}
c.set(8, roof_y - 1, 8, TORCH);
c.set(next(16) as i32, roof_y + 2, next(16) as i32, TORCH);
c.set(0, base_y + 1, 0, TORCH);
c.set(15, base_y + 1, 15, TORCH);
chunks.push(c);
}
}
let mut region: [[Option<&Chunk>; 3]; 3] = [[None; 3]; 3];
for dz in 0..3 {
for dx in 0..3 {
let is_centre = dz == 1 && dx == 1;
let chunk_ref = &chunks[dz * 3 + dx];
let include = if is_centre {
true
} else {
match pattern {
0 => true,
1 => (dz + dx) % 2 == 1,
2 => (dz + dx) % 2 == 0,
3 => next(10) < 6,
4 => dz == 1 && dx == 2,
_ => true,
}
};
if include {
region[dz][dx] = Some(chunk_ref);
}
}
}
let old = old_light_chunk(&region, &t, sky_light);
let new = light_chunk(&region, &t, sky_light);
assert_eq!(
old.len(),
new.len(),
"round {round}: section count mismatch"
);
for si in 0..old.len() {
for i in 0..4096 {
assert_eq!(
new[si].get(i),
old[si].get(i),
"mismatch at round {round} (sky_light={sky_light}, pattern={pattern}), section {si}, index {i} (lx={}, ly={}, lz={}): new={:#04x}, old={:#04x}",
i & 15,
i >> 8,
(i >> 4) & 15,
new[si].get(i),
old[si].get(i)
);
}
}
}
}
}
+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);
+159 -4
View File
@@ -130,6 +130,12 @@ impl Mesher {
let tint = match name.strip_prefix("minecraft:").unwrap_or(name) {
"spruce_leaves" => Tint::Constant(0x61_9961),
"birch_leaves" => Tint::Constant(0x80_A755),
// Cherry (and other pre-coloured) leaves: the texture is
// already pink; biome foliage tint turns a grove yellow (#4688).
"cherry_leaves"
| "azalea_leaves"
| "flowering_azalea_leaves"
| "pale_oak_leaves" => Tint::None,
"leaf_litter" => Tint::Dry,
_ if leaves => Tint::Foliage,
_ => Tint::Grass,
@@ -298,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],
@@ -308,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;
@@ -330,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,
@@ -938,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 {
@@ -988,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,
@@ -1087,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))
+153 -16
View File
@@ -139,11 +139,50 @@ mod vertex_tests {
assert_eq!(hdr_tier("red_candle", 3), LOW);
assert_eq!(hdr_tier("glow_lichen", 7), 0);
assert_eq!(hdr_tier("stone", 0), 0);
assert_eq!(hdr_tier("ochre_froglight", 15), LOW);
assert_eq!(hdr_tier("respawn_anchor", 3), INCANDESCENT);
assert_eq!(hdr_tier("waxed_copper_bulb", 15), LOW);
assert_eq!(hdr_tier("sea_pickle", 15), 0);
// Unlisted ones by their light level.
assert_eq!(hdr_tier("ochre_froglight", 15), INCANDESCENT);
assert_eq!(hdr_tier("vault", 12), INCANDESCENT);
assert_eq!(hdr_tier("sculk_sensor", 1), LOW);
}
}
#[cfg(test)]
mod section_mesh_tests {
use super::*;
#[test]
fn compute_bounds_empty_solid_and_translucent() {
let mut mesh = SectionMesh::default();
assert_eq!(mesh.compute_bounds(), None);
let v1 = TerrainVertex::new([1.0, 2.0, 3.0], [0.0, 0.0], 0, 0, 0);
let v2 = TerrainVertex::new([4.0, 5.0, 6.0], [0.0, 0.0], 0, 0, 0);
mesh.solid.push(v1);
mesh.solid.push(v2);
let pad = glam::Vec3::splat(SectionMesh::BOUNDS_PAD);
assert_eq!(
mesh.compute_bounds(),
Some([
glam::Vec3::new(1.0, 2.0, 3.0) - pad,
glam::Vec3::new(4.0, 5.0, 6.0) + pad,
])
);
let v3 = TerrainVertex::new([0.5, 7.0, 8.0], [0.0, 0.0], 0, 0, 0);
mesh.translucent.push(v3);
assert_eq!(
mesh.compute_bounds(),
Some([
glam::Vec3::new(0.5, 2.0, 3.0) - pad,
glam::Vec3::new(4.0, 7.0, 8.0) + pad,
])
);
}
}
pub struct SectionMesh {
pub solid: Vec<TerrainVertex>,
/// The buckets `solid` is sorted in by the side its quads face: empty
@@ -158,6 +197,8 @@ pub struct SectionMesh {
/// each frame by `block_entity_render` like vanilla's per-section
/// `SectionCompiler.Results.blockEntities`.
pub block_entities: Vec<([i32; 3], u32)>,
/// Axis-aligned bounding box [min, max] of actual terrain mesh vertices in section-local space [0, 16].
pub bounds: Option<[glam::Vec3; 2]>,
}
impl Default for SectionMesh {
fn default() -> Self {
@@ -167,19 +208,66 @@ impl Default for SectionMesh {
translucent: Vec::new(),
visibility: ALL_VISIBLE,
block_entities: Vec::new(),
bounds: None,
}
}
}
impl SectionMesh {
/// Padding applied to mesh vertex bounds to prevent clipping at the frustum
/// boundary when shader packs with vertex displacement (e.g. waving foliage, water) are active.
pub const BOUNDS_PAD: f32 = 0.5;
/// Retain geometry capacity after an upload, but none of its old contents.
pub(crate) fn clear(&mut self) {
self.solid.clear();
self.translucent.clear();
self.block_entities.clear();
self.sides = Sides::default();
self.visibility = ALL_VISIBLE;
self.bounds = None;
}
pub(crate) fn capacity_bytes(&self) -> usize {
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
}
/// Sorts the solid quads by the side they face, for the renderer to
/// leave out those facing away from the eye (`sides`).
pub fn sort_solid(&mut self) {
self.sides = sides::sort(&mut self.solid);
self.bounds = self.compute_bounds();
}
/// Computes the tight axis-aligned bounding box [min, max] of actual terrain geometry vertices.
pub fn compute_bounds(&self) -> Option<[glam::Vec3; 2]> {
if self.solid.is_empty() && self.translucent.is_empty() {
return None;
}
let mut min = glam::Vec3::splat(f32::INFINITY);
let mut max = glam::Vec3::splat(f32::NEG_INFINITY);
for v in &self.solid {
let p = glam::Vec3::from(v.pos);
min = min.min(p);
max = max.max(p);
}
for v in &self.translucent {
let p = glam::Vec3::from(v.pos);
min = min.min(p);
max = max.max(p);
}
let pad = glam::Vec3::splat(Self::BOUNDS_PAD);
Some([min - pad, max + pad])
}
}
pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
#[cfg(test)]
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
visibility_reusing(pad, info, &mut Vec::new())
}
fn visibility_reusing(pad: &Padded, info: &[Info], stack: &mut Vec<usize>) -> u64 {
// The all-visible shortcut can leave a partially filled stack behind.
stack.clear();
// Opaque cells are already visited: resolve each state's opacity once,
// then reuse this map for the flood fill and the uniform-section shortcuts.
let mut seen = [false; 4096];
@@ -201,7 +289,6 @@ fn visibility(pad: &Padded, info: &[Info]) -> u64 {
return 0;
}
let mut result = 0u64;
let mut stack = Vec::new();
for start in 0..4096usize {
if seen[start] {
continue;
@@ -458,7 +545,12 @@ impl MeshTables {
});
let mut face = [0u8; 6];
let mut cover = [0f32; 6];
for d in Direction::ALL {
// A server's visibility knows no state (`FaceVisibility::empty`):
// it meshes no fluid, so these stay unset.
for d in Direction::ALL
.into_iter()
.filter(|_| !visibility.is_empty())
{
let (face_id, shape) = visibility
.occlusion_face(BlockStateId(id as u32), d)
.ok_or("missing occlusion face")?;
@@ -631,21 +723,30 @@ fn pi(x: i32, y: i32, z: i32) -> usize {
((y + B) as usize * P + (z + B) as usize) * P + (x + B) as usize
}
/// Blocks and light of one section plus a `B`-block border.
#[derive(Default)]
struct Padded {
blocks: Vec<u32>,
light: Vec<u8>,
}
impl Padded {
#[cfg(test)]
fn new(region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> Self {
let mut blocks = vec![0u32; P * P * P];
let mut light = vec![0u8; P * P * P];
let mut pad = Self::default();
pad.fill(region, sy, &mut Box::new([0; 4096]));
pad
}
fn fill(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32, own: &mut [u32; 4096]) {
self.blocks.resize(P * P * P, 0);
self.blocks.fill(0);
self.light.resize(P * P * P, 0);
let (blocks, light) = (&mut self.blocks, &mut self.light);
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
// The section itself is unpacked in one pass; the border cells of the
// neighbouring sections are read one by one.
let mut own = Box::new([0u32; 4096]);
own.fill(0);
let center = region[1][1].map(|c| &c.sections[sy as usize]);
if let Some(section) = center {
section.unpack(&mut own);
section.unpack(own);
}
for y in -B..16 + B {
let wy = base_y + y;
@@ -677,9 +778,18 @@ impl Padded {
}
}
}
Self { blocks, light }
}
}
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 88 KiB per worker.
#[derive(Default)]
pub(crate) struct MeshScratch {
padded: Padded,
own: Option<Box<[u32; 4096]>>,
visibility: Vec<usize>,
}
#[cfg(test)]
mod reuse_tests;
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
pub const OPAQUE_LAYER: u16 = 1 << 15;
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
@@ -702,14 +812,25 @@ pub fn hdr_tier(name: &str, level: u8) -> u8 {
match name {
_ if level == 0 => 0,
"glowstone" | "jack_o_lantern" | "campfire" | "soul_campfire" | "soul_lantern"
| "soul_torch" | "soul_wall_torch" | "soul_fire" => BRIGHT,
| "soul_torch" | "soul_wall_torch" | "soul_fire" | "beacon" | "end_rod" | "furnace"
| "redstone_lamp" => BRIGHT,
"torch" | "wall_torch" | "lantern" | "lava" | "crying_obsidian" | "fire"
| "sea_lantern" => INCANDESCENT,
"redstone_torch" | "redstone_wall_torch" | "magma_block" => LOW,
// Glowing at every charge, not only the brighter ones.
"respawn_anchor" => INCANDESCENT,
"redstone_torch"
| "redstone_wall_torch"
| "magma_block"
| "ochre_froglight"
| "verdant_froglight"
| "pearlescent_froglight"
| "shroomlight" => LOW,
// Its flame is a particle.
_ if name.ends_with("candle") || name.ends_with("candle_cake") => LOW,
// One tier at every oxidation stage, waxed or not.
_ if name.ends_with("copper_bulb") => LOW,
// A faint glow in the dark, as in SDR.
"glow_lichen" => 0,
"glow_lichen" | "sea_pickle" | "brown_mushroom" | "dragon_egg" | "end_portal_frame" => 0,
_ if level >= 10 => INCANDESCENT,
_ => LOW,
}
@@ -849,14 +970,30 @@ pub struct Mesher<'a> {
impl Mesher<'_> {
/// `region[dz][dx]` are the chunks around the meshed column (centre 1,1).
pub fn mesh(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32) -> SectionMesh {
let mut out = SectionMesh::default();
self.mesh_reusing(
region,
sy,
SectionMesh::default(),
&mut MeshScratch::default(),
)
}
pub(crate) fn mesh_reusing(
&mut self,
region: &[[Option<&Chunk>; 3]; 3],
sy: i32,
mut out: SectionMesh,
scratch: &mut MeshScratch,
) -> SectionMesh {
out.clear();
let center = region[1][1].expect("centre chunk");
let section = &center.sections[sy as usize];
if section.is_uniform(0) {
return out;
}
let pad = Padded::new(region, sy);
out.visibility = visibility(&pad, &self.tables.info);
let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096]));
scratch.padded.fill(region, sy, own);
let pad = &scratch.padded;
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
let base_y = center.min_y() + sy * 16;
let mut tints = Tints::new(
region,
@@ -891,7 +1028,7 @@ impl Mesher<'_> {
if state.fluid.kind() != FluidKind::Empty {
let water = tints.get(Resolver::Water, x, y, z);
let nether = self.nether_shading;
fluid::tesselate(self.tables, nether, &pad, water, &mut out, [x, y, z]);
fluid::tesselate(self.tables, nether, pad, water, &mut out, [x, y, z]);
}
if !state.model {
continue;
@@ -941,7 +1078,7 @@ impl Mesher<'_> {
continue;
}
self.face(
&pad,
pad,
&mut tints,
&mut out,
[x, y, z],
@@ -0,0 +1,208 @@
use super::*;
#[test]
fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts() {
let mut pad = Padded::default();
let mut own = Box::new([u32::MAX; 4096]);
for min_y in [MIN_Y, -256, 0, 256] {
let layout = crate::chunk::ColumnLayout::new(min_y, 32).unwrap();
let base_y = min_y + 16;
let mut center = Chunk::with_layout(0, 0, layout);
center.set(0, base_y, 0, 7);
center.set(15, base_y + 15, 15, 9);
let mut neighbor = Chunk::with_layout(1, 0, layout);
neighbor.set(0, base_y + 2, 3, 11);
let mut region = [[None; 3]; 3];
region[1][1] = Some(&center);
region[1][2] = Some(&neighbor);
pad.fill(&region, 1, &mut own);
let buffers = (pad.blocks.as_ptr(), pad.light.as_ptr());
for current in [region, [[None; 3]; 3], region] {
pad.fill(&current, 1, &mut own);
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let chunk = current[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
// A missing neighbour continues the centre's edge column.
let (block, light) = match (chunk, current[1][1]) {
(Some(c), _) => (
c.get(x & 15, base_y + y, z & 15),
c.light(x & 15, base_y + y, z & 15),
),
(None, Some(c)) => {
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
(c.get(ex, base_y + y, ez), c.light(ex, base_y + y, ez))
}
(None, None) => (0, 0xF0),
};
assert_eq!(pad.blocks[pi(x, y, z)], block);
assert_eq!(pad.light[pi(x, y, z)], light);
}
}
}
assert_eq!(buffers, (pad.blocks.as_ptr(), pad.light.as_ptr()));
}
}
}
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
let assets = Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let biomes = Biomes::load(&root).unwrap();
let state = |name| assets.registry.default_state(name).unwrap().0;
let names = [
"minecraft:stone",
"minecraft:glass",
"minecraft:grass_block",
"minecraft:oak_leaves",
"minecraft:water",
"minecraft:chest",
];
let mut dense = Chunk::empty(0, 0);
for z in 0..16 {
for x in 0..16 {
dense.set(
x,
MIN_Y + 4,
z,
state(names[((x + z) as usize) % names.len()]),
);
}
}
let mut sparse = Chunk::empty(-3, 5);
sparse.set(0, MIN_Y + 15, 0, state("minecraft:short_grass"));
sparse.set(15, MIN_Y + 1, 15, state("minecraft:lava"));
let empty = Chunk::empty(0, 0);
let mut neighbor = Chunk::empty(1, 0);
neighbor.set(0, MIN_Y + 4, 0, state("minecraft:stone"));
let mut custom = Chunk::with_layout(2, -4, crate::chunk::ColumnLayout::new(-256, 64).unwrap());
custom.set(1, -252, 2, state("minecraft:chest"));
let mut scratch = MeshScratch::default();
let mut recycled = SectionMesh::default();
// The same scratch and output buffers survive a resource reload. They must
// contain no references into the previous RenderAssets or MeshTables.
for reload in 0..2 {
let render = assets.reload(&root).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
let mut mesher = Mesher {
nether_shading: false,
assets: &assets,
render: &render,
tables: &tables,
biomes: &biomes,
visibility,
zoom: BiomeZoom::new(42),
blend_radius: 2,
smooth_lighting: reload == 0,
cutout_leaves: reload == 0,
};
for (case, (center, east)) in [
(&empty, None),
(&dense, Some(&neighbor)),
(&sparse, None),
(&empty, None),
(&dense, None),
(&custom, None),
]
.into_iter()
.enumerate()
{
let mut region = [[None; 3]; 3];
region[1][1] = Some(center);
region[1][2] = east;
let mut fresh = mesher.mesh(&region, 0);
recycled = mesher.mesh_reusing(&region, 0, recycled, &mut scratch);
if reload == 0 && case == 0 {
assert!(scratch.own.is_none());
assert_eq!(scratch.padded.blocks.capacity(), 0);
assert_eq!(scratch.padded.light.capacity(), 0);
assert_eq!(scratch.visibility.capacity(), 0);
}
fresh.sort_solid();
recycled.sort_solid();
assert_eq!(
bytemuck::cast_slice::<TerrainVertex, u8>(&fresh.solid),
bytemuck::cast_slice::<TerrainVertex, u8>(&recycled.solid)
);
assert_eq!(
bytemuck::cast_slice::<TerrainVertex, u8>(&fresh.translucent),
bytemuck::cast_slice::<TerrainVertex, u8>(&recycled.translucent)
);
assert_eq!(fresh.block_entities, recycled.block_entities);
assert_eq!(fresh.visibility, recycled.visibility);
assert_eq!(fresh.sides, recycled.sides);
assert_eq!(fresh.bounds, recycled.bounds);
if std::ptr::eq(center, &dense) {
assert!(!fresh.solid.is_empty());
assert!(!fresh.translucent.is_empty());
assert!(!fresh.block_entities.is_empty());
}
if std::ptr::eq(center, &custom) {
assert_eq!(
fresh.block_entities,
[([33, -252, -62], state("minecraft:chest"))]
);
}
}
}
}
/// An isolated scratch-stage comparison, not a whole-mesher or FPS benchmark.
/// Output each sample so the caller can retain the observations and spread.
#[test]
#[ignore = "opt-in scratch microbenchmark; run with --nocapture --test-threads=1"]
fn benchmark_padded_reuse() {
const ITERATIONS: usize = 4096;
let mut chunk = Chunk::empty(0, 0);
for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
chunk.set(x, MIN_Y + y, z, 1 + ((x + y + z) % 7) as u32);
}
}
}
let mut region = [[None; 3]; 3];
region[1][1] = Some(&chunk);
let mut pad = Padded::new(&region, 0);
let mut own = Box::new([0; 4096]);
let pointers = (pad.blocks.as_ptr(), pad.light.as_ptr(), own.as_ptr());
let bytes = pad.blocks.capacity() * 4 + pad.light.capacity() + 4096 * 4;
// Warm both code paths and the process allocator before recording samples.
for _ in 0..1024 {
std::hint::black_box(Padded::new(std::hint::black_box(&region), 0));
pad.fill(std::hint::black_box(&region), 0, &mut own);
std::hint::black_box(&pad);
}
for sample in 0..9 {
// Alternate execution order to expose, rather than hide, order effects.
for reused in [sample % 2 == 0, sample % 2 != 0] {
let start = std::time::Instant::now();
for _ in 0..ITERATIONS {
if reused {
pad.fill(std::hint::black_box(&region), 0, &mut own);
std::hint::black_box(&pad);
} else {
std::hint::black_box(Padded::new(std::hint::black_box(&region), 0));
}
}
println!(
"{{\"sample\":{sample},\"reused\":{reused},\"iterations\":{ITERATIONS},\"elapsed_ms\":{:.6},\"scratch_bytes\":{bytes}}}",
start.elapsed().as_secs_f64() * 1000.0
);
assert_eq!(
pointers,
(pad.blocks.as_ptr(), pad.light.as_ptr(), own.as_ptr())
);
}
}
}
@@ -187,6 +187,21 @@ fn visibility_matches_reference() {
}
}
#[test]
fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() {
let info = [info(false), info(true), info(false), info(true)];
let mut stack = vec![usize::MAX];
for _ in 0..3 {
for (name, pad) in cases() {
assert_eq!(
visibility_reusing(&pad, &info, &mut stack),
reference(&pad, &info),
"{name}"
);
}
}
}
#[test]
fn visibility_preserves_every_face_pair() {
let info = [info(false), info(true), info(false), info(true)];
@@ -12,6 +12,11 @@ use std::{
pub const FILE: &str = "horizon-mods.txt";
/// The folder holding `.hmod` files, beside `options.txt`.
pub const DIR: &str = "horizon-mods";
/// Where the launcher installs native mods (`<id>.hnmod`), beside [`DIR`]. They run only in a
/// game the launcher built with them.
pub const NATIVE_DIR: &str = "horizon-native-mods";
/// Why a native mod installed in [`NATIVE_DIR`] doesn't run in this game.
pub const NOT_BUILT: &str = "Installed, not built into this game: build the game with it in the launcher (Settings > Native mods), then play the instance that build is in.";
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ModList {
@@ -579,6 +579,9 @@ impl MultiLineEdit {
if show_cursor && !insert_cursor && self.within(cursor_y + shift, cursor_y + shift + LINE) {
batch.text(atlas, "_", cursor_x, cursor_y, cursor_color, true);
}
if self.focused {
crate::preedit::report_caret(cursor_x, cursor_y, 10.0);
}
if self.has_selection() {
let (sel_begin, sel_end) = self.selected();
let draw_x = inner_left;
+166
View File
@@ -0,0 +1,166 @@
// Improved transparency (the options screen's order-independent transparency),
// appended to terrain.wgsl. Vanilla 26.3 draws its translucent layer three
// times: once to find each pixel's nearest and furthest see-through depth (the
// depth bounds), once to add each fragment's absorbance into a small wavelet
// transform over that range (the transmittance), and once to weight every
// fragment's colour by what the transform says lies in front of it (the
// accumulation). A last full-screen pass puts the sum over the opaque world.
//
// Eight coefficients in two RGBA16F targets: [0] is the mean absorbance, [1]
// the widest wavelet, [2..4] the next level, [4..8] the finest, one per depth
// bin pair.
struct OitParams {
// x, y: the projection's [2][2] and [3][2], which turn a device depth into
// a distance along the view axis: y / (depth + x).
projection: vec4<f32>,
}
@group(2) @binding(0) var<uniform> oit: OitParams;
@group(2) @binding(1) var oit_bounds: texture_2d<f32>;
@group(2) @binding(2) var oit_coeff0: texture_2d<f32>;
@group(2) @binding(3) var oit_coeff1: texture_2d<f32>;
@group(2) @binding(4) var oit_accumulated: texture_2d<f32>;
const OIT_FULLY_OPAQUE_ALPHA: f32 = 0.99;
const OIT_FULLY_OPAQUE_TRANSMITTANCE: f32 = 0.02;
const OIT_BINS: i32 = 8;
const OIT_LOW_PRECISION_BINS: f32 = 2.0;
const OIT_HIGH_PRECISION_RANGE: f32 = 10.0;
const OIT_ALPHA_CUTOUT: f32 = 0.1;
fn oit_linear_depth(device_depth: f32) -> f32 {
return oit.projection.y / (device_depth + oit.projection.x);
}
fn oit_absorbance(transmittance: f32) -> f32 {
return clamp(-log(max(transmittance, 0.0001)), 0.0, 4.0);
}
fn oit_transmittance(absorbance: f32) -> f32 {
return clamp(exp(-absorbance), 0.0001, 1.0);
}
// Where a fragment sits in its pixel's depth range, 0 to 1. The last
// OIT_LOW_PRECISION_BINS bins stretch over whatever is past the first ten
// blocks, so a thin pane near the eye keeps its resolution in a deep range.
fn oit_normalize_depth(device_depth: f32, pixel: vec2<f32>) -> f32 {
let bounds = textureLoad(oit_bounds, vec2<i32>(pixel), 0);
let nearest = -bounds.r;
let furthest = bounds.g;
let linear = oit_linear_depth(device_depth);
let range = max(furthest - nearest, 0.0001);
let within = clamp(linear - nearest, 0.0, range);
let fine_share = (f32(OIT_BINS) - OIT_LOW_PRECISION_BINS) / f32(OIT_BINS);
let fine_range = fine_share * range;
if fine_range <= OIT_HIGH_PRECISION_RANGE {
return within / range;
}
if within <= OIT_HIGH_PRECISION_RANGE {
return within / OIT_HIGH_PRECISION_RANGE * fine_share;
}
return fine_share + (within - OIT_HIGH_PRECISION_RANGE)
/ (range - OIT_HIGH_PRECISION_RANGE) * (1.0 - fine_share);
}
struct OitCoefficients {
@location(0) first: vec4<f32>,
@location(1) second: vec4<f32>,
}
// One fragment's contribution to the transform (summed by additive blending).
fn oit_add_transmittance(transmittance: f32, frag_depth: f32, pixel: vec2<f32>) -> OitCoefficients {
var c: array<f32, 8>;
let absorbance = oit_absorbance(transmittance);
let original = oit_normalize_depth(frag_depth, pixel);
let depth = original * f32(OIT_BINS - 1) / f32(OIT_BINS);
c[0] = absorbance * (1.0 - depth);
// The wavelet tree's leaves start at index 8.
var tree = i32(floor(original * f32(OIT_BINS - 1))) + OIT_BINS;
for (var level = 0; level <= 2; level++) {
let power = 2 - level;
let parent = tree >> 1u;
let index_at_parent = f32(parent & ((1 << u32(power)) - 1));
let right = tree & 1;
let side = 1 - (right << 1u);
let width = exp2(-f32(power));
let height = exp2(f32(power) * 0.5);
let offset = depth - width * index_at_parent;
c[parent] = (f32(right) * width + f32(side) * offset) * height * absorbance;
tree = parent;
}
return OitCoefficients(vec4(c[0], c[1], c[2], c[3]), vec4(c[4], c[5], c[6], c[7]));
}
// The absorbance in front of `original` (a normalized depth), without the
// fragment's own contribution `current` that the transform already holds.
fn oit_sample_absorbance(coefficients: array<f32, 8>, original: f32, current: f32) -> f32 {
var c = coefficients;
var average = c[0];
if average == 0.0 {
return 0.0;
}
let in_bins = original * f32(OIT_BINS - 1);
let depth = in_bins / f32(OIT_BINS);
average -= current * (1.0 - depth);
// B: the bin the fragment is in; A: the one before it.
var tree_b = clamp(i32(floor(in_bins)), 0, OIT_BINS - 1);
let sample_a_too = tree_b >= 1;
var tree_a = select(tree_b, tree_b - 1, sample_a_too);
tree_b += OIT_BINS;
tree_a += OIT_BINS;
var sample_b = average;
var sample_a = select(0.0, average, sample_a_too);
for (var level = 0; level <= 2; level++) {
let power = 2 - level;
let width = exp2(-f32(power));
let height = exp2(f32(power) * 0.5);
let parent_b = tree_b >> 1u;
let right_b = tree_b & 1;
let side_b = 1 - (right_b << 1u);
let index_at_parent = f32(parent_b & ((1 << u32(power)) - 1));
let offset = depth - width * index_at_parent;
let own = (f32(right_b) * width + f32(side_b) * offset) * height * current;
let difference_b = c[parent_b] - own;
sample_b -= height * difference_b * f32(side_b);
tree_b = parent_b;
if sample_a_too {
let parent_a = tree_a >> 1u;
let difference_a = select(c[parent_a], difference_b, parent_a == parent_b);
let side_a = 1 - ((tree_a & 1) << 1u);
sample_a -= height * difference_a * f32(side_a);
tree_a = parent_a;
}
}
let blend = select(fract(in_bins), 1.0, in_bins >= f32(OIT_BINS));
return mix(sample_a, sample_b, blend);
}
fn oit_sample_transmittance(pixel: vec2<i32>, depth: f32, current: f32) -> f32 {
let a = textureLoad(oit_coeff0, pixel, 0);
let b = textureLoad(oit_coeff1, pixel, 0);
let c = array<f32, 8>(a.x, a.y, a.z, a.w, b.x, b.y, b.z, b.w);
return oit_transmittance(oit_sample_absorbance(c, depth, oit_absorbance(current)));
}
// The three stages' ends for a pipeline whose fragment colour is computed as
// it is without improved transparency (vanilla's `executeAlphaOnlyPhase` and
// `sampleColorForAccumulation`). `color` is the classic result, fogged:
// weighting a fogged colour equals fogging the weighted one, since the fog
// is a mix and vanilla premultiplies the fog colour by the same alpha.
fn oit_bounds_fragment(alpha: f32, clip: vec4<f32>) -> vec4<f32> {
if alpha < 0.01 { discard; }
let linear = oit_linear_depth(clip.z);
let opaque = select(0.0, clip.z, alpha > OIT_FULLY_OPAQUE_ALPHA);
return vec4(-linear, linear, clip.z, opaque);
}
fn oit_transmittance_fragment(alpha: f32, clip: vec4<f32>) -> OitCoefficients {
return oit_add_transmittance(1.0 - alpha, clip.z, clip.xy);
}
fn oit_accumulate_fragment(color: vec4<f32>, clip: vec4<f32>) -> vec4<f32> {
let seen = oit_sample_transmittance(vec2<i32>(clip.xy),
oit_normalize_depth(clip.z, clip.xy), 1.0 - color.a);
return vec4(color.rgb * color.a, color.a) * seen;
}
// `OIT_ADDITIVE`: no coverage and nothing in the transmittance. The colour
// is not fogged yet: the fog colour is premultiplied by the alpha, which is
// zero, so the fog fades it out instead of tinting it.
fn oit_accumulate_additive(color: vec4<f32>, fog: f32, clip: vec4<f32>) -> vec4<f32> {
let seen = oit_sample_transmittance(vec2<i32>(clip.xy),
oit_normalize_depth(clip.z, clip.xy), 1.0);
return vec4(color.rgb * color.a * (1.0 - fog), 0.0) * seen;
}
@@ -0,0 +1,13 @@
// Improved transparency's stages for the world border (`OIT_WORLD_BORDER`:
// `world_border.fsh` with `OIT_ADDITIVE`), appended to border.wgsl after
// oit.wgsl.
@fragment fn fs_oit_border_bounds(in: Out) -> @location(0) vec4<f32> {
return oit_bounds_fragment(border_color(in).a, in.clip);
}
@fragment fn fs_oit_border_transmittance(in: Out) -> OitCoefficients {
discard;
return OitCoefficients(vec4(0.0), vec4(0.0));
}
@fragment fn fs_oit_border_accumulate(in: Out) -> @location(0) vec4<f32> {
return oit_accumulate_additive(border_color(in), 0.0, in.clip);
}
@@ -0,0 +1,28 @@
// Improved transparency's cloud stages (`clouds.fsh` under OIT_*), appended to
// entity.wgsl after oit.wgsl. The clouds' vertex shader and draw are the
// classic ones; only the fragment colour goes through the three stages.
// The cloud's colour; it fades out towards the cloud fog end everywhere but in
// the depth bounds stage, which sees the unfaded alpha.
fn oit_cloud_color(in: Out, front: bool, fade: bool) -> vec4<f32> {
let color = textureSample(atlas, atlas_sampler, in.uv) * select(in.back_color, in.color, front);
let faded = 1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y);
return vec4(color.rgb, color.a * select(1.0, faded, fade));
}
@fragment fn fs_oit_cloud_bounds(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let color = oit_cloud_color(in, front, false);
if color.a < 0.01 { discard; }
let linear = oit_linear_depth(in.clip.z);
let opaque = select(0.0, in.clip.z, color.a > OIT_FULLY_OPAQUE_ALPHA);
return vec4(-linear, linear, in.clip.z, opaque);
}
@fragment fn fs_oit_cloud_transmittance(in: Out, @builtin(front_facing) front: bool) -> OitCoefficients {
let color = oit_cloud_color(in, front, true);
return oit_add_transmittance(1.0 - color.a, in.clip.z, in.clip.xy);
}
@fragment fn fs_oit_cloud_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
let color = oit_cloud_color(in, front, true);
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
return vec4(color.rgb * color.a, color.a) * seen;
}
@@ -0,0 +1,61 @@
// Improved transparency's stages for what the entity shader draws after the
// opaque world (`LevelRenderer.executeOit`'s "Features, World Border,
// Weather" pass), appended to entity.wgsl after oit.wgsl. The colour of each
// is the classic fragment's, so the shaders cannot drift apart.
// Items, particles, text, breaking cracks (`RenderPipelines.OIT_ENTITY` and
// the others on entity.fsh).
@fragment fn fs_oit_entity_bounds(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return oit_bounds_fragment(entity_color(in, front).a, in.clip);
}
@fragment fn fs_oit_entity_transmittance(in: Out, @builtin(front_facing) front: bool) -> OitCoefficients {
return oit_transmittance_fragment(entity_color(in, front).a, in.clip);
}
@fragment fn fs_oit_entity_accumulate(in: Out, @builtin(front_facing) front: bool) -> @location(0) vec4<f32> {
return oit_accumulate_fragment(entity_color(in, front), in.clip);
}
// `OIT_ENTITY_SHADOW`.
@fragment fn fs_oit_shadow_bounds(in: Out) -> @location(0) vec4<f32> {
return oit_bounds_fragment(shadow_color(in).a, in.clip);
}
@fragment fn fs_oit_shadow_transmittance(in: Out) -> OitCoefficients {
return oit_transmittance_fragment(shadow_color(in).a, in.clip);
}
@fragment fn fs_oit_shadow_accumulate(in: Out) -> @location(0) vec4<f32> {
return oit_accumulate_fragment(shadow_color(in), in.clip);
}
// `OIT_WEATHER` (the particle shader).
@fragment fn fs_oit_weather_bounds(in: Out) -> @location(0) vec4<f32> {
return oit_bounds_fragment(weather_color(in).a, in.clip);
}
@fragment fn fs_oit_weather_transmittance(in: Out) -> OitCoefficients {
return oit_transmittance_fragment(weather_color(in).a, in.clip);
}
@fragment fn fs_oit_weather_accumulate(in: Out) -> @location(0) vec4<f32> {
return oit_accumulate_fragment(weather_color(in), in.clip);
}
// `OIT_ENERGY_SWIRL`: entity.fsh with `OIT_ADDITIVE`, which caps the alpha.
@fragment fn fs_oit_swirl_bounds(in: Out) -> @location(0) vec4<f32> {
return oit_bounds_fragment(min(0.99, energy_swirl_base(in).a), in.clip);
}
@fragment fn fs_oit_swirl_transmittance(in: Out) -> OitCoefficients {
discard;
return OitCoefficients(vec4(0.0), vec4(0.0));
}
@fragment fn fs_oit_swirl_accumulate(in: Out) -> @location(0) vec4<f32> {
let color = energy_swirl_base(in);
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), energy_swirl_fog(in), in.clip);
}
// `OIT_LIGHTNING` and `OIT_DRAGON_RAYS`: additive (`OIT_ADDITIVE`), the
// classic fragment's colour (fs_celestial) with the alpha capped.
@fragment fn fs_oit_lightning_bounds(in: Out) -> @location(0) vec4<f32> {
return oit_bounds_fragment(min(0.99, celestial_color(in).a), in.clip);
}
@fragment fn fs_oit_lightning_transmittance(in: Out) -> OitCoefficients {
discard;
return OitCoefficients(vec4(0.0), vec4(0.0));
}
@fragment fn fs_oit_lightning_accumulate(in: Out) -> @location(0) vec4<f32> {
let color = celestial_color(in);
return oit_accumulate_additive(vec4(color.rgb, min(0.99, color.a)), 0.0, in.clip);
}
@@ -0,0 +1,57 @@
// Improved transparency's terrain stages and its screen passes (oit.wgsl
// has the shared maths), appended to terrain.wgsl after oit.wgsl.
// The terrain's translucent layer, once per stage.
fn oit_terrain_color(in: TerrainOut) -> vec4<f32> {
var color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
if color.a < OIT_ALPHA_CUTOUT { discard; }
return color;
}
// Stage 1: (-nearest, furthest, nearest device depth, nearest opaque device
// depth), combined by max blending.
@fragment fn fs_oit_bounds(in: TerrainOut) -> @location(0) vec4<f32> {
let color = oit_terrain_color(in);
let linear = oit_linear_depth(in.clip.z);
let opaque = select(0.0, in.clip.z, color.a > OIT_FULLY_OPAQUE_ALPHA);
return vec4(-linear, linear, in.clip.z, opaque);
}
// Stage 2.
@fragment fn fs_oit_transmittance(in: TerrainOut) -> OitCoefficients {
let color = oit_terrain_color(in);
return oit_add_transmittance(1.0 - color.a, in.clip.z, in.clip.xy);
}
// Stage 3: premultiplied colour, weighted by the transmittance in front.
@fragment fn fs_oit_accumulate(in: TerrainOut) -> @location(0) vec4<f32> {
let color = oit_terrain_color(in);
let seen = oit_sample_transmittance(vec2<i32>(in.clip.xy),
oit_normalize_depth(in.clip.z, in.clip.xy), 1.0 - color.a);
let weighted = vec4(color.rgb * color.a, color.a) * seen;
let f = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
// The fog colour is premultiplied by the same weight.
let rgb = mix(weighted.rgb, globals.fog_color.rgb * weighted.a, f * globals.fog_color.a);
return vec4(rgb, weighted.a);
}
struct OitFragment {
@location(0) color: vec4<f32>,
@builtin(frag_depth) depth: f32,
}
// After the bounds stage: a fully opaque fragment hides what is behind it, so
// the far bound stops there and its depth goes into the depth buffer.
@fragment fn fs_oit_cull(@builtin(position) pos: vec4<f32>) -> OitFragment {
let b = textureLoad(oit_bounds, vec2<i32>(pos.xy), 0);
let opaque = oit_linear_depth(b.a);
return OitFragment(vec4(b.r, min(b.g, opaque), b.b, b.a), b.a);
}
// The sum over the world, renormalized to the coverage the transform gives.
@fragment fn fs_oit_composite(@builtin(position) pos: vec4<f32>) -> OitFragment {
let p = vec2<i32>(pos.xy);
let accumulated = textureLoad(oit_accumulated, p, 0);
let seen = oit_sample_transmittance(p, 100000.0, 1.0);
let coverage = 1.0 - select(seen, 0.0, seen < OIT_FULLY_OPAQUE_TRANSMITTANCE);
// Additive surfaces add light but no coverage.
if coverage < 0.00001 && dot(accumulated.rgb, vec3(1.0)) < 0.00001 { discard; }
let scale = select(1.0, coverage / accumulated.a, accumulated.a > 0.00001);
return OitFragment(vec4(accumulated.rgb * scale, coverage), textureLoad(oit_bounds, p, 0).b);
}
+133 -14
View File
@@ -128,7 +128,6 @@ impl Opt {
| Opt::HideMatchedNames
| Opt::OnlySecureChat
| Opt::SaveChatDrafts
| Opt::MonochromeLogo
| Opt::RotateWithMinecarts
// Saved, but no Horizon system reads them yet: no Realms, no
// friends list, no directional audio, no now-playing toast.
@@ -141,9 +140,6 @@ impl Opt {
| Opt::ChunkFade
| Opt::ImprovedTransparency
| Opt::ChunkBuilder
// Saved, but the terrain sampler has no RGSS or anisotropic filtering yet.
| Opt::TextureFiltering
| Opt::MaxAnisotropy
// Saved, but the mouse doesn't read it yet.
| Opt::CtrlClickEmulatesRightClick
)
@@ -876,6 +872,7 @@ impl Opt {
| Opt::HideLightningFlashes
| Opt::HideSplashTexts
| Opt::HighContrastBlockOutlines
| Opt::MonochromeLogo
| Opt::HideMatchedNames
| Opt::OnlySecureChat
| Opt::SaveChatDrafts
@@ -1181,6 +1178,8 @@ pub enum Page {
Graphics,
GraphicsAdvanced,
GraphicsHdr,
/// HDR calibration, full screen (`calibration`).
HdrCalibration,
/// Horizon's controller settings and button binds.
Controller,
PadBinds,
@@ -1352,10 +1351,17 @@ pub struct OptionsUi {
pub resource_error: Option<String>,
/// When the pack screen's search box took the focus (its cursor blink).
pack_focus: Instant,
/// The selection carried over from a pack screen made again over a changed pack list
/// (`update_pack_infos`) differs from the settings'.
pack_reselected: bool,
pub in_world: bool,
/// 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>,
}
@@ -1387,9 +1393,12 @@ impl Default for OptionsUi {
data_pack_stamp: (Vec::new(), Instant::now()),
resource_error: None,
pack_focus: Instant::now(),
pack_reselected: false,
in_world: false,
center_language: false,
graphics: Default::default(),
calibration: None,
calibration_fullscreen: None,
agent_status: Vec::new(),
}
}
@@ -1418,6 +1427,10 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
if !opt.available() {
return inactive(button(x, y, w, opt.label(s), Act::Cycle(opt)));
}
// VideoSettingsScreen.tick: Max Anisotropy only works with Anisotropic filtering.
if opt == Opt::MaxAnisotropy && s.file.int("textureFiltering") != 2 {
return inactive(button(x, y, w, opt.label(s), Act::Cycle(opt)));
}
match opt.kind(s) {
Kind::Slider(v) => Widget {
x,
@@ -1472,6 +1485,23 @@ impl OptionsUi {
self.settings_search.focused
&& matches!(self.page(), Page::Video | Page::Controls | Page::KeyBinds)
}
/// Whether the resource pack screen is open (not Create World's data packs on the same
/// page).
pub fn resource_packs_open(&self) -> bool {
self.page() == Page::Packs && self.data_pack_dir.is_none()
}
/// The packs in `resourcepacks/` changed while the pack screen is open
/// (`PackSelectionScreen`'s directory watcher): the screen is made again over
/// `infos`, keeping the selection made on it so far.
pub fn update_pack_infos(&mut self, infos: Vec<crate::pack_screen::PackInfo>) {
if let Some(p) = self.pack_screen.take()
&& p.changed
{
self.pack_selection = p.commit();
self.pack_reselected = true;
}
self.pack_infos = infos;
}
pub fn page(&self) -> Page {
*self.stack.last().unwrap_or(&Page::Main)
}
@@ -1580,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",
@@ -1966,7 +1998,9 @@ impl OptionsUi {
.map(|(i, installed)| {
let mut label = Text::literal(format!("{}: ", installed.title))
.append(on_off(s.mods.is_on(i)));
if installed.problem.is_some() {
if installed.problem.as_deref() == Some(crate::mod_list::NOT_BUILT) {
label = label.append(tr("horizon.mods.not_built"));
} else if installed.problem.is_some() {
label = label.append(tr("horizon.mods.stopped"));
} else if installed.dev {
label = label.append(tr("horizon.mods.dev"));
@@ -2587,7 +2621,14 @@ 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
.draw(level, s, batch, atlas, cursor, self.in_world);
return;
@@ -2845,8 +2886,26 @@ 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.
if self.selected_key.is_some() && !drag {
let button = if right {
winit::event::MouseButton::Right
} else {
winit::event::MouseButton::Left
};
return self.bind_mouse(s, button);
}
let mut events = Vec::new();
for action in self
.graphics
@@ -2858,6 +2917,11 @@ impl OptionsUi {
Action::SliderDrag => events.push(Event::SliderDrag),
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
@@ -3084,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()
@@ -3226,7 +3291,7 @@ impl OptionsUi {
} else {
"resourcePack.title"
};
self.pack_screen = Some(crate::pack_screen::PackScreen::new(
let mut screen = crate::pack_screen::PackScreen::new(
&m,
crate::text::Text::translate(title, &[]),
w,
@@ -3234,12 +3299,45 @@ impl OptionsUi {
self.in_world,
self.pack_infos.clone(),
&self.pack_selection,
));
);
screen.changed = std::mem::take(&mut self.pack_reselected);
self.pack_screen = Some(screen);
}
}
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);
@@ -3348,7 +3446,7 @@ impl OptionsUi {
keys.insert(key.clone(), value);
return vec![Event::Changed];
}
if !s.extras.set_key(name, value.clone()) {
if !s.extras.set_key(name, value.clone()) && !s.horizon.set_key(name, value.clone()) {
s.keys.set(name, &value);
}
vec![Event::Changed]
@@ -3371,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;
@@ -3465,10 +3569,12 @@ mod tests {
assert_eq!(loaded.file.double(opt.file_key().unwrap()), expected);
}
}
assert!(Opt::MonochromeLogo.available());
for opt in [
Opt::TextBackground,
Opt::HideLightningFlashes,
Opt::HideSplashTexts,
Opt::MonochromeLogo,
] {
let key = opt.file_key().unwrap();
let before = s.file.bool(key);
@@ -4024,9 +4130,21 @@ mod tests {
assert_eq!(s.file.int("maxAnisotropyBit"), bit, "{preset}");
assert_eq!(s.file.int("textureFiltering"), filtering, "{preset}");
}
// Nothing samples the terrain atlas with them yet, so the buttons stay greyed out.
assert!(!Opt::TextureFiltering.available());
assert!(!Opt::MaxAnisotropy.available());
assert!(Opt::TextureFiltering.available());
assert!(Opt::MaxAnisotropy.available());
// The anisotropy slider only works with Anisotropic filtering (VideoSettingsScreen.tick).
let grey = |s: &Settings| {
matches!(
option_widget(s, Opt::MaxAnisotropy, 0.0, 0.0, 150.0).kind,
WKind::Button { active: false, .. }
)
};
s.file
.set("textureFiltering", crate::options_file::V::Int(1));
assert!(grey(&s));
s.file
.set("textureFiltering", crate::options_file::V::Int(2));
assert!(!grey(&s));
}
#[test]
@@ -4928,7 +5046,7 @@ mod tests {
MouseButton::Back,
MouseButton::Forward,
] {
for name in [SORT_KEY, "key.jump"] {
for name in [SORT_KEY, crate::graphics::HDR_KEY, "key.jump"] {
let mut ui = OptionsUi::default();
let mut s = Settings::default();
ui.selected_key = Some(name);
@@ -4937,6 +5055,7 @@ mod tests {
let value = keybinds::mouse_name(button);
match name {
SORT_KEY => assert_eq!(s.extras.sort_key, value),
crate::graphics::HDR_KEY => assert_eq!(s.horizon.hdr_key, value),
_ => assert_eq!(s.keys.get(name), value),
}
assert_eq!(Extras::parse(&s.extras.serialize()), s.extras);
+79 -24
View File
@@ -414,6 +414,13 @@ fn overlays_of(meta: &serde_json::Value) -> Vec<String> {
.flatten()
.filter(|e| overlay_applies(e))
.filter_map(|e| e["directory"].as_str().map(str::to_owned))
// OverlayMetadataSection's DIR_VALIDATOR: one folder of the pack, `[-_a-zA-Z0-9.]+`.
.filter(|d| {
stays_inside(d)
&& !d.contains('/')
&& d.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b))
})
.collect()
}
fn filter_of(meta: &serde_json::Value) -> Vec<Filter> {
@@ -539,7 +546,7 @@ fn pack_files(pack: &Pack) -> io::Result<BTreeMap<String, Provided>> {
let zip = Zip::open(z)?;
for root in &roots {
let prefix = format!("{root}assets/");
for name in zip.names() {
for name in zip.names().filter(|name| stays_inside(name)) {
if let Some(rest) = name.strip_prefix(&prefix) {
out.insert(format!("assets/{rest}"), (None, name.to_owned()));
}
@@ -549,6 +556,15 @@ fn pack_files(pack: &Pack) -> io::Result<BTreeMap<String, Provided>> {
}
Ok(out)
}
/// Whether a zip entry's name, joined to the overlay folder, stays in it: `/`-separated parts
/// (zip.rs turns `\` into `/`), none empty, `.` or `..`, and no `:` (a drive or a stream on
/// Windows). A pack, a server's too, may name entries anything.
fn stays_inside(name: &str) -> bool {
!name.contains([':', '\0'])
&& name
.split('/')
.all(|part| !part.is_empty() && part != "." && part != "..")
}
#[cfg(unix)]
fn link(target: &Path, at: &Path) -> io::Result<()> {
// Relative sources are relative to the process, not the overlay directory.
@@ -566,31 +582,11 @@ fn link(target: &Path, at: &Path) -> io::Result<()> {
fs::hard_link(target, at).or_else(|_| fs::copy(target, at).map(|_| ()))
}
}
/// A directory junction, which Windows creates without privileges (a directory symlink needs
/// developer mode). The mirror falls back to copying the tree when it fails.
/// A directory junction (`horizon_threads::junction`); the mirror falls back to copying the
/// tree when it fails.
#[cfg(windows)]
fn junction(target: &Path, at: &Path) -> io::Result<()> {
use std::os::windows::process::CommandExt;
// cmd parses its own line: Rust quotes only paths with spaces, so `&` or `^` in an
// unquoted path would split or change the command. A Windows path cannot hold `"`.
let quoted = |path: &Path| -> io::Result<std::ffi::OsString> {
let mut quoted = std::ffi::OsString::from("\"");
quoted.push(std::path::absolute(path)?);
quoted.push("\"");
Ok(quoted)
};
let status = std::process::Command::new("cmd")
.args(["/C", "mklink", "/J"])
.raw_arg(quoted(at)?)
.raw_arg(quoted(target)?)
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()?;
status
.success()
.then_some(())
.ok_or_else(|| io::Error::other("mklink /J failed"))
horizon_threads::junction(target, at)
}
#[cfg(not(windows))]
fn junction(_: &Path, _: &Path) -> io::Result<()> {
@@ -1422,6 +1418,56 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn zip_entries_never_leave_the_overlay() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-escape-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let vanilla = base.join("vanilla");
fs::create_dir_all(vanilla.join("assets/minecraft/textures/block")).unwrap();
fs::write(
vanilla.join("assets/minecraft/textures/block/stone.png"),
b"vanilla",
)
.unwrap();
fs::create_dir_all(base.join("packs")).unwrap();
let mut zip = zip::ZipWriter::new(fs::File::create(base.join("packs/evil.zip")).unwrap());
for (name, data) in [
(
"pack.mcmeta",
&br#"{"pack":{"description":"d","pack_format":97}}"#[..],
),
("assets/minecraft/textures/block/stone.png", b"pack"),
("assets/../../escaped.txt", b"out"),
("assets/minecraft/../../../escaped_lang.json", b"{}"),
("assets\\..\\..\\escaped_backslash.txt", b"out"),
("assets/./minecraft/dot.txt", b"in"),
] {
zip.start_file(name, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(data).unwrap();
}
zip.finish().unwrap();
let packs = discover(&vanilla, &base.join("packs"));
assert_eq!(packs.len(), 1);
let out = base.join("work/overlay");
build_overlay(&vanilla, &packs, &out).unwrap();
assert_eq!(
fs::read(out.join("assets/minecraft/textures/block/stone.png")).unwrap(),
b"pack"
);
for escaped in [
"escaped.txt",
"escaped_lang.json",
"escaped_backslash.txt",
"work/escaped.txt",
"work/escaped_lang.json",
"work/escaped_backslash.txt",
] {
assert!(!base.join(escaped).exists(), "{escaped} was written");
}
let _ = fs::remove_dir_all(base);
}
#[test]
fn built_in_and_folder_packs_give_their_pack_png() {
use std::io::Write as _;
let base = std::env::temp_dir().join(format!("horizon-pack-icons-{}", std::process::id()));
@@ -1469,6 +1515,15 @@ mod tests {
{"min_format":97,"max_format":[97,5],"directory":"also"},
{"formats":50,"directory":"no"}]}}"#);
assert_eq!(overlays_of(&meta), ["new", "also"]);
// Only one plain folder of the pack, as vanilla's DIR_VALIDATOR has it.
let meta = v(r#"{"overlays":{"entries":[
{"formats":97,"directory":".."},
{"formats":97,"directory":"../outside"},
{"formats":97,"directory":"/etc"},
{"formats":97,"directory":"a/b"},
{"formats":97,"directory":"C:"},
{"formats":97,"directory":"ok_1.21-x"}]}}"#);
assert_eq!(overlays_of(&meta), ["ok_1.21-x"]);
let base = std::env::temp_dir().join(format!("horizon-packs-ov-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let vanilla = base.join("vanilla");
+32 -15
View File
@@ -9,7 +9,9 @@ use std::{collections::BTreeSet, fs, io, path::Path, path::PathBuf};
// Bump when overlay/merge semantics change. Markers are disposable cache data.
// Version 4 mirrored every untouched directory file by file on Windows.
// Version 3 could omit untouched vanilla directories on Windows (#1649, #1688).
const VERSION: u32 = 5;
// Version 5 read every vanilla file to stamp it on Windows.
// Version 6 stamps vanilla's files by their metadata on Windows too.
const VERSION: u32 = 6;
#[derive(Default, Serialize, Deserialize)]
pub(super) struct Dependencies {
@@ -18,13 +20,16 @@ pub(super) struct Dependencies {
failed: bool,
#[serde(skip)]
tracking: bool,
/// The vanilla root (absolute): its files are stamped without reading them.
#[serde(skip)]
vanilla: PathBuf,
}
impl Dependencies {
fn record(&mut self, path: &Path, directory: bool) {
if !self.tracking {
return;
}
match dependency_signature(path, directory) {
match dependency_signature(path, directory, &self.vanilla) {
Ok(signature) => self.entries.push((path.into(), directory, signature)),
Err(_) => self.failed = true,
}
@@ -38,7 +43,7 @@ impl Dependencies {
fn valid(&self) -> bool {
!self.failed
&& self.entries.iter().all(|(path, directory, signature)| {
dependency_signature(path, *directory).is_ok_and(|s| s == *signature)
dependency_signature(path, *directory, &self.vanilla).is_ok_and(|s| s == *signature)
})
}
}
@@ -72,13 +77,16 @@ fn content(hash: &mut Sha256, path: &Path) -> io::Result<()> {
}
Ok(())
}
fn stamp(hash: &mut Sha256, path: &Path) -> io::Result<()> {
/// `path`'s stamp; `read` lets platforms without a change timestamp hash a file's bytes.
fn stamp(hash: &mut Sha256, path: &Path, read: bool) -> io::Result<()> {
let meta = fs::metadata(path)?;
hash.update(meta.len().to_le_bytes());
field(hash, format!("{:?}", meta.modified()?).as_bytes());
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
// The change time stands in for the bytes here.
let _ = read;
hash.update(meta.dev().to_le_bytes());
hash.update(meta.ino().to_le_bytes());
hash.update(meta.ctime().to_le_bytes());
@@ -87,8 +95,11 @@ fn stamp(hash: &mut Sha256, path: &Path) -> io::Result<()> {
#[cfg(not(unix))]
{
// Portable metadata lacks a change timestamp. Preserve content-based
// validation for materialized files on those platforms.
if meta.is_file() {
// validation for the packs' materialized files on those platforms;
// vanilla's (the launcher's, tens of thousands) keep to the size and
// the write and creation times: reading each costs minutes on Windows,
// where every open is scanned by the antivirus.
if read && meta.is_file() {
return content(hash, path);
}
if let Ok(created) = meta.created() {
@@ -104,17 +115,18 @@ fn entries(path: &Path) -> io::Result<Vec<PathBuf>> {
entries.sort();
Ok(entries)
}
fn dependency_signature(path: &Path, directory: bool) -> io::Result<String> {
fn dependency_signature(path: &Path, directory: bool, vanilla: &Path) -> io::Result<String> {
let mut hash = Sha256::new();
let read = !(path.starts_with(vanilla) || absolute(path).is_ok_and(|p| p.starts_with(vanilla)));
match fs::metadata(path) {
Err(e) if e.kind() == io::ErrorKind::NotFound => field(&mut hash, b"missing"),
Err(e) => return Err(e),
Ok(meta) if directory && meta.is_dir() => {
field(&mut hash, b"directory");
stamp(&mut hash, path)?;
stamp(&mut hash, path, read)?;
}
Ok(_) if directory => field(&mut hash, b"not-directory"),
Ok(_) => stamp(&mut hash, path)?,
Ok(_) => stamp(&mut hash, path, read)?,
}
Ok(finish(hash))
}
@@ -190,7 +202,7 @@ fn tree_inner(
if pack.is_none() {
return content(hash, path);
}
stamp(hash, path)?;
stamp(hash, path, true)?;
}
}
Ok(())
@@ -283,11 +295,14 @@ pub(super) fn build(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()
let signature = pack_signature(packs);
if let Ok(signature) = &signature
&& let Ok(bytes) = fs::read(&marker_path)
&& let Ok(marker) = serde_json::from_slice::<Marker>(&bytes)
&& let Ok(mut marker) = serde_json::from_slice::<Marker>(&bytes)
&& marker.version == VERSION
&& marker.vanilla == vanilla_key
&& marker.packs == *signature
&& marker.dependencies.valid()
&& {
marker.dependencies.vanilla = vanilla_key.clone();
marker.dependencies.valid()
}
&& out.is_dir()
{
return Ok(());
@@ -300,6 +315,7 @@ pub(super) fn build(vanilla: &Path, packs: &[Pack], out: &Path) -> io::Result<()
}
let mut dependencies = Dependencies {
tracking: signature.is_ok(),
vanilla: vanilla_key.clone(),
..Dependencies::default()
};
build_overlay_uncached(vanilla, packs, out, &mut dependencies)?;
@@ -642,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!(
@@ -1064,8 +1064,9 @@ pub struct ParticleEngine {
pub particle_level: u8,
/// FireworkParticles.Starter instances.
starters: Vec<Starter>,
/// Local sounds requested by particles (dripstone drips): (event, position, volume, pitch).
pub sounds: Vec<(&'static str, [f64; 3], f32, f32)>,
/// Local sounds requested by particles (dripstone drips, firework bursts):
/// (event, position, volume, pitch, distanceDelay).
pub sounds: Vec<(&'static str, [f64; 3], f32, f32, bool)>,
/// How many of each type were made, for `horizon-ctl status`.
made: std::collections::BTreeMap<&'static str, u64>,
/// The item particles' sprites (`BreakingItemParticle`'s slime ball, snowball
@@ -3059,7 +3060,7 @@ impl ParticleEngine {
(false, false) => "minecraft:entity.firework_rocket.blast",
};
let pitch = 0.95 + self.random.next_float() * 0.1;
self.sounds.push((sound, s.at, 20.0, pitch));
self.sounds.push((sound, s.at, 20.0, pitch, true));
}
if s.life % 2 == 0 && ((s.life / 2) as usize) < s.explosions.len() {
let e = s.explosions[(s.life / 2) as usize].clone();
@@ -3110,7 +3111,7 @@ impl ParticleEngine {
"minecraft:entity.firework_rocket.twinkle"
};
let pitch = 0.9 + self.random.next_float() * 0.15;
self.sounds.push((sound, s.at, 20.0, pitch));
self.sounds.push((sound, s.at, 20.0, pitch, true));
}
continue;
}
@@ -3305,7 +3306,7 @@ impl ParticleEngine {
}
if let Some(sound) = sound {
let volume = p.random.next_float() * 0.7 + 0.3;
self.sounds.push((sound, p.position, volume, 1.0));
self.sounds.push((sound, p.position, volume, 1.0, false));
}
}
_ => {}
File diff suppressed because it is too large. Load diff
@@ -1,8 +1,9 @@
// The built-in stand-ins for the vanilla post shaders a `post_effect` chain can
// name (`core/screenquad`, `post/rotscale`, `post/invert`, `post/blit`,
// `post/bits`, `post/color_convolve`, `post/box_blur`, `post/spiderclip`).
// Coordinates stay in the chain's own convention (v up from the bottom edge) and
// are flipped only where a texture is read.
// Targets keep GL's rows (row 0 is the bottom edge): the passes draw with clip y
// negated (`vs_quad_gl`, `vs_rotscale`) and read their textures as they are;
// `vs_quad` with `fs_blit` is the flip between the frame and a chain's `main`.
// OutSize, InSize | BlurSize, menu blur radius.
@group(0) @binding(0) var<uniform> info: array<vec4<f32>, 2>;
@@ -27,10 +28,10 @@ fn fcf(i: u32) -> f32 {
return fs_cfg[i / 4u][i % 4u];
}
fn read_in(uv: vec2<f32>) -> vec4<f32> {
return textureSampleLevel(in_tex, in_smp, vec2(uv.x, 1.0 - uv.y), 0.0);
return textureSampleLevel(in_tex, in_smp, uv, 0.0);
}
fn read_blur(uv: vec2<f32>) -> vec4<f32> {
return textureSampleLevel(blur_tex, blur_smp, vec2(uv.x, 1.0 - uv.y), 0.0);
return textureSampleLevel(blur_tex, blur_smp, uv, 0.0);
}
fn corner(i: u32) -> vec2<f32> {
@@ -46,10 +47,19 @@ fn corner(i: u32) -> vec2<f32> {
return out;
}
@vertex fn vs_quad_gl(@builtin(vertex_index) i: u32) -> Out {
let p = corner(i);
var out: Out;
out.clip = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
out.uv = p;
out.scaled = p;
return out;
}
@vertex fn vs_rotscale(@builtin(vertex_index) i: u32) -> Out {
let p = corner(i);
var out: Out;
out.clip = vec4(p * 2.0 - 1.0, 0.0, 1.0);
out.clip = vec4(p.x * 2.0 - 1.0, 1.0 - p.y * 2.0, 0.0, 1.0);
out.uv = p;
let radians = vcf(4u) * 0.0174532925;
let c = cos(radians);
@@ -0,0 +1,316 @@
//! Vanilla's post-processing GLSL (`#version 330` with `layout(std140)` uniform blocks
//! and `uniform sampler2D`s, Vulkan-style `gl_VertexIndex`) turned into naga modules at
//! run time, from whichever resource root is active, the way Horizon's shader packs turn
//! their GLSL into naga (`horizon-shaders`' translator, which handles the compatibility
//! dialect; the post shaders need only the block and sampler binding rewrite below).
//!
//! `ShaderManager`/`GlslPreprocessor` read `assets/<ns>/shaders/<path>.{vsh,fsh}` and
//! resolve `#include <ns:path>` to `assets/<ns>/shaders/include/<path>`.
//!
//! The rewrite gives every uniform block a binding of bind group 0, shared by both
//! stages, and splits each `uniform sampler2D NameSampler;` into a texture and a sampler
//! object at the next two bindings, `NameSampler` becoming the combined
//! `sampler2D(texture, sampler)` again where it is used. The vertex stage's `main` is
//! wrapped so that clip y is negated: targets keep GL's rows (row 0 is the bottom edge),
//! so `texCoord` reads the texel it would in vanilla.
use naga::ShaderStage;
use naga::front::glsl::{Frontend, Options};
use naga::valid::{Capabilities, ValidationFlags, Validator};
use std::path::Path;
/// One program, ready for wgpu.
pub struct Program {
pub vertex: naga::Module,
pub fragment: naga::Module,
/// Every uniform block either stage declares, with its binding.
pub blocks: Vec<(String, u32)>,
/// Every sampler (`NameSampler`): the texture's binding; the sampler object is the next.
pub samplers: Vec<(String, u32)>,
}
fn is_ident(c: u8) -> bool {
c.is_ascii_alphanumeric() || c == b'_'
}
/// The source with its comments blanked (same length, so offsets stay valid).
fn blank_comments(src: &str) -> String {
let b = src.as_bytes();
let mut out = b.to_vec();
let mut i = 0;
while i < b.len() {
if b[i] == b'/' && b.get(i + 1) == Some(&b'/') {
while i < b.len() && b[i] != b'\n' {
out[i] = b' ';
i += 1;
}
} else if b[i] == b'/' && b.get(i + 1) == Some(&b'*') {
while i < b.len() && !(b[i] == b'*' && b.get(i + 1) == Some(&b'/')) {
if b[i] != b'\n' {
out[i] = b' ';
}
i += 1;
}
for slot in out.iter_mut().take((i + 2).min(b.len())).skip(i) {
*slot = b' ';
}
i += 2;
} else {
i += 1;
}
}
String::from_utf8(out).unwrap_or_default()
}
/// `GlslPreprocessor`'s `#include <ns:path>`.
fn read_source(root: &Path, id: &str, ext: &str) -> Result<String, String> {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
let file = root
.join("assets")
.join(ns)
.join("shaders")
.join(format!("{path}.{ext}"));
std::fs::read_to_string(&file).map_err(|e| format!("{}: {e}", file.display()))
}
fn include(root: &Path, src: &str, depth: u32) -> Result<String, String> {
if depth > 8 {
return Err("#include nested too deep".into());
}
let mut out = String::new();
for line in src.lines() {
let t = line.trim_start();
if let Some(rest) = t.strip_prefix("#include") {
let name = rest
.trim()
.trim_start_matches(['<', '"'])
.trim_end_matches(['>', '"'])
.trim();
let (ns, path) = name.split_once(':').unwrap_or(("minecraft", name));
let file = root
.join("assets")
.join(ns)
.join("shaders")
.join("include")
.join(path);
let text = std::fs::read_to_string(&file)
.map_err(|e| format!("#include <{name}>: {}: {e}", file.display()))?;
out.push_str(&include(root, &text, depth + 1)?);
} else if t.starts_with("#version") || t.starts_with("#extension") {
// Re-added below, for the translator.
out.push('\n');
continue;
} else {
out.push_str(line);
}
out.push('\n');
}
Ok(out)
}
/// Every `uniform` declaration of a source: (start of the declaration including a
/// `layout(...)` before it, end of the type or of the whole `;`, type, name, is a sampler).
fn declarations(src: &str) -> Vec<(usize, usize, String, String, bool)> {
let plain = blank_comments(src);
let b = plain.as_bytes();
let mut out = Vec::new();
let mut at = 0;
while let Some(found) = plain[at..].find("uniform") {
let start = at + found;
at = start + 7;
if (start > 0 && is_ident(b[start - 1])) || b.get(start + 7).is_some_and(|c| is_ident(*c)) {
continue;
}
let word = |from: usize| -> Option<(usize, usize)> {
let mut s = from;
while s < b.len() && b[s].is_ascii_whitespace() {
s += 1;
}
let mut e = s;
while e < b.len() && is_ident(b[e]) {
e += 1;
}
(e > s).then_some((s, e))
};
let Some((ts, te)) = word(start + 7) else {
continue;
};
let ty = plain[ts..te].to_owned();
// The `layout(...)` right before `uniform`, if any.
let before = plain[..start].trim_end();
let decl_start = before
.ends_with(')')
.then(|| before.rfind("layout"))
.flatten()
.filter(|l| {
before[*l + 6..].trim_start().starts_with('(') && !before[*l..].contains(';')
})
.unwrap_or(start);
if ty.starts_with("sampler") {
let Some((ns, ne)) = word(te) else { continue };
let end = plain[ne..].find(';').map_or(ne, |p| ne + p + 1);
out.push((decl_start, end, ty, plain[ns..ne].to_owned(), true));
} else {
let mut s = te;
while s < b.len() && b[s].is_ascii_whitespace() {
s += 1;
}
if b.get(s) == Some(&b'{') {
out.push((decl_start, te, String::new(), ty, false));
}
}
}
out
}
/// The source with each declaration given its binding from the shared tables.
fn rewrite(src: &str, blocks: &[(String, u32)], samplers: &[(String, u32)]) -> String {
let mut out = String::new();
let mut pos = 0;
for (start, end, ty, name, sampler) in declarations(src) {
out.push_str(&src[pos..start]);
pos = end;
if sampler {
let tex = samplers.iter().find(|(n, _)| *n == name).map_or(0, |s| s.1);
let kind = if ty == "sampler2D" { "texture2D" } else { &ty };
out.push_str(&format!(
"layout(set = 0, binding = {tex}) uniform {kind} {name}_hz_tex; \
layout(set = 0, binding = {}) uniform sampler {name}_hz_smp;\n\
#define {name} sampler2D({name}_hz_tex, {name}_hz_smp)\n",
tex + 1
));
} else {
let binding = blocks.iter().find(|(n, _)| *n == name).map_or(0, |s| s.1);
out.push_str(&format!(
"layout(std140, set = 0, binding = {binding}) uniform {name}"
));
}
}
out.push_str(&src[pos..]);
out
}
/// Replaces the word `main` of `void main(` with `hz_main`.
fn wrap_main(src: &str) -> Result<String, String> {
let plain = blank_comments(src);
let mut at = 0;
while let Some(found) = plain[at..].find("main") {
let p = at + found;
at = p + 4;
let b = plain.as_bytes();
if (p > 0 && is_ident(b[p - 1])) || b.get(p + 4).is_some_and(|c| is_ident(*c)) {
continue;
}
if plain[..p].trim_end().ends_with("void") && plain[p + 4..].trim_start().starts_with('(') {
let mut out = String::with_capacity(src.len() + 80);
out.push_str(&src[..p]);
out.push_str("hz_main");
out.push_str(&src[p + 4..]);
out.push_str("\nvoid main() { hz_main(); gl_Position.y = -gl_Position.y; }\n");
return Ok(out);
}
}
Err("no main".into())
}
fn parse(stage: ShaderStage, text: &str) -> Result<naga::Module, String> {
let module = Frontend::default()
.parse(&Options::from(stage), text)
.map_err(|e| {
e.errors
.iter()
.map(|e| e.kind.to_string())
.collect::<Vec<_>>()
.join("; ")
})?;
Validator::new(ValidationFlags::all(), Capabilities::empty())
.validate(&module)
.map_err(|e| format!("{:?}", e.into_inner()))?;
Ok(module)
}
/// Translates the vertex and fragment shader a pass names (`minecraft:post/invert`).
pub fn translate(root: &Path, vertex: &str, fragment: &str) -> Result<Program, String> {
let vs = include(root, &read_source(root, vertex, "vsh")?, 0)?;
let fs = include(root, &read_source(root, fragment, "fsh")?, 0)?;
// Blocks and samplers are shared by name across the stages, so both agree on a binding.
let mut blocks: Vec<(String, u32)> = Vec::new();
let mut names: Vec<String> = Vec::new();
for src in [&vs, &fs] {
for (_, _, _, name, sampler) in declarations(src) {
if sampler {
if !names.contains(&name) {
names.push(name);
}
} else if !blocks.iter().any(|(n, _)| *n == name) {
blocks.push((name, blocks.len() as u32));
}
}
}
let first = blocks.len() as u32;
let samplers: Vec<(String, u32)> = names.into_iter().zip((first..).step_by(2)).collect();
let vs = wrap_main(&rewrite(&vs, &blocks, &samplers))?;
let fs = rewrite(&fs, &blocks, &samplers);
let vertex_module = parse(ShaderStage::Vertex, &format!("#version 450\n{vs}"))
.map_err(|e| format!("{vertex}: {e}"))?;
let fragment_module = parse(ShaderStage::Fragment, &format!("#version 450\n{fs}"))
.map_err(|e| format!("{fragment}: {e}"))?;
Ok(Program {
vertex: vertex_module,
fragment: fragment_module,
blocks,
samplers,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn root() -> Option<std::path::PathBuf> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.ancestors()
.nth(2)?
.join(".local/horizon-client/26.3");
dir.join("assets/minecraft/shaders/post/blit.fsh")
.exists()
.then_some(dir)
}
#[test]
fn vanilla_post_shaders_translate() {
let Some(root) = root() else { return };
let vs = ["minecraft:core/screenquad", "minecraft:post/rotscale"];
let fs = [
"bits",
"blit",
"box_blur",
"color_convolve",
"entity_outline_box_blur",
"entity_sobel",
"invert",
"spiderclip",
];
for f in fs {
for v in vs {
let name = format!("minecraft:post/{f}");
let p = translate(&root, v, &name);
// rotscale's outputs only match spiderclip, but each stage translates alone.
if let Err(e) = &p {
panic!("{v} + {name}: {e}");
}
}
}
}
#[test]
fn sampler_and_block_bindings() {
let src = "layout(std140) uniform A {\n float x;\n};\nuniform sampler2D InSampler; // c\nvoid main(){}\n";
let d = declarations(src);
assert_eq!(d.len(), 2);
let out = rewrite(src, &[("A".into(), 0)], &[("InSampler".into(), 1)]);
assert!(out.contains("layout(std140, set = 0, binding = 0) uniform A {"));
assert!(out.contains("binding = 1) uniform texture2D InSampler_hz_tex;"));
assert!(out.contains("binding = 2) uniform sampler InSampler_hz_smp;"));
assert!(wrap_main("void main() {}").unwrap().contains("hz_main"));
}
}
+304
View File
@@ -0,0 +1,304 @@
//! Input method composition text (`PreeditEvent`, `IMEPreeditOverlay`,
//! `TextInputManager`): the text an input method is still composing, shown
//! under the focused text field's caret, and the area the system's candidate
//! window is placed at.
//!
//! Vanilla hands each `PreeditEvent` to the focused widget, which keeps an
//! overlay and reports its caret every frame. Horizon draws its fields from
//! many places, so the event is kept here and every focused field calls
//! [`report_caret`] where it draws its caret; the game draws the overlay last
//! with [`draw_overlay`] and applies [`take_text_input_area`] to the window.
use crate::{
hud::{HudAtlas, HudBatch},
text::{Style, Text},
ui::nine_slice,
};
use std::{sync::Mutex, time::Instant};
/// `PreeditEvent`: the composition, its caret and the blocks of text the
/// input method set apart (the one being converted is `focused_block`).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PreeditEvent {
pub full_text: String,
/// Byte offset of the caret in `full_text` (vanilla's UTF-16 offset).
pub caret_position: usize,
pub blocks: Vec<String>,
pub focused_block: usize,
}
impl PreeditEvent {
/// `PreeditEvent.fromSdlTextEditing`: `selection_start` and
/// `selection_length` are in code points; a negative start puts the caret
/// at the end. Empty text is no composition.
pub fn from_sdl_text_editing(
text: Option<&str>,
selection_start: i32,
selection_length: i32,
) -> Option<Self> {
let text = text.filter(|t| !t.is_empty())?;
let total = text.chars().count();
let caret_codepoint = if selection_start < 0 {
total
} else {
(selection_start as usize).min(total)
};
let caret_char = offset_by_codepoints(text, caret_codepoint);
let single = |caret| Self {
full_text: text.to_owned(),
caret_position: caret,
blocks: vec![text.to_owned()],
focused_block: 0,
};
if selection_length <= 0 {
return Some(single(caret_char));
}
let end_codepoint = (caret_codepoint + selection_length as usize).min(total);
let end_char = offset_by_codepoints(text, end_codepoint);
if end_char <= caret_char {
return Some(single(caret_char));
}
let mut blocks = Vec::new();
let mut focused_block = 0;
if caret_char > 0 {
blocks.push(text[..caret_char].to_owned());
focused_block = 1;
}
blocks.push(text[caret_char..end_char].to_owned());
if end_char < text.len() {
blocks.push(text[end_char..].to_owned());
}
Some(Self {
full_text: text.to_owned(),
caret_position: caret_char,
blocks,
focused_block,
})
}
/// winit's `Ime::Preedit`: `cursor` is the byte range of the part being
/// converted, `None` when the input method hides its cursor. SDL's
/// `TEXTEDITING` says the same in code points.
pub fn from_winit(text: &str, cursor: Option<(usize, usize)>) -> Option<Self> {
let (start, length) = match cursor {
Some((from, to)) => {
let from = from.min(text.len());
let to = to.clamp(from, text.len());
let start = text.get(..from).map_or(-1, |s| s.chars().count() as i32);
let length = text.get(from..to).map_or(0, |s| s.chars().count() as i32);
(start, length)
}
None => (-1, 0),
};
Self::from_sdl_text_editing(Some(text), start, length)
}
/// `PreeditEvent.toFormattedText`.
pub fn to_formatted_text(&self, focused_style: &Style) -> Text {
if self.blocks.len() == 1 {
return Text::literal(self.blocks[0].clone()).with_style(focused_style);
}
let mut result = Text::empty();
for (i, block) in self.blocks.iter().enumerate() {
let mut part = Text::literal(block.clone());
if i == self.focused_block {
part = part.with_style(focused_style);
}
result.push(part);
}
result
}
}
/// `String.offsetByCodePoints(0, n)`: the byte offset after `n` characters.
fn offset_by_codepoints(text: &str, n: usize) -> usize {
text.char_indices().nth(n).map_or(text.len(), |(i, _)| i)
}
/// The caret a field reported this frame, in GUI pixels.
#[derive(Clone, Copy, Debug, PartialEq)]
struct Caret {
x: f32,
y: f32,
input_height: f32,
}
#[derive(Default)]
struct State {
/// `KeyboardHandler.lastPreeditEvent`, with when the overlay was created.
preedit: Option<(PreeditEvent, Instant)>,
caret: Option<Caret>,
area: Option<[f32; 4]>,
}
static STATE: Mutex<State> = Mutex::new(State {
preedit: None,
caret: None,
area: None,
});
fn state() -> std::sync::MutexGuard<'static, State> {
STATE.lock().unwrap_or_else(|e| e.into_inner())
}
/// `KeyboardHandler.textEditing`: the input method's composition changed
/// (`None`: it ended). A new `IMEPreeditOverlay` restarts the caret blink.
pub fn set_preedit(event: Option<PreeditEvent>) {
let mut s = state();
s.preedit = event.map(|e| (e, Instant::now()));
}
/// Whether text is being composed.
pub fn composing() -> bool {
state().preedit.is_some()
}
/// What the focused field's `extractRenderState` ends with: its caret is at
/// (`x`, `y`) in GUI pixels and `input_height` tall (a line plus one).
/// Without a composition that is the area for the candidate window; with one
/// it is where the overlay goes ([`draw_overlay`]).
pub fn report_caret(x: f32, y: f32, input_height: f32) {
state().caret = Some(Caret { x, y, input_height });
}
/// `IMEPreeditOverlay.extractRenderState` for the caret reported this frame,
/// drawn on top of everything in `batch`. Without a composition the caret is
/// the text input area (`TextInputManager.setTextInputArea`).
pub fn draw_overlay(batch: &mut HudBatch, atlas: &HudAtlas) {
let mut s = state();
let Some(caret) = s.caret.take() else {
return;
};
let Some((event, init)) = s.preedit.as_ref() else {
s.area = Some([
caret.x,
caret.y,
caret.x + 1.0,
caret.y + caret.input_height,
]);
return;
};
let font = &atlas.font;
let focused = Style {
underlined: Some(true),
..Style::default()
};
let black = 0xFF00_0000;
let text = event.to_formatted_text(&focused).with_color(black);
let width = font.width_text(&text);
let caret_pos = font.width(&event.full_text[..event.caret_position]);
let blink_visible = (init.elapsed().as_millis() / 300).is_multiple_of(2);
let line = 9.0;
let (gui_w, gui_h) = (batch.width, batch.height);
let mut left = caret.x;
let mut right = left + width;
if right > gui_w {
left = gui_w - width;
right = left + width;
}
let input_bottom = caret.y + caret.input_height;
let mut bottom = input_bottom + 4.0 + line;
if bottom > gui_h {
bottom = caret.y - 4.0 - line;
}
let top = bottom - line;
s.area = Some([left, top, right, bottom]);
drop(s);
nine_slice(
batch,
atlas,
"gui/sprites/widget/preedit",
left - 5.0,
top - 5.0,
right - left + 10.0,
bottom - top + 10.0,
u32::MAX,
);
batch.text_styled(atlas, &text, left, top, black, false);
if blink_visible {
batch.fill(atlas, left + caret_pos, top - 1.0, 1.0, 11.0, black);
}
}
/// The text input area (`x0, y0, x1, y1` in GUI pixels) set since the last
/// call, for `Window.set_ime_cursor_area`.
pub fn take_text_input_area() -> Option<[f32; 4]> {
state().area.take()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_text_is_no_composition() {
assert_eq!(PreeditEvent::from_sdl_text_editing(None, 0, 0), None);
assert_eq!(PreeditEvent::from_sdl_text_editing(Some(""), 0, 0), None);
}
#[test]
fn a_negative_start_puts_the_caret_at_the_end() {
let e = PreeditEvent::from_sdl_text_editing(Some("abc"), -1, 0).unwrap();
assert_eq!(
(e.caret_position, e.blocks, e.focused_block),
(3, vec!["abc".into()], 0)
);
}
#[test]
fn the_caret_counts_code_points() {
// Two code points before the caret, the second outside the BMP.
let e = PreeditEvent::from_sdl_text_editing(Some("a\u{1F600}b"), 2, 0).unwrap();
assert_eq!(e.caret_position, "a\u{1F600}".len());
let past = PreeditEvent::from_sdl_text_editing(Some("ab"), 9, 0).unwrap();
assert_eq!(past.caret_position, 2);
}
#[test]
fn a_selection_splits_the_text_into_blocks() {
let e = PreeditEvent::from_sdl_text_editing(Some("abcdef"), 2, 2).unwrap();
assert_eq!(e.blocks, ["ab", "cd", "ef"]);
assert_eq!((e.focused_block, e.caret_position), (1, 2));
let first = PreeditEvent::from_sdl_text_editing(Some("abcdef"), 0, 3).unwrap();
assert_eq!(first.blocks, ["abc", "def"]);
assert_eq!(first.focused_block, 0);
let last = PreeditEvent::from_sdl_text_editing(Some("abcdef"), 3, 99).unwrap();
assert_eq!(last.blocks, ["abc", "def"]);
assert_eq!(last.focused_block, 1);
}
#[test]
fn winit_byte_ranges_become_code_points() {
let text = "\u{3042}\u{3044}\u{3046}";
// Converting the middle character (bytes 3..6).
let e = PreeditEvent::from_winit(text, Some((3, 6))).unwrap();
assert_eq!(e.blocks, ["\u{3042}", "\u{3044}", "\u{3046}"]);
assert_eq!(e.focused_block, 1);
// A hidden cursor sits at the end.
let hidden = PreeditEvent::from_winit(text, None).unwrap();
assert_eq!(hidden.caret_position, text.len());
assert_eq!(hidden.blocks.len(), 1);
}
#[test]
fn only_the_focused_block_is_underlined() {
let focused = Style {
underlined: Some(true),
..Style::default()
};
let e = PreeditEvent::from_sdl_text_editing(Some("abcdef"), 2, 2).unwrap();
let styled: Vec<(char, bool)> = e
.to_formatted_text(&focused)
.chars()
.map(|(c, s)| (c, s.underlined == Some(true)))
.collect();
let underlined: String = styled.iter().filter(|x| x.1).map(|x| x.0).collect();
assert_eq!(underlined, "cd");
assert_eq!(styled.len(), 6);
let one = PreeditEvent::from_sdl_text_editing(Some("ab"), 0, 0).unwrap();
assert!(
one.to_formatted_text(&focused)
.chars()
.all(|(_, s)| s.underlined == Some(true))
);
}
}
@@ -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::*;
@@ -5,7 +5,7 @@
//! dimension type allows.
/// Section indexes of one column, 0 being the lowest.
#[derive(Clone, Copy, Default, PartialEq, Eq, Hash)]
#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct SectionMask([u64; 4]);
impl SectionMask {
@@ -497,10 +497,13 @@ impl Settings {
/// `Options.createInformation`: what the server learns about this player.
pub fn client_information(&self, view_distance: i8) -> ClientInformation {
ClientInformation {
language: self.language.clone(),
chat_visibility: self.chat_visibility as i32,
chat_colors: self.chat_colors,
model_customisation: self.file.model_customisation(),
main_hand: i32::from(!self.main_hand_left),
allows_listing: self.file.bool("allowServerListing"),
particle_status: i32::from(self.particles),
..ClientInformation::new(view_distance)
}
}
+97
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.
@@ -844,6 +888,12 @@ impl SkinSlots {
kept: Default::default(),
}
}
/// The atlas was replaced (a resource pack reload): every slot is blank,
/// so no skin counts as stored and each uploads again.
pub fn forget(&mut self) {
self.slots.clear();
self.kept.clear();
}
/// Whether `location` is in a slot.
pub fn contains(&self, location: &str) -> bool {
self.slots.iter().any(|(l, _)| l == location)
@@ -910,6 +960,23 @@ pub fn sha1(data: &[u8]) -> [u8; 20] {
#[cfg(test)]
mod tests {
/// A pack reload swaps the atlas: its skin slots are blank, so a forgotten
/// table holds nothing and hands out slots from the start again, even for
/// skins drawn in the last frame.
#[test]
fn a_new_atlas_forgets_every_skin_slot() {
use super::SkinSlots;
let mut slots = SkinSlots::new(2);
assert_eq!(slots.place("skins/a".into()), Some((0, None)));
assert_eq!(slots.place("skins/b".into()), Some((1, None)));
assert_eq!(slots.place("skins/c".into()), None);
slots.forget();
assert!(!slots.contains("skins/a"));
assert!(!slots.contains("skins/b"));
assert_eq!(slots.place("skins/c".into()), Some((0, None)));
assert_eq!(slots.place("skins/a".into()), Some((1, None)));
}
/// The launcher's offline skin: a 64x64 or legacy 64x32 PNG laid out as
/// a downloaded skin, slim only for the `slim` model; anything else is
/// refused and the player keeps their default skin.
@@ -1010,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]
+276 -9
View File
@@ -7,7 +7,7 @@ use crate::section_mask::SectionMask;
use crate::{
chunk::Chunk,
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
mesher::{MeshTables, Mesher, Region, SectionMesh, borrow},
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
world::World,
};
use horizon_core::biome::BiomeZoom;
@@ -234,10 +234,39 @@ struct Shared {
/// packs reload; each mesh job takes the current one.
context: RwLock<Arc<WorkerContext>>,
done: Mailbox,
/// Only workers use this pool; frames never wait for its lock. Retaining
/// cleared buffers avoids racing the renderer for the process heap.
meshes: Mutex<MeshPool>,
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
/// job since: the last of them gives the freed memory back to the OS.
released: AtomicUsize,
}
#[derive(Default)]
struct MeshPool {
meshes: Vec<SectionMesh>,
bytes: usize,
}
impl MeshPool {
const BYTES: usize = 16 << 20;
const COUNT: usize = 128;
fn take(&mut self) -> SectionMesh {
let mesh = self.meshes.pop().unwrap_or_default();
self.bytes -= mesh.capacity_bytes();
mesh
}
/// A rejected mesh is dropped by the caller, after releasing the lock.
fn put(&mut self, mut mesh: SectionMesh) -> Option<SectionMesh> {
let bytes = mesh.capacity_bytes();
if bytes == 0 || self.meshes.len() == Self::COUNT || bytes > Self::BYTES - self.bytes {
return Some(mesh);
}
mesh.clear();
self.bytes += bytes;
self.meshes.push(mesh);
None
}
}
/// Finished jobs, from the workers to the game thread, in the order they
/// finished. Not an `mpsc` channel: its receiver waits for a sender it finds
/// half way through a send, and a worker descheduled there on a busy machine
@@ -536,6 +565,7 @@ impl Streaming {
stop: AtomicBool::new(false),
context: RwLock::new(context.clone()),
done: Mailbox::default(),
meshes: Mutex::new(MeshPool::default()),
released: AtomicUsize::new(0),
});
let workers = threads.max(1);
@@ -556,6 +586,7 @@ impl Streaming {
// the worker across mesh jobs, like vanilla's per-thread cache:
// cloning it per job copied every state and face shape.
let mut faces: Option<(Arc<MeshTables>, FaceVisibility)> = None;
let mut mesh_scratch = MeshScratch::default();
loop {
let job = {
let mut queue = shared.queue.lock().unwrap();
@@ -580,10 +611,12 @@ impl Streaming {
if wait.timed_out() && queue.is_empty() {
// A server nobody is on, a client standing still:
// free the generator's buffers (and the mesher's
// face cache) until the next job needs them.
// face cache and scratch) until the next job
// needs them.
drop(queue);
generator.idle(&mut worker);
faces = None;
mesh_scratch = MeshScratch::default();
released = true;
// The allocator keeps freed pages until memory
// runs short: once every worker has freed its own,
@@ -591,6 +624,12 @@ impl Streaming {
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
== workers
{
// No worker needs retained mesh capacity now.
// Drop it outside the pool lock before asking
// the allocator to return freed pages.
let meshes =
std::mem::take(&mut *shared.meshes.lock().unwrap());
drop(meshes);
horizon_threads::release_free_memory();
}
queue = shared.queue.lock().unwrap();
@@ -620,7 +659,10 @@ impl Streaming {
Done::Saved(protos)
}
Job::Free(meshes) => {
drop(meshes);
for (_, mesh) in meshes {
let rejected = shared.meshes.lock().unwrap().put(mesh);
drop(rejected);
}
continue;
}
Job::Light(x, z, region, seq, sky) => {
@@ -648,7 +690,13 @@ impl Streaming {
biomes: &context.biomes,
visibility,
};
let mut mesh = mesher.mesh(&borrow(&region), sy);
let recycled = shared.meshes.lock().unwrap().take();
let mut mesh = mesher.mesh_reusing(
&borrow(&region),
sy,
recycled,
&mut mesh_scratch,
);
mesh.sort_solid();
faces = Some((context.tables.clone(), mesher.visibility));
Done::Meshed(x, sy, z, mesh, seq)
@@ -958,6 +1006,21 @@ impl Streaming {
}
Some(Box::new(region))
}
/// Whether `light_region` would find all nine columns: the same walk
/// without building the snapshot, so the scheduler can rank the ready
/// columns before any region is cloned. A proto neighbour whose light
/// view turns out empty is skipped when its region is built, and asked
/// for again, as `light_region` returning `None` always was.
fn light_region_ready(&self, world: &World, x: i32, z: i32) -> bool {
light_region_ready(
world,
|x, z| self.in_range(x, z, 1),
&self.decorated,
&self.protos,
x,
z,
)
}
fn region(world: &World, x: i32, z: i32) -> Option<Box<Region>> {
let mut region: Region = Default::default();
for dz in 0..3 {
@@ -1357,13 +1420,29 @@ impl Streaming {
}
}
self.schedule_generation(world, workers);
// Lighting once all neighbours exist.
let light: Vec<_> = world.dirty_light.iter().copied().collect();
for (x, z) in light {
// Lighting once all neighbours exist, nearest first and only as
// much of it as the workers can chew. Vanilla's light engine also
// hands a bounded queue to its workers and reschedules what did
// not fit by distance; submitted whole, the light jobs of every
// unlit column at once — each carrying a 3x3 region snapshot —
// grew with the render distance and outlived the columns they
// were queued for (#3629). What stays in `dirty_light` is asked
// for again, from the current centre, as capacity frees.
let mut light: Vec<(i64, i32, i32)> = Vec::new();
let mut gone: Vec<(i32, i32)> = Vec::new();
for &(x, z) in world.dirty_light.iter() {
if !world.chunks.contains_key(&(x, z)) {
world.dirty_light.remove(&(x, z));
continue;
gone.push((x, z));
} else if self.light_region_ready(world, x, z) {
light.push((self.distance(x, z), x, z));
}
}
for key in gone {
world.dirty_light.remove(&key);
}
let room = (self.threads.len() * 4).saturating_sub(self.lighting.len());
nearest_first(&mut light, room);
for (_, x, z) in light {
let Some(region) = self.light_region(world, x, z) else {
continue;
};
@@ -1409,6 +1488,7 @@ impl Streaming {
}
// Column by column: the checks below hold for all of a column's
// sections, and columns waiting for light cost one look per frame.
let mut candidates: Vec<(i64, i32, i32, SectionMask)> = Vec::new();
let columns: Vec<_> = world.dirty_sections.columns().collect();
for ((x, z), sections) in columns {
if !world.chunks.contains_key(&(x, z)) {
@@ -1437,6 +1517,18 @@ impl Streaming {
if !ready {
continue;
}
candidates.push((self.distance(x, z), x, z, sections));
}
// Nearest first, and only as many sections as the workers can chew.
// Every ready column used to be submitted at once, so at a wide
// render distance the queue — and `meshing`, and each job's 3x3
// region snapshot — grew with the number of loaded columns and the
// jobs went stale long before a worker reached them (#3629). What
// stays dirty is asked for again, from the current centre, as
// capacity frees; `requeue` then has a small queue to keep close.
let room = (self.threads.len() * 32).saturating_sub(self.meshing.len());
nearest_first(&mut candidates, room);
for (_, x, z, sections) in candidates {
let Some(region) = Self::region(world, x, z) else {
continue;
};
@@ -2051,10 +2143,112 @@ fn rank_again(queue: &mut BinaryHeap<Queued>, distance: impl Fn(i32, i32) -> i64
}
*queue = BinaryHeap::from(jobs);
}
/// Whether the 3x3 a light job for (x, z) reads is all there: every
/// neighbour a finished column, or a decorated proto chunk past the area
/// (the last FULL ring lights against neighbours at FEATURES, as
/// `Streaming::light_region` reads them).
fn light_region_ready(
world: &World,
in_range: impl Fn(i32, i32) -> bool,
decorated: &FxHashSet<(i32, i32)>,
protos: &FxHashMap<(i32, i32), Option<Proto>>,
x: i32,
z: i32,
) -> bool {
for dz in 0..3 {
for dx in 0..3 {
let n = (x + dx - 1, z + dz - 1);
if world.chunks.contains_key(&n)
|| (!in_range(n.0, n.1)
&& decorated.contains(&n)
&& matches!(protos.get(&n), Some(Some(_))))
{
continue;
}
return false;
}
}
true
}
/// The nearest `room` of the `candidates`, ordered: the light and mesh
/// stages submit bounded work, chosen from everything that is ready each
/// `update`, so what runs is always the nearest of what is left — chosen
/// again, from the current centre, as capacity frees, the way vanilla's
/// chunk task queues follow the current ticket levels. The selection is
/// O(n); the sort orders only what is submitted.
fn nearest_first<T: Ord>(candidates: &mut Vec<T>, room: usize) {
if candidates.len() > room {
candidates.select_nth_unstable(room);
candidates.truncate(room);
}
candidates.sort_unstable();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mesh_pool_reuses_capacity_with_clean_geometry_state() {
let mut mesh = SectionMesh::default();
mesh.solid.resize(64, bytemuck::Zeroable::zeroed());
mesh.translucent.resize(16, bytemuck::Zeroable::zeroed());
mesh.block_entities.push(([1, 2, 3], 7));
mesh.visibility = 0;
mesh.sides = crate::mesher::Sides::unsorted(16);
let capacities = mesh.capacity_bytes();
let pointers = (mesh.solid.as_ptr(), mesh.translucent.as_ptr());
let mut pool = MeshPool::default();
assert!(pool.put(mesh).is_none());
assert_eq!(pool.bytes, capacities);
let reused = pool.take();
assert_eq!(reused.capacity_bytes(), capacities);
assert_eq!(
pointers,
(reused.solid.as_ptr(), reused.translucent.as_ptr())
);
assert!(reused.solid.is_empty());
assert!(reused.translucent.is_empty());
assert!(reused.block_entities.is_empty());
assert_eq!(reused.visibility, crate::mesher::ALL_VISIBLE);
assert_eq!(reused.sides, crate::mesher::Sides::default());
assert_eq!(pool.bytes, 0);
}
#[test]
fn mesh_pool_caps_retained_count_and_bytes() {
let mut pool = MeshPool::default();
for _ in 0..MeshPool::COUNT {
let mut mesh = SectionMesh::default();
mesh.solid.reserve_exact(1);
assert!(pool.put(mesh).is_none());
}
let mut extra = SectionMesh::default();
extra.solid.reserve_exact(1);
assert!(pool.put(extra).is_some());
for _ in 0..MeshPool::COUNT {
pool.take();
}
assert_eq!(pool.bytes, 0);
for i in 0..2 {
let mut mesh = SectionMesh::default();
mesh.solid.reserve_exact(
MeshPool::BYTES / std::mem::size_of::<crate::mesher::TerrainVertex>() / 2 + 1,
);
assert_eq!(pool.put(mesh).is_some(), i == 1);
assert!(pool.bytes <= MeshPool::BYTES);
assert_eq!(pool.meshes.len(), 1);
}
pool.take();
assert_eq!(pool.bytes, 0);
let mut huge = SectionMesh::default();
huge.solid.reserve_exact(
MeshPool::BYTES / std::mem::size_of::<crate::mesher::TerrainVertex>() + 1,
);
assert!(pool.put(huge).is_some());
assert!(pool.meshes.is_empty());
assert_eq!(pool.bytes, 0);
}
/// The frame never waits for a worker: while one holds the mailbox, the
/// frame takes nothing and finds the results, in order, the next time.
#[test]
@@ -2140,6 +2334,79 @@ mod tests {
assert_eq!(queue.pop().and_then(|q| q.at), Some((20, 0)));
}
/// The light and mesh stages submit bounded work, the nearest first:
/// what exceeds the room stays dirty, to be chosen again — from the
/// current centre — as capacity frees. Submitted whole, the queue once
/// held every ready column's jobs and grew with the render distance
/// (#3629); ties break by position, so the choice is deterministic.
#[test]
fn bounded_submission_takes_the_nearest_first() {
let candidates: Vec<(i64, i32, i32, u32)> = (0..40i32)
.map(|i| {
let (x, z) = (i / 8 - 2, i % 8 - 3);
(
i64::from((x - 3).pow(2) + (z - 2).pow(2)),
x,
z,
1 << (i % 24),
)
})
.collect();
let mut expected = candidates.clone();
expected.sort_unstable();
let mut none = candidates.clone();
nearest_first(&mut none, 0);
assert!(none.is_empty());
let mut nearest = candidates;
nearest_first(&mut nearest, 5);
assert_eq!(nearest, expected[..5]);
let mut past = expected.clone();
nearest_first(&mut past, expected.len() + 10);
assert_eq!(past, expected);
}
/// What the scheduler counts as ready to light: every neighbour a
/// finished column, or a decorated proto chunk past the area — the
/// walk `light_region` makes without its snapshot.
#[test]
fn light_readiness_follows_the_region_a_light_job_reads() {
let mut world = World::new(horizon_world::registry::BlockStateId(0), 0);
let chunk = Arc::new(Chunk::empty(0, 0));
let nine = (-1..=1).flat_map(|dz| (-1..=1).map(move |dx| (dx, dz)));
for key in nine {
world.chunks.insert(key, chunk.clone());
}
// The kept area is the cross around (0, 0); the corners of the
// 3x3 light reads lie past it.
let in_range = |x: i32, z: i32| x.abs() + z.abs() <= 1;
let none = FxHashSet::<(i32, i32)>::default();
let empty = FxHashMap::<(i32, i32), Option<Proto>>::default();
assert!(light_region_ready(&world, in_range, &none, &empty, 0, 0));
// A corner is missing, and nothing stands in for it yet.
world.chunks.remove(&(1, 1));
assert!(!light_region_ready(&world, in_range, &none, &empty, 0, 0));
// A decorated, checked-in proto chunk past the area lights against.
let decorated: FxHashSet<(i32, i32)> = [(1, 1)].into_iter().collect();
let mut protos: FxHashMap<(i32, i32), Option<Proto>> = Default::default();
protos.insert((1, 1), Some(Box::new(()) as Proto));
assert!(light_region_ready(
&world, in_range, &decorated, &protos, 0, 0
));
// The proto is checked out to a job: it cannot be read.
protos.insert((1, 1), None);
assert!(!light_region_ready(
&world, in_range, &decorated, &protos, 0, 0
));
// A neighbour inside the area waits for the chunk itself.
world.chunks.remove(&(1, 0));
let mut inside = decorated.clone();
inside.insert((1, 0));
protos.insert((1, 0), Some(Box::new(()) as Proto));
assert!(!light_region_ready(
&world, in_range, &inside, &protos, 0, 0
));
}
#[test]
fn view_distance_is_vanillas_shape() {
// ChunkTrackingView.isInViewDistance at render distance 12: a column
+125 -8
View File
@@ -27,6 +27,9 @@ struct Globals {
// The sunrise fan's 16 triangles (gfx/sunrise_fan.rs): xyz the apex weight's normal, w the
// triangle's volume with the camera; then each rim vertex's normal against the apex.
sunrise_fan: array<vec4<f32>, 32>,
// x: Options.textureFiltering (0 none, 1 RGSS, 2 anisotropic), y: the sprite arrays' width
// in texels (vanilla's TextureSize).
filtering: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
@@ -45,14 +48,12 @@ struct Globals {
// which then samples exactly as before.
override SPRITE_ARRAYS: u32 = 1u;
override SPRITE_SPLIT: u32 = 2048u;
fn sprite(uv: vec2<f32>, layer: u32) -> vec4<f32> {
if SPRITE_ARRAYS == 1u {
return textureSample(sprites, sprite_sampler, uv, i32(layer));
}
// The array is chosen per fragment: sample with the derivatives taken here.
let dx = dpdx(uv);
let dy = dpdy(uv);
// A sprite sampled with the given gradients, from whichever array holds its layer.
fn sprite_grad(uv: vec2<f32>, layer: u32, dx: vec2<f32>, dy: vec2<f32>) -> vec4<f32> {
let l = i32(layer % SPRITE_SPLIT);
if SPRITE_ARRAYS == 1u {
return textureSampleGrad(sprites, sprite_sampler, uv, l, dx, dy);
}
switch layer / SPRITE_SPLIT {
case 0u: { return textureSampleGrad(sprites, sprite_sampler, uv, l, dx, dy); }
case 1u: { return textureSampleGrad(sprites_1, sprite_sampler, uv, l, dx, dy); }
@@ -64,6 +65,78 @@ fn sprite(uv: vec2<f32>, layer: u32) -> vec4<f32> {
default: { return textureSampleGrad(sprites_7, sprite_sampler, uv, l, dx, dy); }
}
}
// The same at an explicit mip level.
fn sprite_level(uv: vec2<f32>, layer: u32, lod: f32) -> vec4<f32> {
let l = i32(layer % SPRITE_SPLIT);
if SPRITE_ARRAYS == 1u {
return textureSampleLevel(sprites, sprite_sampler, uv, l, lod);
}
switch layer / SPRITE_SPLIT {
case 0u: { return textureSampleLevel(sprites, sprite_sampler, uv, l, lod); }
case 1u: { return textureSampleLevel(sprites_1, sprite_sampler, uv, l, lod); }
case 2u: { return textureSampleLevel(sprites_2, sprite_sampler, uv, l, lod); }
case 3u: { return textureSampleLevel(sprites_3, sprite_sampler, uv, l, lod); }
case 4u: { return textureSampleLevel(sprites_4, sprite_sampler, uv, l, lod); }
case 5u: { return textureSampleLevel(sprites_5, sprite_sampler, uv, l, lod); }
case 6u: { return textureSampleLevel(sprites_6, sprite_sampler, uv, l, lod); }
default: { return textureSampleLevel(sprites_7, sprite_sampler, uv, l, lod); }
}
}
// texture_sampling.glsl's sampleNearest: the sprite's texels stay square pixels, blending only
// across the one screen pixel where two meet, so a linear (and anisotropic) sampler keeps them crisp.
fn sample_nearest(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
texel_screen: vec2<f32>) -> vec4<f32> {
let texel_coords = uv / pixel;
let center = round(texel_coords) - 0.5;
var offset = texel_coords - center;
offset = (offset - 0.5) * pixel / max(texel_screen, vec2(1e-12)) + 0.5;
offset = clamp(offset, vec2(0.0), vec2(1.0));
return sprite_grad((center + offset) * pixel, layer, du, dv);
}
// sampleRGSS: four rotated-grid samples of the two nearest mip levels, faded in as the texels
// shrink below two screen pixels, over sample_nearest.
fn sample_rgss(uv: vec2<f32>, layer: u32, pixel: vec2<f32>, du: vec2<f32>, dv: vec2<f32>,
texel_screen: vec2<f32>) -> vec4<f32> {
let max_texel = max(texel_screen.x, texel_screen.y);
let min_pixel = min(pixel.x, pixel.y);
let blend = smoothstep(min_pixel, min_pixel * 2.0, max_texel);
let du_len = length(du);
let dv_len = length(dv);
let effective = sqrt(min(du_len, dv_len) * max(du_len, dv_len));
let exact = max(0.0, log2(effective / min_pixel));
let low = floor(exact);
let mip_blend = fract(exact);
var offsets = array<vec2<f32>, 4>(
vec2(0.125, 0.375), vec2(-0.125, -0.375), vec2(0.375, -0.125), vec2(-0.375, 0.125));
var sum_low = vec4(0.0);
var sum_high = vec4(0.0);
for (var i = 0; i < 4; i++) {
let p = uv + offsets[i] * pixel;
sum_low += sprite_level(p, layer, low);
sum_high += sprite_level(p, layer, low + 1.0);
}
let rgss = mix(sum_low * 0.25, sum_high * 0.25, mip_blend);
return mix(sample_nearest(uv, layer, pixel, du, dv, texel_screen), rgss, blend);
}
fn sprite(uv: vec2<f32>, layer: u32) -> vec4<f32> {
let mode = u32(globals.filtering.x);
if mode != 0u {
// Texture Filtering Anisotropic or RGSS: terrain.fsh's two sampling paths.
let du = dpdx(uv);
let dv = dpdy(uv);
let pixel = vec2(1.0 / globals.filtering.y);
let texel_screen = sqrt(du * du + dv * dv);
if mode == 1u {
return sample_rgss(uv, layer, pixel, du, dv, texel_screen);
}
return sample_nearest(uv, layer, pixel, du, dv, texel_screen);
}
if SPRITE_ARRAYS == 1u {
return textureSample(sprites, sprite_sampler, uv, i32(layer));
}
// The array is chosen per fragment: sample with the derivatives taken here.
return sprite_grad(uv, layer, dpdx(uv), dpdy(uv));
}
struct TerrainOut {
@builtin(position) clip: vec4<f32>,
@@ -302,7 +375,7 @@ fn sky_fog(color: vec3<f32>, dist: vec2<f32>) -> vec3<f32> {
// Weight of the fan centre where the ray crosses the sunrise fan (0 when missed):
// Moller-Trumbore from the origin along d, over normals built once a frame
// (gfx/sunrise_fan.rs). The barycentrics are apex/s, k1/s and -k0/s; s is -det.
fn sunrise_weight(d: vec3<f32>) -> f32 {
fn sunrise_weight_full(d: vec3<f32>) -> f32 {
if globals.sunrise.a <= 0.001 { return 0.0; }
var best = 0.0;
var k0 = dot(d, globals.sunrise_fan[16].xyz);
@@ -322,6 +395,50 @@ fn sunrise_weight(d: vec3<f32>) -> f32 {
}
return best;
}
// The rim's YZ projection is an ellipse. Comparing its unsquashed X/Y
// magnitudes with tan(PI/8) selects one of sixteen sectors without atan2.
fn sunrise_triangle_weight(d: vec3<f32>, i: u32) -> f32 {
let k0 = dot(d, globals.sunrise_fan[16u+i].xyz);
let k1 = dot(d, globals.sunrise_fan[16u+(i+1u)%16u].xyz);
let triangle = globals.sunrise_fan[i];
let apex = dot(d, triangle.xyz);
let s = apex + k1 - k0;
if abs(s) >= 1e-6 && s * triangle.w > 0.0 && apex * s >= 0.0
&& k1 * s >= 0.0 && k0 * s <= 0.0 {
return apex / s;
}
return 0.0;
}
fn sunrise_weight(d: vec3<f32>) -> f32 {
if globals.sunrise.a <= 0.001 { return 0.0; }
if dot(d.yz, d.yz) < 1e-10 { return sunrise_weight_full(d); }
// The camera lies in the rim plane. A forward ray can reach the fan
// only on the apex side. Keep a guard for rounded coplanar normals.
let apex = dot(d, globals.sunrise_fan[0].xyz);
if apex * globals.sunrise_fan[0].w < -1e-4 { return 0.0; }
let y = dot(d, globals.sunrise_fan[16].xyz);
let x = -dot(d, globals.sunrise_fan[20].xyz);
let ax = abs(x);
let ay = abs(y);
let tangent = 0.414213562373095;
let quadrant_sector = select(select(0u, 1u, ay > ax * tangent),
select(2u, 3u, ax < ay * tangent), ay > ax);
let sector = select(select(8u + quadrant_sector, 7u - quadrant_sector, y >= 0.0),
select(15u - quadrant_sector, quadrant_sector, y >= 0.0), x >= 0.0);
var best = max(sunrise_triangle_weight(d, sector),
sunrise_triangle_weight(d, (sector + 8u) % 16u));
// Test adjacent sectors only at a rounded boundary; their exact rim
// normals remain the authority for hit tests and interpolation weights.
let k0 = dot(d, globals.sunrise_fan[16u+sector].xyz);
let k1 = dot(d, globals.sunrise_fan[16u+(sector+1u)%16u].xyz);
if min(abs(k0), abs(k1)) < max(ax, ay) * 0.001 {
best = max(best, sunrise_triangle_weight(d, (sector + 15u) % 16u));
best = max(best, sunrise_triangle_weight(d, (sector + 1u) % 16u));
best = max(best, sunrise_triangle_weight(d, (sector + 7u) % 16u));
best = max(best, sunrise_triangle_weight(d, (sector + 9u) % 16u));
}
return best;
}
// The sky's colour along the view ray `d` (a shader pack's sky stage gets it as its input:
// gfx/native_sky.rs builds that stage from this section).
fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
+19 -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();
@@ -1289,6 +1289,9 @@ impl EditBox {
batch.text(atlas, "_", cursor_x, ty, color, true);
}
}
if self.focused {
crate::preedit::report_caret(cursor_x, ty, 10.0);
}
}
/// Character index under GUI x (mouse click).
pub fn index_at(&self, atlas: &HudAtlas, x: f32) -> usize {
@@ -1351,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,
)
};
@@ -0,0 +1,189 @@
#[path = "../src/gfx/sunrise_fan.rs"]
mod sunrise_fan;
#[path = "../src/gfx/wgsl_prefix.rs"]
mod wgsl_prefix;
fn sunrise_shader() -> String {
let terrain = include_str!("../src/terrain.wgsl");
let start = terrain.find("fn sunrise_weight_full").unwrap();
let end = terrain[start..].find("// The sky's colour").unwrap() + start;
let mut source = String::from(
"struct Globals { sunrise: vec4<f32>, sunrise_fan: array<vec4<f32>,32> }\n\
@group(0) @binding(0) var<uniform> globals: Globals;\n\
@group(0) @binding(1) var<storage,read> rays: array<vec4<f32>>;\n\
@group(0) @binding(2) var<storage,read_write> results: array<vec2<f32>>;\n",
);
source.push_str(&terrain[start..end]);
source.push_str(
"@compute @workgroup_size(64) fn main(@builtin(global_invocation_id) id: vec3<u32>) {\n\
if id.x < arrayLength(&rays) {\n\
let d = rays[id.x].xyz;\n\
results[id.x] = vec2(sunrise_weight_full(d), sunrise_weight(d));\n\
}\n\
}\n",
);
source
}
#[test]
fn extracted_sunrise_shader_validates() {
let module = naga::front::wgsl::parse_str(&sunrise_shader()).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
// Execute the actual optimized and reference WGSL on a host GPU.
// This opt-in test compares shipped WGSL with the full fan search, not FPS.
#[test]
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
fn sunrise_sectors_match_reference_on_real_gpu() {
use wgpu::util::DeviceExt;
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
eprintln!("Sunrise GPU correctness: {}", adapter.get_info().name);
let (device, queue) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(sunrise_shader().into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: None,
layout: None,
module: &module,
entry_point: Some("main"),
compilation_options: Default::default(),
cache: None,
});
let mut rays = Vec::<[f32; 4]>::new();
for yaw in 0..360 {
for pitch in -89..90 {
let (sy, cy) = (yaw as f32).to_radians().sin_cos();
let (sp, cp) = (pitch as f32).to_radians().sin_cos();
rays.push([sy * cp, sp, cy * cp, 0.]);
}
}
rays.extend([
[1., 0., 0., 0.],
[-1., 0., 0., 0.],
[0., 1., 0., 0.],
[0., -1., 0., 0.],
]);
let input = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&rays),
usage: wgpu::BufferUsages::STORAGE,
});
let size = (rays.len() * 8) as u64;
let output = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
for alpha in [0.01, 0.2, 0.65, 1.] {
for hemisphere in [-1., 1.] {
let mut globals = vec![[1., 1., 1., alpha]];
globals.extend(sunrise_fan::normals(
alpha,
glam::Vec3::new(0., 0., hemisphere),
));
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(&globals),
usage: wgpu::BufferUsages::UNIFORM,
});
let bindings = [&uniform, &input, &output];
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &bindings
.iter()
.enumerate()
.map(|(i, b)| wgpu::BindGroupEntry {
binding: i as u32,
resource: b.as_entire_binding(),
})
.collect::<Vec<_>>(),
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_compute_pass(&Default::default());
pass.set_pipeline(&pipeline);
pass.set_bind_group(0, &group, &[]);
pass.dispatch_workgroups((rays.len() as u32).div_ceil(64), 1, 1);
}
encoder.copy_buffer_to_buffer(&output, 0, &read, 0, size);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
{
let mapped = read.slice(..).get_mapped_range().unwrap();
let pairs: &[[f32; 2]] = bytemuck::cast_slice(&mapped);
for (i, pair) in pairs.iter().enumerate() {
assert!(
(pair[0] - pair[1]).abs() < 1e-4,
"alpha={alpha} hemisphere={hemisphere} ray={:?} values={pair:?}",
rays[i]
);
}
}
read.unmap();
}
}
}
#[test]
fn native_pack_sky_keeps_sunrise_helpers_private() {
use wgsl_prefix::{TERRAIN, prefixed, section};
// Use the same complete terrain sections and prefixer as native_sky.rs.
let sky = prefixed(
&[
section(TERRAIN, "struct Globals {", "@group(0) @binding(1)"),
section(TERRAIN, "fn linear_fog(", "fn apply_fog("),
section(TERRAIN, "struct SkyOut {", "// rendertype_lines"),
]
.concat(),
"horizon_sky_",
);
for name in [
"sunrise_weight",
"sunrise_weight_full",
"sunrise_triangle_weight",
] {
assert!(sky.contains(&format!("fn horizon_sky_{name}(")));
}
// A native pack may declare the same helper names as the renderer.
let own = "fn sunrise_weight(d: vec3<f32>) -> f32 { return d.x; }\n\
fn sunrise_weight_full(d: vec3<f32>) -> f32 { return d.y; }\n\
fn sunrise_triangle_weight(d: vec3<f32>, i: u32) -> f32 { return d.z + f32(i); }\n";
let module = naga::front::wgsl::parse_str(&format!("{own}\n{sky}")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
#[test]
fn complete_terrain_shader_still_validates_for_fallback_sky() {
let module = naga::front::wgsl::parse_str(wgsl_prefix::TERRAIN).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
+7 -2
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).
@@ -64,10 +64,11 @@ horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
# `LevelStorageAccess.estimateDiskSpace`: usable disk space, without unsafe code on Windows.
fs4 = { version = "1.1.0", default-features = false, features = ["sync"] }
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
# `LevelStorageAccess.estimateDiskSpace` (statvfs) too.
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
[target.'cfg(windows)'.dependencies]
@@ -96,6 +97,10 @@ audio = ["horizon-client-render/audio"]
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
windows-segment-heap = []
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
[[example]]
Loaded 100 of 681 files, more files were not shown because too many files have changed in this diff. Show more