Compare commits

...
Author SHA1 Message Date
levzzz c39b4ed4b8 Merge pull request #4833 from levzzz5154/parity/T3-test-command
/test: run, runmultiple, runthese, runclosest, runthat, runfailed, reset, clear, stop
2026-10-31 05:34:53 +05:00
Ludovic Marie 9510efd2fe Merge pull request #4795 from horizon-rust/fix/dedicated-commands-tree
🤖 Agent: proven on the Mac mini: chat_screen, command_parse, command_exec 925/925, dedicated_server, server_ops_feedback, server_player_targets; pr_ready --quick green. Fixes main's red parity (#4680's tree, #4702's /fetchprofile ticks).
2026-10-31 01:30:45 +01:00
levzzz 747ccd0b5b Merge pull request #4828 from levzzz5154/parity/E3-entity-post-effect
Spectating a creeper, spider or enderman puts its post effect on the screen
2026-10-31 05:30:12 +05:00
levzzz 396ff1e5f9 Merge pull request #4826 from levzzz5154/parity/E2-post-effects-server
/posteffect: per-player post effects sent with ClientboundPostEffects
2026-10-31 05:26:54 +05:00
levzzz cb93abfe66 Merge pull request #4825 from levzzz5154/parity/E1-post-chain
Post-effect chains: draw post_effect JSON over the world, handle ClientboundPostEffects
2026-10-31 05:26:19 +05:00
levzzz 70ede6a0ef Merge pull request #4827 from levzzz5154/parity/R2-load
Loading screen: integrated server's chunk map and progress, new-world delay
2026-10-31 05:26:17 +05:00
levzzz 7a6dd1ffc9 Merge hr/main into parity/T3-test-command 2026-10-31 05:26:02 +05:00
levzzz 656b7c7c9b Merge pull request #4824 from levzzz5154/parity/T2-runner
Game tests: the runner, block-based and function tests; RUN starts the test
2026-10-31 05:25:53 +05:00
levzzzandClaude Sonnet 5.5 e0cee924f0 Update the test command corpus note
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:24:57 +05:00
levzzz 061a7dda03 Merge pull request #4823 from levzzz5154/parity/R2-cam
Spectator cameras on more entities, camera spectators hidden from spectators
2026-10-31 05:22:01 +05:00
levzzzandClaude Sonnet 5.5 baae358894 /test: run, runmultiple, runthese, runclosest, runthat, runfailed, reset*, clear*, stop
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:21:47 +05:00
levzzzandClaude Sonnet 5.5 83b29e43ff Put the spectated creeper, spider or enderman's post effect on the screen
GameRenderer.checkEntityPostEffect on setCameraEntity and on the perspective toggle.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:20:43 +05:00
levzzzandClaude Sonnet 5.5 d35bf3ba33 Integrated server's load progress and chunk map on the loading screen, 500 ms new-world delay
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:19:32 +05:00
levzzz 6667abcb04 Merge pull request #4820 from levzzz5154/parity/R2-disk
Dedicated servers warn operators of low disk space
2026-10-31 05:19:30 +05:00
levzzzandClaude Sonnet 5.5 9b5e0026f3 Keep /posteffect per player and send ClientboundPostEffectsPacket
ServerPlayer.postEffects lives on each guest, is saved as post_effects,
sent on join/respawn and at the end of the tick when dirty.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:17:57 +05:00
levzzz 12eb38e131 Merge hr/main into parity/T2-runner 2026-10-31 05:16:30 +05:00
levzzz 3c1d4b39c6 Merge pull request #4816 from levzzz5154/parity/T1-runner
Test blocks: trigger, reset, redstone signal and the start block's power
2026-10-31 05:16:21 +05:00
levzzzandClaude Sonnet 5.5 c5abd34fb1 Game tests: the runner, block-based and function tests, RUN starts the test
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:15:51 +05:00
levzzzandClaude Sonnet 5.5 434577c913 Spectator cameras on projectiles, dragon parts, TNT and falling blocks; hide camera spectators from spectators
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:14:04 +05:00
levzzzandClaude Sonnet 5.5 a55abdfe89 Draw post_effect chains over the world (PostChain, PostPass, handlePostEffects)
The chains in assets/<ns>/post_effect/*.json are read from the active
resource root at run time and run over the frame after the entity outline
and before the GUI, through Horizon's own stand-ins for the vanilla post
shaders. ClientboundPostEffectsPacket sets LocalPlayer.postEffects.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:11:29 +05:00
levzzzandClaude Sonnet 5.5 5a70e0ba0d Dedicated servers send ClientboundLowDiskSpaceWarningPacket to operators
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:09:47 +05:00
levzzzandClaude Sonnet 5.5 56dcdc8464 Test blocks: trigger, reset, the redstone signal and the start block's power
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 05:07:45 +05:00
levzzz 2a009ad16f Merge pull request #4805 from levzzz5154/parity/U-levelload
Client LevelLoadTracker: the loading screen waits for the server's start and the player's section
2026-10-31 05:06:23 +05:00
Ludovic Marie 37b1f605d5 Merge pull request #4766 from horizon-rust/fix/refused-pull-no-event
🤖 Agent: verified: CI verify passes; pr_ready --quick clean but for the candle cake test that fails on main too; piston harnesses pass; the vanilla path is unchanged (nothing is refused without a mod). Follow-up to #4739's review. Queued.
2026-10-31 01:05:23 +01:00
levzzz a6cf9d0370 Merge pull request #4804 from levzzz5154/parity/G-spectate
Spectator camera on the server (handleSpectatorAction, SetCamera)
2026-10-31 05:02:39 +05:00
levzzz 68e9da706c Merge pull request #4810 from levzzz5154/parity/C-chunk-fade
Chunk sections fade in over chunkSectionFadeInTime
2026-10-31 05:01:49 +05:00
Ludovic MarieandClaude Opus 5.5 f2f768bb36 command_exec harness: /fetchprofile answers on the next tick, as the oracle's
The other half of main's command_exec failure (since #4702): the oracle's
Services resolve no profile, at once, so /fetchprofile fails on the next
tick. The game asked Mojang's services over the network on a thread that
the harness's back-to-back ticks outran: no line in the recorded ticks.
The probe gets a discovery address that refuses at once
(HORIZON_API_DISCOVERY=http://127.0.0.1:9/), and the harness waits for
lookups in flight before each scripted tick (profile_fetches_pending).
925/925.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:57:57 +01:00
levzzz dd4f61225b Merge remote-tracking branch 'hr/main' into parity/U-levelload 2026-10-31 04:57:57 +05:00
levzzzandClaude Sonnet 5.5 1b99614b7b test_world_input_capture: follow the level load tracker's PlayerLoaded
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:57:56 +05:00
levzzz 2789409634 Merge pull request #4797 from levzzz5154/parity/U-multiline
Dialogs: multiline text inputs are a MultiLineEditBox
2026-10-31 04:56:24 +05:00
levzzzandClaude Sonnet 5.5 d9cbb0f4e6 Level loading: the client's LevelLoadTracker state machine
startClientLoad on login and respawn, the loading start game event, the
player's section, the 30 second timeout and the close delay. The
LevelLoadingScreen stays up (by the respawn's portal reason) until the level
is ready, and PlayerLoaded is sent then.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:53:56 +05:00
levzzz 3bfec3857d Merge pull request #4788 from levzzz5154/parity/U-chunk-toast
Toasts for chunk load and save failures and low disk space
2026-10-31 04:53:54 +05:00
levzzzandClaude Sonnet 5.5 37aa729526 Server side of the spectator camera: handleSpectatorAction, SetCamera, camera follow for guests
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:53:42 +05:00
levzzzandClaude Sonnet 5.5 1c6992a28c Chunk sections fade in over chunkSectionFadeInTime, block entities wait for 0.3
RenderSection.getVisibility, ChunkSectionInfo visibility, terrain.vsh/fsh mix with fog colour,
LevelExtractor.extractVisibleBlockEntities gate.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:51:14 +05:00
levzzzandClaude Sonnet 5.5 7e902a6886 Dialog text inputs with a multiline option are a MultiLineEditBox
Port MultiLineEditBox, MultilineTextField and AbstractTextAreaWidget's frame and
scroll bar as multiline_edit::MultiLineEdit: wrapping to the box, caret and
selection moving by line, word and page keys, character and line limits, mouse
placement, drag and wheel, and the character count. TextInputHandler.addControl
sizes the box by its height or max_lines.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:48:23 +05:00
levzzz 101280753b Merge pull request #4784 from levzzz5154/parity/L2-screen
Port OptimizeWorldScreen and the Edit World flow into it
2026-10-31 04:47:25 +05:00
levzzz d5c4248f62 Merge pull request #4782 from levzzz5154/parity/K2-hash-3
Hash block predicates and inline trim, instrument and banner pattern holders in container clicks
2026-10-31 04:41:50 +05:00
levzzzandClaude Sonnet 5.5 0580d262da Toast chunk load and save failures and low disk space from the integrated server
IntegratedServer.reportChunkLoadFailure / reportChunkSaveFailure: region read
errors and writer-thread write errors are logged, checked for low disk space and
queued for the window, which shows SystemToast.onChunkLoadFailure / OnChunkSaveFailure
(red title) and onLowDiskSpace (ten seconds).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:41:35 +05:00
levzzz d3a5cf3925 Merge hr/main into parity/L2-screen 2026-10-31 04:38:54 +05:00
levzzzandClaude Sonnet 5.5 4bbd8f3e8e Port OptimizeWorldScreen and the Edit World flow into it
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:38:53 +05:00
levzzz a71733bfe7 Merge pull request #4774 from levzzz5154/parity/L1-upgrader
Port WorldUpgrader (Optimize World worker)
2026-10-31 04:38:49 +05:00
levzzz 79c76034a0 Merge pull request #4773 from levzzz5154/parity/bl2-leash-holder
Boats can hold leads (Entity.interact sneak branch)
2026-10-31 04:37:39 +05:00
Ludovic MarieandClaude Opus 5.5 1af0acfc37 Commands: /op, /ban, /pardon, /deop and /whitelist only on a dedicated server
#4680 registered them in the one tree every server uses, so single player's
and a LAN world's /help and Tab offered them (chat_screen and command_exec
failed on every PR's parity). Vanilla registers them only when
Commands.CommandSelection.includeDedicated: tree_extra::register_dedicated
holds them, vanilla::build_for(dev, dedicated) adds them, and the dedicated
server turns them on with set_dedicated_commands in preflight (single
player's server thread never runs it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:37:08 +01:00
levzzz e8897035e4 Merge pull request #4772 from levzzz5154/parity/K2-hash-2
Hash item behaviour components (food, tool, equippable, attributes, ...) in container clicks
2026-10-31 04:34:57 +05:00
levzzzandClaude Sonnet 5.5 a26ebcb37e Port WorldUpgrader and RegionStorageUpgrader (Optimize World's worker)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:31:13 +05:00
levzzzandClaude Sonnet 5.5 8fc8f02820 Boats and mobs on a lead can hang on a boat (Entity.interact sneak branch, game events)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:30:07 +05:00
levzzzandClaude Sonnet 5.5 d3fd453cfa Hash block predicates and direct trim, instrument and banner pattern holders
can_place_on and can_break (blocks, state properties, exact components),
armor trims, instruments and banner patterns with inline holders, and
provides_trim_material, decoded from their stream form.

Part of #3518

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:28:41 +05:00
levzzz be529ea12d Merge pull request #4767 from levzzz5154/parity/51-crossed-arms
Villagers and wandering traders hold items in crossed arms (CrossedArmsItemLayer)
2026-10-31 04:27:40 +05:00
levzzz 3275d82094 Merge pull request #4764 from levzzz5154/parity/K2-hash
Hash container, profile, sign, variant and other stream-only components in container clicks
2026-10-31 04:26:02 +05:00
levzzz 2dcafe3ee3 Merge pull request #4763 from levzzz5154/parity/BL-boatlead
Make boats and rafts leashable (AbstractBoat is Leashable)
2026-10-31 04:24:22 +05:00
levzzzandClaude Sonnet 5.5 a87db94c53 Hash the item behaviour components like the vanilla client (part 2)
food, consumable, use_remainder, use_cooldown, damage_resistant, tool, weapon,
equippable, repairable, death_protection, blocks_attacks, piercing_weapon,
kinetic_weapon, swing animations, use_effects, attribute_modifiers, break_sound,
the fuels, compostable and mob_visibility, decoded from their stream form into
the codec form HashOps sees.

Part of #3518

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:22:36 +05:00
levzzzandClaude Sonnet 5.5 33d8b6ebeb CrossedArmsItemLayer: villagers and wandering traders show their held item; ShowTradesToPlayer displays matching offers
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:19:32 +05:00
Ludovic MarieandClaude Opus 5.5 917e58b2da Native mods: a refused sticky pull sends clients the blocks, not the retraction
A client isn't asked whether a piston may move: replaying the retraction's
block event, it would pull the block the server kept. When the level
refuses a pull, the event isn't broadcast (as for a push that fails), and
clients get the piston's and the head's block changes instead.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:18:37 +01:00
levzzzandClaude Sonnet 5.5 c830a170ad Hash the components Horizon keeps as stream bytes like the vanilla client (part 1)
Containers, pot decorations, block state, bees, typed entity data, sign text,
the NBT-only components, dye and mob variants, scalars, potion custom effects
and profiles are decoded from their stream form into the codec form HashOps
sees. The profile's skin patch is now read as vanilla writes it (texture ids,
slim flag) instead of two strings per texture.

Part of #3518

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:18:05 +05:00
levzzzandClaude Sonnet 5.5 5eb665b1c3 Make boats and rafts leashable (AbstractBoat implements Leashable)
Lead on/off, Leashable.tickLeash (elastic pull incl. quad, snap), save/load of the
holder, entity link packets, quad leash render for boats, lead drop on destroy.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:17:22 +05:00
Ludovic Marie 672b204c0a Merge pull request #4757 from horizon-rust/feat/report-threads
🤖 Agent: verified. Report-only change (Linux /proc and cgroup reads, null elsewhere); summary test covers both cases; pr_ready --quick green.
2026-10-31 00:14:40 +01:00
levzzz a79436fb97 Merge pull request #4741 from levzzz5154/parity/R-client
Finish rope hold position, sniffer digging sound and world stem check (parity)
2026-10-31 04:14:38 +05:00
levzzz 3da2b20cc8 Merge pull request #4754 from levzzz5154/parity/H-cmdcart
Command block minecart: edit screen, SetCommandMinecart, activator rail command, NBT
2026-10-31 04:13:41 +05:00
levzzz 4b461a9aae Merge pull request #4753 from levzzz5154/parity/R-render
Finish spectator player model and transient block removal (parity)
2026-10-31 04:12:04 +05:00
Ludovic Marie 9990eabc7f Merge pull request #4755 from horizon-rust/docs/anticheat-native-mod
🤖 Agent: docs only (docs/anticheat.md: where the anticheat lives, moves to a native mod once move/packet-timing hooks exist); CI green
2026-10-31 00:09:45 +01:00
levzzz d9e7cae928 Merge pull request #4749 from levzzz5154/parity/R-server
Finish dragon killing blow, lightning item bystanders and look-at entity form (parity)
2026-10-31 04:07:37 +05:00
levzzz dc29fd238b Merge hr/main into parity/R-client 2026-10-31 04:06:30 +05:00
Ludovic MarieandClaude Opus 5.5 393a461720 Server report: threads, and the container's limits on tasks and memory
The official server ran out of threads tonight (EAGAIN starting one, which
stopped its region writer until #4726). The report now says how many
threads the server runs and what the system allows: Max processes from
/proc/self/limits, and the container's pids.current/pids.max and
memory.current/memory.max from /sys/fs/cgroup. Linux only; null elsewhere.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:05:44 +01:00
levzzzandClaude Sonnet 5.5 973eeadcfd Command block minecart: editor, SetCommandMinecart, activator rail command, entity data, NBT
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:05:18 +05:00
Ludovic MarieandClaude Opus 5.5 f82149a3dd docs: the anticheat moves into a native mod once the hooks exist
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 00:05:00 +01:00
levzzzandClaude Sonnet 5.5 0dc793fdb5 PlayerModel.setupAnim (spectator), LevelRenderer.submitTransientBlocks (compile start): finish the partial ports
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 04:04:43 +05:00
levzzz 9054950e06 Merge pull request #4742 from levzzz5154/parity/K-component-hash
Hash item components in container clicks like the vanilla client
2026-10-31 04:04:19 +05:00
Ludovic Marie 84ebab8a78 Merge pull request #4739 from horizon-rust/feat/smp-grief-hooks
🤖 Agent: verified: CI verify passes; local pr_ready (fmt, clippy, tests: the one failing test fails on main too); local parity 125/128 affected harnesses pass, command_exec and chat_screen differ only by main's new /op, /ban... (e1a530a71), remote_fireball passed on rerun; end to end with the SMP mod on a dedicated server (horizon-mods#72 proof). Queued.
2026-10-31 00:04:07 +01:00
levzzzandClaude Sonnet 5.5 b307359409 KilledTrigger (dragon), LightningStrikeTrigger (items), ClientboundPlayerLookAtPacket (entity form): finish the partial ports
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:59:20 +05:00
levzzz 424dc918e7 Merge hr/main into parity/K-component-hash 2026-10-31 03:56:09 +05:00
levzzz ce12bd5b6d Merge pull request #4738 from levzzz5154/parity/K-hashops
Port HashOps (CRC32C) for item component hashes
2026-10-31 03:55:54 +05:00
levzzz 0d04976fa1 Merge pull request #4736 from levzzz5154/parity/01-chatdraft
Chat drafts: ChatScreen exit reasons (shouldDiscardDraft, removed)
2026-10-31 03:55:15 +05:00
levzzzandClaude Sonnet 5.5 303e418b08 LocalPlayer.getRopeHoldPosition, SnifferSoundInstance.tick, openWorldCheckWorldStemCompatibility: finish the partial ports
getRopeHoldPosition: main-arm side, the eased yaw/pitch (Mth.lerp(pt*0.5, yRot, yRotO)) and getEyePosition(pt).
SnifferSoundInstance: follows the sniffer, stops when it is removed, has a target or stops digging.
openWorldCheckWorldStemCompatibility: reads the enabled data packs' dimensions and a world that
still needs file fixes, so the backup question comes on the first open.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:54:33 +05:00
levzzz 403df829f3 Merge pull request #4723 from levzzz5154/parity/13-riding-nautilus
Nautilus riding sound and legacy server list ping
2026-10-31 03:50:31 +05:00
levzzzandClaude Sonnet 5.5 8e40d11b27 Hash item components in HashedStack like the vanilla client
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:50:23 +05:00
levzzzandClaude Sonnet 5.5 e921ddb7c5 Port HashOps (CRC32C) for item component hashes
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:45:37 +05:00
levzzz a4fec395ee Merge pull request #4733 from levzzz5154/parity/D-transient
Show a landed falling block until its mesh is ready (AddTransientBlock)
2026-10-31 03:44:54 +05:00
levzzzandClaude Sonnet 5.5 d8d6dd15aa Chat drafts follow ChatScreen.ExitReason: an interrupted chat keeps its text
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:43:26 +05:00
Ludovic Marie e825d36af8 Merge pull request #4675 from horizon-rust/feat/anticheat-ground
🤖 Agent: review findings fixed (happy ghast rider bound, grace for ground removed server-side); pr_ready --quick green; anticheat_e2e ground and off pass; rdbtCVS's placement question answered and passed to Ludovic
2026-10-30 23:41:19 +01:00
levzzz ae19796505 Merge pull request #4731 from levzzz5154/parity/F-stuck-arrows
Draw arrows and bee stingers stuck in a player's body
2026-10-31 03:40:25 +05:00
levzzz ec156a856f Merge pull request #4732 from levzzz5154/parity/17-open
World open: backup question for customized/experimental worlds, low disk space, unreadable level data
2026-10-31 03:39:42 +05:00
levzzzandClaude Sonnet 5.5 d49bcab591 Show the block a falling block lands as (ClientboundAddTransientBlockPacket)
The server tells the players tracking a landed falling block to show the
block for a moment, and the client draws it as a moving block until its
section mesh has been rebuilt or a second has passed.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:36:01 +05:00
Ludovic Marie 5f5acece3f Merge remote-tracking branch 'origin/main' into feat/smp-grief-hooks 2026-10-30 23:34:04 +01:00
Ludovic MarieandClaude Opus 5.5 51119592e2 Native mods: pass the bucket's stack as it is (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:34:03 +01:00
levzzz f4a81d27ee Merge pull request #4729 from levzzz5154/parity/16-video
Video Settings: Exclusive Fullscreen (and mode), Graphics API, Texture Filtering rows
2026-10-31 03:33:48 +05:00
levzzzandClaude Sonnet 5.5 1f4dba50e3 WorldOpenFlows: backup question for customized and experimental worlds, low disk space, unreadable level data
askForBackup / openWorldCheckWorldStemCompatibility: a world with
legacy_custom_options or a non-vanilla dimension asks for a backup first.
openWorldCheckDiskSpace: under 64 MiB usable asks Continue or Back.
openWorldLoadLevelData: a world opened by name (control socket) whose
level.dat does not read shows the Recover World Data screen, and a world
opened without a title no longer becomes a new random-seed world.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:32:14 +05:00
levzzzandClaude Sonnet 5.5 7a09947e46 Draw arrows and bee stingers stuck in a player's body
LivingEntity's arrow and stinger counts are synched entity data (12, 13):
the server counts a hurting arrow hit and a bee sting, lets them fall out
as LivingEntity.tick does, and the clients draw them with StuckInBodyLayer's
placement (ArrowLayer, BeeStingerLayer).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:31:07 +05:00
Ludovic MarieandClaude Opus 5.5 6034bab012 Anticheat ground: a happy ghast's rider, and a grace for vanished ground
A player standing on a happy ghast is 4.0 above it: the carrier test
now takes from a block above to 4.5 below (carries_player, tested). A
claim at the height where the ground last held the player, within 10
ticks, isn't judged: the block may have gone server-side (broken,
pushed by a piston) before the client heard.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:28:16 +01:00
Ludovic Marie 1b06b53500 Merge pull request #4726 from horizon-rust/fix/region-writer-thread-limit
🤖 Agent: verified. Fixes the official server's 21:54Z crash (region writer panicked on EAGAIN when spawning a helper); unit test covers refused helpers; pr_ready --quick green.
2026-10-30 23:28:05 +01:00
levzzzandClaude Sonnet 5.5 6907fe0a79 Video Settings: Exclusive Fullscreen, its mode, Graphics API, Texture Filtering and Anisotropic Filtering rows
VideoSettingsScreen.addOptions/displayOptions/qualityOptions: the Exclusive
Fullscreen Mode row, the Exclusive Fullscreen and Graphics API options, and the
Texture Filtering and Anisotropic Filtering options (greyed out until the
terrain sampler reads them). Exclusive fullscreen takes effect through winit's
exclusive video modes; the Graphics API option drives Horizon's own API setting
and asks for a restart.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:21:26 +05:00
levzzz a12b5151e1 Merge pull request #4724 from levzzz5154/parity/A4b-test-instance
Test instance block: entity, packets, actions, edit screen and renderer
2026-10-31 03:21:19 +05:00
Ludovic Marie 65b312814b Merge pull request #4722 from horizon-rust/perf/sculk-portal-per-mob
🤖 Agent: verified. Same tables, order and events; A/B with 60 bots: sculk entities −25%, entity portals −44%. pr_ready --quick green; sculk parity runs in CI.
2026-10-30 23:17:17 +01:00
levzzz 771394b76d Merge pull request #4720 from levzzz5154/parity/40-worker-thread
Crash report and exit on a streaming worker thread panic
2026-10-31 03:12:57 +05:00
levzzz 5f06dbbc16 Merge remote-tracking branch 'hr/main' into parity/A4b-test-instance 2026-10-31 03:12:54 +05:00
levzzz ef7ef9959c Merge pull request #4721 from levzzz5154/parity/A4a-test-block
Test block: block entity, packet, handler and edit screen
2026-10-31 03:12:50 +05:00
levzzzandClaude Sonnet 5.5 a1f56c2f2e Ride-sound for nautilus and legacy server list ping (parity #3518)
LocalPlayer.startRiding starts RidingEntitySoundInstance for an AbstractNautilus (underwater loop);
RidingEntitySoundInstance reads the ridden entity's isUnderWater. ServerStatusPinger falls back to
pingLegacyServer when the status ping fails, and a protocol mismatch shows as incompatible.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:11:51 +05:00
Ludovic MarieandClaude Opus 5.5 c3c2ffc43e Saves: a thread the system refuses no longer stops the region writer
The region writer shares a batch's compression out to scoped helper threads.
thread::scope's spawn panics when the system refuses a thread, and on the
official server (a container at its limit of threads, EAGAIN) that panic
stopped the writer: chunks and entities failed to save for four minutes, then
the server stopped with "the region writer stopped".

Helpers now start with Builder::spawn_scoped; one that can't start leaves its
share to the others, and the writer thread takes chunks too (helpers - 1
threads plus itself, as many as before), so at worst it does the whole batch
alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 23:06:51 +01:00
levzzz 0ec594d9d3 Merge remote-tracking branch 'hr/main' into parity/A4a-test-block 2026-10-31 03:05:24 +05:00
levzzz 98b6a442cf Merge pull request #4716 from levzzz5154/parity/A3-jigsaw
Jigsaw block: block entity, packets, handlers and edit screen
2026-10-31 03:05:21 +05:00
levzzz 96caf0bf88 Merge pull request #4718 from levzzz5154/parity/10-minor
Sprint: a shallow wall slide no longer stops the sprint (isHorizontalCollisionMinor)
2026-10-31 03:05:10 +05:00
levzzzandClaude Sonnet 5.5 31192110c6 Test instance block: block entity, packets, handler, edit screen, renderer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:04:45 +05:00
levzzzandClaude Sonnet 5.5 00b1e2c12a A panic on a streaming worker thread writes a crash report and ends the game
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 03:04:16 +05:00
rdbt b0cd391031 Merge pull request #4159 from spidercraft/far-terrain-fade
Far terrain fades in and out chunk by chunk, near chunks too
2026-10-30 22:02:19 +00:00
rdbt 22c09057fc Merge pull request #4158 from spidercraft/far-terrain-look
Far terrain: shaded water and canopies, swamp grass, no floor pillars or water walls
2026-10-30 22:02:15 +00:00
rdbt 7366ff29b5 Merge pull request #4157 from spidercraft/far-terrain-speed
Far terrain: real chunks out to level 2 on idle threads, loading outward from the player
2026-10-30 22:02:10 +00:00
rdbt fd92adb803 Merge pull request #4156 from spidercraft/far-terrain-cache
Far terrain: decorated chunks kept on disk, read again next session
2026-10-30 22:02:05 +00:00
levzzz b914de7e69 Merge pull request #4715 from levzzz5154/parity/31-lookat
Send PlayerLookAt for /tp and /rotate facing
2026-10-31 03:01:16 +05:00
Ludovic Marie c5d4217dbb Merge pull request #3251 from 1038april/fix/remote-sprint-hit-slowdown
Apply the attacker's 0.6 speed cut after a knockback hit on another p…
2026-10-30 23:00:09 +01:00
levzzzandClaude Sonnet 5.5 3c5dd6b03b Port LocalPlayer.isHorizontalCollisionMinor so a shallow wall slide keeps the sprint
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:57:06 +05:00
levzzz c4c4b63aba Merge remote-tracking branch 'hr/main' into parity/A3-jigsaw 2026-10-31 02:55:41 +05:00
levzzz 56f9a17682 Merge pull request #4712 from levzzz5154/parity/A2-bounding-box
Structure block: bounding box and invisible block markers
2026-10-31 02:55:38 +05:00
levzzzandClaude Sonnet 5.5 35212f09a5 Send ClientboundPlayerLookAtPacket for /tp facing and /rotate facing
ServerPlayer.lookAt tells the player's client; /rotate also used the
target's eyes instead of the source anchor.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:53:05 +05:00
levzzzandClaude Sonnet 5.5 e017850f93 Test block: block entity, packet, handler, edit screen
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:51:28 +05:00
Ludovic Marie 0d1dccd5ff Merge pull request #4563 from horizon-rust/feat/launcher-brand-logos
🤖 Agent: R8c approved by Ludovic, retargeted to main after #4552, main merged, full pr_ready green, CI green. The review found no defect. Its focus-state note: shell::action and button() draw no focus either, so nothing regresses. Queued.
2026-10-30 22:50:47 +01:00
levzzz 6307b54dea Merge pull request #4706 from levzzz5154/parity/07-ui
Show the accessibility onboarding on a first launch; cartography preview draws renderOnFrame markers
2026-10-31 02:50:03 +05:00
Ludovic MarieandClaude Opus 5.5 51629f9085 Server perf: sculk and portal checks no longer cost per mob per tick
On the official server (62 players, 1,445 mobs), tick_sculk_entities took
2.0 ms and entity_portals 1.5 ms a tick, 7% of a 47.9 ms tick.

- sculk: whether the block under a mob is climbable, or a redstone ore, comes
  from per-state tables built with the others in sculk_prepare, instead of a
  tag lookup by string and an Arc<str> clone per mob; a step knows whether a
  warden made it from the caller, instead of a scan of every mob.
- portals: canUsePortal is asked only for the entities that have a portal
  state (in a portal or cooling down), not for every mob, whose check scanned
  every server player; positions before the tick go in an FxHashMap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:49:35 +01:00
levzzz b74a78c970 Merge pull request #4708 from levzzz5154/parity/32-compstr
Compile selector and nbt entity strings when decoding text components
2026-10-31 02:49:28 +05:00
levzzz e694791bb5 Merge remote-tracking branch 'hr/main' into parity/A2-bounding-box 2026-10-31 02:46:24 +05:00
levzzz 65030881fc Merge pull request #4704 from levzzz5154/parity/A1c-structure-screen
Structure block: StructureBlockEditScreen
2026-10-31 02:46:20 +05:00
levzzzandClaude Sonnet 5.5 ceb5791a69 Jigsaw block: block entity, packets, handlers, edit screen
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:46:03 +05:00
levzzzandClaude Sonnet 5.5 4fa552591a Compile selector and nbt entity strings when a text component is decoded
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:42:56 +05:00
levzzzandClaude Sonnet 5.5 699afa8007 Show the accessibility onboarding on a first launch; draw only renderOnFrame markers on the cartography map
Gui.addInitialScreens queues AccessibilityOnboardingScreen first while onboardAccessibility is set:
the title box, Narrator (inactive without a narrator), Music & Sounds, Accessibility Settings,
Language and Continue, fading in over 2 s and out over 1 s, Escape or Continue finishing it
(Options.onboardingAccessibilityFinished). GuiGraphicsExtractor.map draws only the decorations
with renderOnFrame, so the player arrows and markers no longer show on the cartography preview.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:41:35 +05:00
levzzz dc35e78173 Merge pull request #4702 from levzzz5154/parity/33-adv
Parity: fall_after_explosion cause, effects_changed updates, /fetchprofile lookups
2026-10-31 02:38:17 +05:00
levzzz 49db9b276b Merge remote-tracking branch 'hr/main' into parity/A1c-structure-screen 2026-10-31 02:38:03 +05:00
levzzz 0b95a7a921 Merge pull request #4703 from levzzz5154/parity/A1b-structure-server
Structure block: handleSetStructureBlock (save, load, scan)
2026-10-31 02:37:59 +05:00
Ludovic MarieandClaude Opus 5.5 524071f9cf Native mods: hook points that keep fluids, fire, pistons, explosions, buckets and blows out of guarded land (API 22)
A server mod that guards land (the SMP's claims) can now refuse what changes
blocks without a player's break or place: a fluid flowing or fire spreading
into a block (native_block_spread), a piston's move (native_piston_move), an
explosion's blocks (native_explosion_detonate takes out the spared ones), a
bucket (native_fill_bucket) and a player's blow on an entity
(native_attack_entity, on the server's attack packet, so frames, armor
stands, animals and players). horizon-world asks through two LevelAccess
methods that allow by default; fire asks after its random draws, so a
refusal leaves vanilla's random order as it was.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 22:36:17 +01:00
Ludovic Marie 5d3a45f09f Merge remote-tracking branch 'origin/main' into feat/launcher-brand-logos 2026-10-30 22:32:41 +01:00
Ludovic Marie ab92024350 Merge pull request #4552 from horizon-rust/feat/launcher-instance-icons
🤖 Agent: R8b approved by Ludovic, retargeted to main after #4520, main merged, full pr_ready green, CI green; the review found no defect; queued.
2026-10-30 22:31:32 +01:00
levzzzandClaude Sonnet 5.5 b91f0330f4 Port FallAfterExplosion cause, EffectsChanged updates and /fetchprofile name|id lookups
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:28:40 +05:00
levzzz dc6e548538 Merge remote-tracking branch 'hr/main' into parity/A1b-structure-server 2026-10-31 02:27:38 +05:00
levzzz 95f097fd06 Merge pull request #4687 from levzzz5154/parity/A1-structure
Structure block: block entity, save format and packet codec
2026-10-31 02:27:33 +05:00
rdbt c6dd5e2205 Merge pull request #4526 from horizon-rust/fix/prepare-output-version-3
horizon-prepare: OUTPUT_VERSION 3 (creative tabs exporter changed in #4052)
2026-10-30 21:26:59 +00:00
levzzzandClaude Sonnet 5.5 1604f8abee BlockEntityWithBoundingBoxRenderer: the structure block's box and invisible-block markers
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:25:30 +05:00
levzzz f9d0a88d27 Merge pull request #4685 from levzzz5154/parity/42-ui
Show the file fixer's progress screen while a world upgrades
2026-10-31 02:25:18 +05:00
levzzzandClaude Sonnet 5.5 c739b7af73 StructureBlockEditScreen
The structure block's edit screen with vanilla's layout and lang keys, opened
by using the block (BlockEntity.usedBy), over a shared edit-screen runner the
jigsaw and test blocks will use.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:22:58 +05:00
levzzzandClaude Sonnet 5.5 177aff1240 Structure block: handleSetStructureBlock, save, load, scan and redstone trigger
The server side of the structure block: ServerGamePacketListenerImpl.
handleSetStructureBlock, StructureBlockEntity.saveStructure/placeStructure/
detectSize/loadStructureInfo, StructureTemplate.fillFromWorld + save, the
StructureTemplateManager cache and generated/<ns>/structures/<path>.nbt,
StructureBlock.neighborChanged/trigger/setPlacedBy, and placeInWorld on the
live level.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:22:44 +05:00
levzzz 062d66e495 Merge pull request #4680 from levzzz5154/parity/34-profiles
Add /op, /deop, /ban, /pardon and /whitelist to a dedicated server's commands
2026-10-31 02:20:02 +05:00
levzzzandClaude Sonnet 5.5 aa44f715e6 Structure block entity: save format, update tag, packet codec
StructureBlockEntity (fields, saveAdditional/loadAdditional, renderMode,
getRenderableBox, the corner scan) as a block entity kind, synced in the
chunk update tag like saveCustomOnly, and ServerboundSetStructureBlockPacket.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:17:03 +05:00
levzzzandClaude Sonnet 5.5 ffe68c936f Show FileFixerProgressScreen while a world's files are fixed
Part of #3518. UpgradeProgress counts the file fixes and the data upgrade; the
world-opening screen draws vanilla's layout from it, with Cancel.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:16:00 +05:00
Ludovic Marie d2fe5c0768 Merge remote-tracking branch 'origin/main' into feat/launcher-instance-icons
# Conflicts:
#	THIRD_PARTY_NOTICES.md
2026-10-30 22:15:52 +01:00
Ludovic Marie f788711fe1 Merge pull request #4520 from horizon-rust/feat/launcher-icons
🤖 Agent: R8a approved by Ludovic (logos everywhere), main merged, full pr_ready green, CI green; queued.
2026-10-30 22:14:45 +01:00
Ludovic Marie c6132e2b9b Merge pull request #4676 from horizon-rust/nightly/no-review-needed
Nightly: /nightly no longer needs an approving review
2026-10-30 22:13:44 +01:00
levzzzandClaude Sonnet 5.5 e1a530a71e Add /op, /deop, /ban, /pardon and /whitelist as commands of a dedicated server
GameProfileArgument.SelectorResult.getNames and the five commands that use
it, in vanilla's tree shape and permission level (admins).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 02:09:57 +05:00
Ludovic Marie b5b451de7c Merge pull request #4610 from horizon-rust/feat/smp-claim-hooks
🤖 Agent: verified. pr_ready --quick passed; 31/32 affected harnesses passed locally, and lunge_motion fails on main too, see the body. Hook points API 21 for the SMP's claims and teleports. Nothing changes without a native mod.
2026-10-30 22:08:16 +01:00
levzzz 1498477aec Merge pull request #4674 from levzzz5154/parity/04-dialog
Dialog suggest_command click fills the chat input
2026-10-31 02:07:57 +05:00
Ludovic MarieandClaude Sonnet 5.5 c527d60b75 Nightly: /nightly no longer needs an approving review
Ludovic decided on 2026-10-30, after the Team asked (levzzz5154: they are often
blocked on the launcher), that a trusted reviewer or Ludovic can pick any open
pull request to main for the Nightly, reviewed or not.

Kept: only Ludovic and the listed reviewers, the `paused` line, the
CODEOWNERS-guarded files, MAX_PRS, MAX_BUILDS_PER_DAY, open and targeting main,
the build without secrets, and Ludovic's `remove`. Each pull request is built at
the head it was picked at.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 22:04:22 +01:00
levzzz 0a825b42a2 Merge pull request #4672 from levzzz5154/parity/35-trig
Advancement triggers: durability ranges, killing_blow on all KilledTrigger uses, player bystanders
2026-10-31 02:01:59 +05:00
Ludovic Marie 75867c69b9 Merge remote-tracking branch 'origin/main' into feat/launcher-icons 2026-10-30 22:01:02 +01:00
levzzz 475d096fe2 Merge pull request #4671 from levzzz5154/parity/18-avatar-ears
Draw deadmau5's ears (Deadmau5EarsLayer)
2026-10-31 01:58:48 +05:00
Ludovic MarieandClaude Opus 5.5 c0f09fed88 Merge main: mod screens took API 18 to 20, these hooks are API 21
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:58:20 +01:00
levzzzandClaude Sonnet 5.5 04c7cfc1af Dialog suggest_command click inserts the command into the chat screen under it
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:57:18 +05:00
levzzz 70436b6f7d Merge pull request #4666 from levzzz5154/parity/00-container-death
Close a dead guest's open container on respawn
2026-10-31 01:52:31 +05:00
levzzzandClaude Sonnet 5.5 ecd85c497f Advancement triggers: item durability ranges, killing_blow on every KilledTrigger, players as lightning bystanders
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:51:40 +05:00
levzzzandClaude Sonnet 5.5 8bad582185 Draw deadmau5's ears: Deadmau5EarsLayer
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:49:42 +05:00
spiderandClaude Opus 5.5 963b7d8bd9 Far terrain: chunks leaving the render distance stay in front until they have faded
A chunk leaving the render distance popped to far terrain:

- Its sections were drawn only within the render distance, or in columns
  far tiles still leave out, so once fading out (leave_section) they were
  culled at once: the fade ran unseen. Columns with sections fading out
  are drawn past it now.
- Far terrain's coarser blocks stand a little out of the real ones, and
  0.2% of the distance behind did not keep them back: the tile coming in
  under a column hid its sections the moment it was meshed with it, held
  or fading. Sections past the render distance (lod::HELD) and fading out
  go 2% of their distance toward the camera.

Measured frame by frame over a forest, render distance 10 to 3: the
change spreads over about 0.33 s instead of landing in one frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:23 +00:00
spiderandClaude Opus 5.5 ee7d443c8f Far terrain: shaded water and canopies, swamp grass, no floor pillars or water walls
- Skylight goes straight down generated columns: water and leaves take a
  level a block, one span per level as a real chunk's light has them.
  Full light to the bottom lit sea beds as if in the open, and coarse cells
  merging water and sand showed that light as white dots across the sea.
  Solid blocks pass it on: under them far terrain shows cliffs and
  overhangs, which vanilla lights sideways (they went black).
- Kelp and seagrass in saved and generated chunks are the water they hold
  (loaded chunks: #4144).
- Grass takes vanilla's colour at each cell (BiomeColors' grass with its
  swamp and dark forest modifiers), not the biome's flat one.
- Buried gaps are filled only where no open air reaches them through
  neighbouring gaps (a flood fill): the air under a forest canopy was
  filled with the floor block, pillars up to the leaves.
- The seal far tiles put where near terrain begins is opaque only: near
  water carries on into far water, and the seal showed through it as a
  light wall.
- A section beside a chunk not loaded meshes against its own edge column,
  not lit air: the outermost chunks' water drew a light sheet toward it
  (fluids show both sides).

Seed 1234, LOD Distance 512: 9.27M far quads become 11.22M, the trunks and
forest floor under canopies.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:23 +00:00
spiderandClaude Opus 5.5 3554efb04f Far terrain: a circular range; tiles leaving it fade out instead of being cut
The renderer cut far tiles whose nearest point was past the LOD distance,
a circle, every frame, while the plan kept tiles out to a square: tiles
popped out at the circle as the player moved, before the plan dropped
them and before their fade-out could run. The plan's range is now the
same circle (TileKey::reach) and the renderer no longer cuts by distance
(the frustum only): a tile leaving the range leaves the plan and fades out
(upload_lods), as tiles entering it fade in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:23 +00:00
spiderandClaude Opus 5.5 94f48bbf6a Far terrain: near chunks and far tiles fade out as they came
Unloading popped where loading faded:

- A section whose column far terrain now draws (a shorter render
  distance, the player moving away) fades out over it, chunk by chunk as it
  came, and is released once it has (Renderer::leave_section), instead of
  in one frame. Columns far tiles still leave out stay drawn until they
  don't (#3765), then fade out the same way.
- A far tile leaving the plan with nothing to replace it (out of range: a
  shorter LOD distance, the player moving away) fades out. A tile replaced
  by new tiles, or by near chunks fading in, stays whole behind them as
  before: two patterns fading at once left holes between them.

The fade code gains a fading-out range (65 to 128 sixty-fourths, per
level 129 values): a section's largest code, 129, keeps its UV step exact
(checked in tiles_fade_in_on_their_own_grid).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:23 +00:00
spiderandClaude Opus 5.5 ada4e6567d Far terrain fades in chunk by chunk, and near chunks fade in over it
New far tiles fade in over the ones they replace, and new near sections
over far terrain, instead of appearing at once. Each chunk-sized column of
a tile (wider ones for coarse tiles) starts in turn over the first half of
600 ms and fades over the second, as a fine ordered dither fixed to the
terrain: its unit is the power of two of a block nearest 1.5 pixels, so it
stays fine up close, does not sparkle far off and does not crawl as the
camera moves. The tiles replaced stay whole behind until it is done: no
pixel drawn twice, no hole.

- The fade rides in the instance's UV step (negated past a fade code);
  terrain.wgsl and GLSL packs take the step as fract(abs(w)), unchanged
  for whole tiles and sections.
- A fading tile or section is drawn a hair toward the camera; far tiles
  0.2% of their distance back, so near chunks win over tiles still under
  them (near water lies a little below far water: a smaller step left two
  waters, opaque).
- Water and other see-through faces are not dithered, and near water is
  drawn before far water, replaced tiles' last: depth keeps each layer to
  where the one before has none.
- A tile re-meshed around new near chunks or finer tiles keeps drawing its
  old mesh until they are in; a tile over nothing shows whole (its hidden
  chunks opened holes to the sea bed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:23 +00:00
spiderandClaude Opus 5.5 fb9db82d1b Far terrain: real chunks out to level 2 on idle threads, loading outward from the player
- Decoration (real trees and structures) runs on a third of the threads, up
  to 12, at idle priority, instead of one; batches of 8x8 chunks share
  their proto border (2.25 protos a chunk instead of 4): 16.6 ms a chunk
  instead of 26.9.
- Tiles up to level 2 (cells of 4 blocks) are decorated, not only 0 and 1;
  a detail request asks for up to 512 chunks instead of 32, which capped
  detail at one decoration thread's pace.
- Tiles mesh across the sampling threads, each with its own material
  cache, instead of on the far terrain thread alone.
- Only the tiles the plan keeps are sampled, each at the level its
  distance calls for, nearest first: far terrain grows outward from the
  player in rings, whichever way they look. Coarse tiles sampled first
  under fine ones beside the player cost a far tile's work for terrain
  replaced moments later. The view-ahead focus and Lod::update's yaw go.
- The stop flags are set under the lock their threads wait on: a thread
  between its check and its wait missed the wake, and closing the world
  waited forever (more likely with more threads).

Fresh world, LOD Distance 512: every tile sampled after about 12 s instead
of 15; real detail after 40 s 500k cells instead of 447k.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:16 +00:00
rdbtCVSandClaude Opus 5.5 8922550fdf Far terrain cache: a chunk cached already is not written again
A generation batch hands back its neighbours too, and drain_details put
every one of them, cached or not: the append-only region files grew by
the same chunks each session until the build key changed. The build key
already clears stale chunks, so one cached stays as it is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:16 +00:00
Ludovic Marie 1085b42fc5 Merge pull request #4657 from horizon-rust/fix/log-flood-limit
🤖 Agent: verified. Unit test covers the per-line limit, the note and the left-out count; info lines are never limited. pr_ready --quick green.
2026-10-30 21:48:03 +01:00
spiderandClaude Opus 5.5 2e321e385a Far terrain: decorated chunks kept on disk, read again next session
Far terrain decorates the chunks of its nearest ring with the world's own
generator (vanillagen/lod.rs), kept in memory only, 1024 of them: every
session generated them again. They now go to
dimensions/<dim>/data/horizon_far_terrain/, one append-only file per region
(lod/cache.rs, about 6 KB a chunk deflated), and a detail request reads
them back before asking the generator.

- Saved chunks still take precedence over cached ones.
- A file written by another build, block registry or format is cleared on
  first use; a torn last record (a crash mid-write) is cut off.
- Only detail requests read the cache: reading it for every cell of the
  coarse tiles' sampling slowed their sampling more than it saved.

Rejoining a world at LOD Distance 512: 343k refined far cells after 45 s
with the cache, 244k without.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:47:55 +00:00
levzzz 9c471a85ce Merge pull request #4653 from levzzz5154/parity/36-packs
Parity: validate pack.mcmeta formats, clear packs on a failed reload
2026-10-31 01:47:37 +05:00
levzzz 0bb993afd7 Merge pull request #4658 from levzzz5154/parity/29-render
Wind charge renderer: age includes the partial tick
2026-10-31 01:45:51 +05:00
levzzz 6f26c1f355 Merge pull request #4655 from levzzz5154/parity/36-adv
Parity: passenger predicate, boat vehicles, sulfur cube shear trigger
2026-10-31 01:44:23 +05:00
levzzzandClaude Sonnet 5.5 2052e55b0b Close a dead guest's open container when it respawns
AbstractContainerScreen.tick closes the container of a dead player; a guest's
chest stayed counted as open (lid up) after its death.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:43:45 +05:00
Ludovic MarieandClaude Opus 5.5 8975278e5e Anticheat: the ground check (no-fall, walking on water)
A move that says it ends on the ground is judged when nothing there can
hold a player: only air and fluids around the feet, no collision a tenth
of a block down, no boat, minecart, shulker or happy ghast near. The
false claim is dropped, so vanilla's fall damage follows, and the levels
send back, alert and kick as configured. Creative, spectators and
gliders are skipped. Off unless [anticheat] turns it on.

horizon-bots: --height and --ground. anticheat_e2e --check ground: a
hovering bot and one on water that claim the ground are flagged and
kicked, bots walking and standing on the ground never are.
server_bench --ground: survival bots on a flat world's ground.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:42:45 +01:00
levzzz c4c7df589a Merge pull request #4652 from levzzz5154/parity/12-options
Pause menu bug and feedback links, title credits text button
2026-10-31 01:40:27 +05:00
levzzzandClaude Sonnet 5.5 f387c1d78f fmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:35:54 +05:00
Ludovic Marie 571e0e5341 Merge pull request #4650 from horizon-rust/fix/vehicle-moved-wrongly
🤖 Agent: verified. lan_e2e boat_shoved fails on main (boat stuck, 70 'moved wrongly' lines) and passes here (paddles 3.0, 1 line); mounts and riding pass; boats fails the same on main (fist breaking, unrelated). pr_ready --quick green.
2026-10-30 21:34:44 +01:00
levzzzandClaude Sonnet 5.5 68b90d860b Wind charge: spin and scroll with the partial tick (WindChargeRenderer.submit)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:30:01 +05:00
Ludovic MarieandClaude Opus 5.5 53ba582419 Log: limit a warning or error from one line of code to 100 a minute
A warning repeated on every packet filled the official server's log at up to
6,700 lines a second (16 MB in 30 minutes). Past 100 warnings or errors from
one line of code in a minute, the log says so once, counts the rest, and says
how many it left out when that line logs again in a later minute. Below the
limit the log is vanilla's, line for line; info lines (chat, joins) are never
limited.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:29:35 +01:00
rdbt c995c9b76e Merge pull request #4098 from DUzzL/fix/issue-4086-main-hand
Send the stored Main Hand option on join
2026-10-30 20:28:41 +00:00
levzzzandClaude Sonnet 5.5 ab2ab8ef61 Advancements: passenger predicate, boats as vehicles, sulfur cube shearing trigger
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:28:33 +05:00
rdbt 9e267fbf9e Merge pull request #4636 from JustKarkat/fix/target-anger-death-forgiveness
A dying player's forgiveness reaches the enderman, zombified piglin and iron golem
2026-10-30 20:28:30 +00:00
rdbt 618469513b Merge pull request #4632 from ludovicthenot/fix-key-names-layout
Key names follow the keyboard layout: an AZERTY player's Q key shows A
2026-10-30 20:28:24 +00:00
rdbt e387764263 Merge pull request #4626 from AurelienBelloir/parity/block-entity-updates
Send beacon, end gateway and creaking heart update tags
2026-10-30 20:28:19 +00:00
rdbt 0e6e9ab098 Merge pull request #4624 from AurelienBelloir/parity/team-packets-to-guests
Send a team leave before the join that moves a player
2026-10-30 20:28:14 +00:00
rdbt 6f3757c8e4 Merge pull request #4621 from WooCaash/fix/4567-pillager-ominous-banner
Give raid captains the ominous banner stack for clients
2026-10-30 20:28:09 +00:00
rdbt 49878e9aee Merge pull request #4617 from WooCaash/fix/4428-title-screen-toasts
Draw ToastManager toasts over the title screen
2026-10-30 20:28:03 +00:00
rdbt dd63a5cc5f Merge pull request #4603 from xFedeT/fix/piglin-doors
Piglins open the doors on their path (InteractWithDoor)
2026-10-30 20:27:58 +00:00
rdbt 8e83c3eb23 Merge pull request #4596 from xFedeT/fix/path-recompute
Mobs re-path when a block changes near their route (PathNavigation.shouldRecomputePath)
2026-10-30 20:27:52 +00:00
rdbt 48f539e7d3 Merge pull request #4595 from doodlyyousuf/fix/sound-packet-seed
Give each server sound packet its own seed
2026-10-30 20:27:46 +00:00
rdbt 6db9492f5f Merge pull request #4587 from phexuss/fix/cartography-map-markers
Draw map markers on the cartography table's preview
2026-10-30 20:27:41 +00:00
rdbt 3ad2bda85f Merge pull request #4585 from xFedeT/fix/frog-preferred-jump
Port map: the frog and goat long jump rows point at frog.rs (ported in #3946)
2026-10-30 20:27:34 +00:00
rdbt 2817362973 Merge pull request #4582 from phexuss/fix/enchanting-book
Draw the enchanting table's book in its screen
2026-10-30 20:27:27 +00:00
rdbt 4acfc70975 Merge pull request #4578 from phexuss/fix/happy-ghast-riding-sound
Play the happy ghast's riding sound while the player rides one
2026-10-30 20:27:22 +00:00
levzzzandClaude Sonnet 5.5 d214aa61d7 Validate pack.mcmeta formats like PackFormat.IntermediaryFormat; clear packs on a failed reload
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:27:18 +05:00
rdbt 797d6512ea Merge pull request #4568 from xFedeT/fix/armadillo-follow-adult
Baby armadillos follow the nearest visible adult (AdultSensor, BabyFollowAdult 5..16)
2026-10-30 20:27:16 +00:00
rdbt 3c66c314b9 Merge pull request #4566 from CocofireHD/fix/pillager-crossbow
A pillager shows the arrow in its loaded crossbow
2026-10-30 20:27:11 +00:00
rdbt 337b0e67fb Merge pull request #4564 from doodlyyousuf/fix/item-slime-piston-bounce
Bounce dropped items on slime, including piston-moved slime
2026-10-30 20:27:05 +00:00
rdbt 12a7bf8b29 Merge pull request #4559 from xFedeT/fix/frog-breeding
Frogs breed: slimeball feeding, pregnancy, and frogspawn laid on water
2026-10-30 20:27:00 +00:00
rdbt 3a906e8647 Merge pull request #4544 from AurelienBelloir/test/windows-world-preset-dirs
Name the world preset test's folders without a colon
2026-10-30 20:26:54 +00:00
rdbt d2b451af13 Merge pull request #4540 from DmytroChup/fix-4431
Use workspace base64 and sha1 crates in skins.rs
2026-10-30 20:26:48 +00:00
rdbt 5560aadd24 Merge pull request #4523 from xFedeT/fix/kid-tag-run-off
mob_ai: baby villagers play tag, checked tick for tick against vanilla
2026-10-30 20:26:42 +00:00
levzzzandClaude Sonnet 5.5 795c8e96a0 Pause menu bug/feedback links and the title's credits text button
PauseScreen.createPauseMenu: the Report Bugs and Give Feedback icon buttons open
CommonLinks through a trusted ConfirmLinkScreen. PlainTextButton on the title's
bottom text opens Credits & Attribution.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:26:37 +05:00
rdbt f257d62aa3 Merge pull request #4518 from xFedeT/fix/copper-golem-doors
Copper golems open the doors on their path (InteractWithDoor)
2026-10-30 20:26:37 +00:00
rdbt 4bd0bd7dc1 Merge pull request #4516 from arismoko/vanilla-server-block-writes
Route vanilla server block writes through level updates
2026-10-30 20:26:32 +00:00
rdbt b0d000b4b4 Merge pull request #4501 from xFedeT/fix/iron-golem-village-parity
Iron golems draw the sprint particle's randoms; village goal parity scenarios
2026-10-30 20:26:26 +00:00
rdbt 860a3ec2b2 Merge pull request #4497 from xFedeT/fix/fish-school-parity
Fish schools recruit in vanilla's order, with mob_ai parity scenarios (FollowFlockLeaderGoal)
2026-10-30 20:26:21 +00:00
rdbt 0c65b58a65 Merge pull request #4492 from xFedeT/fix/dolphin-follow-ridden
Dolphins swim after the boat or nautilus a player steers (FollowPlayerRiddenEntityGoal)
2026-10-30 20:26:16 +00:00
rdbt 5ab1a8f543 Merge pull request #4468 from Bloodulon/pr/display-passengers
Client: seat displays and hitboxes on whatever carries them
2026-10-30 20:26:11 +00:00
rdbt 0e282c022c Merge pull request #4467 from Bloodulon/pr/interaction-entities
Client: draw click boxes and hit-test server interaction entities
2026-10-30 20:26:06 +00:00
rdbt dcddbbe841 Merge pull request #4457 from DenisHumen/fix/player-attribute-save
Save and restore the player's attribute bases
2026-10-30 20:26:00 +00:00
rdbt 03f7a69993 Merge pull request #4367 from elythepreciouscat/feat/quit-shortcuts
Controls: implement Quit Shortcuts toggle and window-close blocking
2026-10-30 20:25:55 +00:00
rdbt 2d4e6ba0be Merge pull request #4339 from MSedgeMC/fix/3962-cooking-recipe-book
Encode cooking recipes into the recipe book
2026-10-30 20:25:50 +00:00
rdbt cab2754fe1 Merge pull request #4332 from WhyFovius/fix/player-hurt-sound
Combat: the hurt player hears its own hurt sound (single player and multiplayer)
2026-10-30 20:25:44 +00:00
rdbt dfb9e41079 Merge pull request #4307 from MSedgeMC/fix/4129-nametag-pose-height
Hang nametags from the pose box, not the sneak flag
2026-10-30 20:25:38 +00:00
rdbt 1374959d6f Merge pull request #4304 from MSedgeMC/fix/eye-of-ender-lift
Lift the thrown eye of ender by the item ground transform
2026-10-30 20:25:33 +00:00
rdbt 0f1a100391 Merge pull request #4283 from DUzzL/fix/issue-4269-creative-tab-tooltip
Pad the creative tab tooltip like the item tooltip
2026-10-30 20:25:27 +00:00
rdbt ac6f38c3d6 Merge pull request #4277 from DUzzL/fix/issue-4266-candle-on-cake
Place a candle on an uneaten cake
2026-10-30 20:25:22 +00:00
rdbt 5d47a8557c Merge pull request #4270 from DenisHumen/fix/lectern-scroll
Keep the mouse wheel off the hotbar while a book or lectern is open
2026-10-30 20:25:16 +00:00
rdbt 48960bffd6 Merge pull request #4263 from DUzzL/fix/issue-4251-lectern-take-book
Close the lectern book screen after taking the book
2026-10-30 20:25:10 +00:00
rdbt 5370ae92cf Merge pull request #4258 from DUzzL/fix/issue-4248-render-icons
Clip HUD item icons as polygons at the scissor edge
2026-10-30 20:25:05 +00:00
rdbt 77e8224441 Merge pull request #4249 from DenisHumen/fix/return-to-sender
Return to Sender: a fireball struck back credits the player's kill
2026-10-30 20:24:59 +00:00
rdbt c003ada028 Merge pull request #4246 from DenisHumen/fix/falling-block-offset
Falling blocks keep their random model offset, so pointed dripstone doesn't jump when it falls
2026-10-30 20:24:53 +00:00
rdbt 4daf4be5c0 Merge pull request #4238 from DUzzL/fix/issue-4234-worldspawn-after-levels
Stop the levels E2E test leaving an Overworld spawn point
2026-10-30 20:24:47 +00:00
rdbt b3f160b674 Merge pull request #4216 from DenisHumen/fix/shield-server-stop
Lower the shield when a server stops the player's use
2026-10-30 20:24:42 +00:00
rdbt 13451e7207 Merge pull request #4207 from GIGABAIT93/fix/port-publish-utf8
port_publish: read gh's and Kira's output as UTF-8
2026-10-30 20:24:37 +00:00
rdbt 00c456d313 Merge pull request #4196 from kacpherek/fix/gameplay-mp-combat
Riders never claim to be on the ground, so Grim stops flagging boats
2026-10-30 20:24:31 +00:00
rdbt 6c8e7e3e59 Merge pull request #4195 from kacpherek/fix/discord-carts-boats
A player who boards a boat while gliding leaves the elytra pose
2026-10-30 20:24:26 +00:00
rdbt a71dfda331 Merge pull request #4194 from kacpherek/fix/discord-dupes
Creative inventory: send the edit with the click, so holding Q over a stack can't duplicate it
2026-10-30 20:24:21 +00:00
rdbt 0d1681d649 Merge pull request #4193 from DUzzL/fix/issue-4190-crystal-beam-reset
Clear a respawned end crystal's beam for LAN guests
2026-10-30 20:24:15 +00:00
rdbt 318ed97be4 Merge pull request #4172 from DUzzL/fix/issue-4168-pack-download-timeout
Time out stalled resource pack downloads
2026-10-30 20:24:10 +00:00
Ludovic MarieandClaude Opus 5.5 fde63c0597 Server: reset a ridden vehicle's last good position every tick
ServerGamePacketListenerImpl.tick sets vehicleFirstGood/LastGood to where the
vehicle is at every tick. Horizon set it only on boarding and after an
accepted move, so once the server moved the vehicle itself (/tp, a piston,
a push), the rider's moves were checked against a stale position: each one
failed, was sent back, and the client's answer failed again. The boat stayed
stuck and the official server logged up to 6,700 "moved wrongly" lines a
second.

lan_e2e boat_shoved: a piston shoves, then /tp moves, the boat the host and
the guest each ride; they paddle on, with at most a few "moved wrongly" lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 21:24:05 +01:00
rdbt 1803841469 Merge pull request #4146 from DUzzL/fix/issue-4145-gateway-exact-teleport-key
Use the ExactTeleport key in the levels E2E end-gateway setup
2026-10-30 20:24:04 +00:00
rdbt 660babec5b Merge pull request #4141 from DUzzL/fix/issue-4134-slot-gap-hover
Hit-test the one-pixel border around inventory slots
2026-10-30 20:23:59 +00:00
rdbt 3613a6eb0d Merge pull request #4138 from DUzzL/fix/issue-4131-crystal-random-rotation
Randomise the spin phase of end crystals placed by the server
2026-10-30 20:23:53 +00:00
rdbt 49544daa24 Merge pull request #4132 from DUzzL/fix/issue-4118-attribute-main-hand
Apply held-item attribute modifiers to the main hand
2026-10-30 20:23:47 +00:00
rdbt 44a07ac4bf Merge pull request #4125 from Benflex1/fix/3515-trade-sound
Play the merchant's yes sound on a shift-clicked trade
2026-10-30 20:23:42 +00:00
rdbt 1beefecb54 Merge pull request #4108 from DUzzL/fix/issue-4096-remote-default-skin
Show the default skin when a remote player's skin sprite is missing
2026-10-30 20:23:34 +00:00
rdbt a2fa7b322d Merge pull request #4101 from DUzzL/fix/issue-4090-lingering-cloud-color
Keep the potion colour on lingering clouds
2026-10-30 20:23:28 +00:00
levzzz e654389d2a Merge pull request #4646 from levzzz5154/parity/26-render
Draw a happy ghast's quad leads and ropes, bob pufferfish
2026-10-31 01:23:22 +05:00
rdbt 0ce6c9fe94 Merge pull request #4093 from arismoko/fix/server-lists-offline-test
Keep stopped server list test offline
2026-10-30 20:23:19 +00:00
rdbt d57bcbca83 Merge pull request #4081 from xFedeT/fix/inventory-preview-lighting
Inventory preview: the player is lit as ENTITY_IN_UI, not by a guess
2026-10-30 20:23:13 +00:00
rdbt 521a01282b Merge pull request #4075 from xFedeT/fix/ctrl-click-right-click
Right Click Emulation: Control + left click is a right click on macOS
2026-10-30 20:23:07 +00:00
rdbt aa1a9b9379 Merge pull request #4012 from elythepreciouscat/fix/armor-rendering
Render: multi-layer equipment definitions and overlays
2026-10-30 20:23:02 +00:00
rdbt 176cfef84f Merge pull request #4007 from DUzzL/fix/issue-4000-invisible-armor
Invisible players keep their armour, elytra and held items
2026-10-30 20:22:56 +00:00
rdbt 9d59efaf3f Merge pull request #3927 from DenisHumen/fix/launcher-trash-com
Launcher: moving a world to the trash no longer closes it on Windows
2026-10-30 20:22:51 +00:00
rdbt d9cbe12a4f Merge pull request #3917 from quintyo/fix/hand-swap-scale
Fix item model hand swap animation scale
2026-10-30 20:22:45 +00:00
rdbt 2b2851a0dc Merge pull request #3869 from spidercraft/fix/friends-code-ends-with-world
Play with Friends: leaving the world ends its invite code
2026-10-30 20:22:39 +00:00
rdbt 1adb5cf92d Merge pull request #3841 from DUzzL/fix/inventory-server-transfer
Reset the client menu when a server replaces the player
2026-10-30 20:22:34 +00:00
rdbt 4742c9b3f5 Merge pull request #3839 from GIGABAIT93/fix/offscreen-pointer
--offscreen: hide the window and leave the system pointer alone
2026-10-30 20:22:28 +00:00
rdbt 1f82ef2116 Merge pull request #3811 from DenisHumen/fix/launcher-menus-close-outside
Launcher: the version and account menus close on a click outside them
2026-10-30 20:22:23 +00:00
rdbt b9e348c2c0 Merge pull request #3669 from martipops/fix/stick-on-servers
Controller movement on servers: the stick moves as the keys it sets do
2026-10-30 20:22:17 +00:00
levzzz f4da935e0f Merge pull request #4289 from Sylviromi/native-mod-screen-widgets
Native mods: buttons, tooltips and item icons on mod screens
2026-10-31 01:19:39 +05:00
levzzzandClaude Sonnet 5.5 066eb31ad5 Draw a happy ghast's quad leads and ropes, bob pufferfish, scroll breeze wind by partial tick
PufferfishRenderer.setupRotations, RopesLayer.submit, LeashFeatureRenderer/EntityRenderer quad leash, RenderTypes.breezeWind.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:10:38 +05:00
levzzz 073afd484e Merge pull request #4639 from levzzz5154/parity/37-deathmsg
Reload sends tags and recipes and reloads advancements; code of conduct task
2026-10-31 01:08:21 +05:00
levzzzandClaude Sonnet 5.5 2bc03baa6b Merge main; buttons, tooltips and item icons on mod screens are API 20
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:04:57 +05:00
levzzz 3a5ac7ffed Merge pull request #4106 from Sylviromi/native-mod-screens
Native mods: mod-drawn screens for mod blocks (own GUI textures, layout, data)
2026-10-31 01:03:37 +05:00
levzzz 0a7ad01ed6 Merge pull request #4635 from levzzz5154/parity/23-misc
Parity: view-blocking overlay from isViewBlocking and the near plane
2026-10-31 01:00:57 +05:00
levzzzandClaude Sonnet 5.5 be0445848d Reload: send tags, recipes and the recipe book to players and reload their advancements; offer the code of conduct
PlayerList.reloadResources and ServerConfigurationPacketListenerImpl.addOptionalTasks.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 01:00:13 +05:00
JustKarkatandZCode f5c79065e9 A dying player's forgiveness reaches the target-anger mobs
The forgiveness dispatch's default arm is the tame gate, which needs
extra.angry_at — a field only the wolf's anger writes. The enderman and
the zombified piglin hold their persistent anger as their target (the
stare goal, the hurt-by path) and the iron golem keeps a timer and its
target, so none of the three were ever forgiven when the player they
were angry at died, while vanilla's NeutralMob.playerDied ends the anger
of all three through stopBeingAngry (Enderman and ZombifiedPiglin are
NeutralMob implementers in 26.3, checked with javap).

Their arms clear the target the dying player held, the last hurt by
with it, and for the golem the timer; anger at another mob stays, and
the tame, bee and polar-bear paths are untouched.

Follow-up to #3507, closed for main carrying the area and gate fixes:
this is the piece main still misses, on a fresh small branch as asked.

Co-Authored-By: ZCode <[email protected]>
2026-10-30 14:57:09 -05:00
levzzzandClaude Sonnet 5.5 a14cac7f7c Merge main; mod screens are API 19; the server menu skips the slots a mod screen hides
A slot the mod's screen hides was still in the server's menu, so a shift-click
could move items into a slot the player cannot see. The server asks the same
native_screen when it opens the menu and the menu's clicks and quick moves skip
those slots. Also documents that data slots are 16-bit and start empty.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:56:49 +05:00
levzzzandClaude Sonnet 5.5 08918a8a7f Show the view-blocking block overlay by BlockState.isViewBlocking against the near plane
Frustum.getNearPlaneBounds, then stairs and slabs by outline, snow at 8 layers, others by isSuffocating.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:54:36 +05:00
levzzz 34118b285f Merge pull request #4622 from levzzz5154/parity/38-srvpkts
Server players: the flying kick and the build limit message told twice
2026-10-31 00:54:18 +05:00
levzzz b680d71bdc Merge pull request #4097 from Sylviromi/native-mod-neighbours
Native mods: what a machine's tick reaches (neighbouring mod block entities, its block's state)
2026-10-31 00:53:30 +05:00
Ludovic MarieandClaude Opus 5.5 1eedd39c76 lunge_motion: the jump's positions follow the server's ticks, not 50 ms sleeps
LocatorBarClient's arc slept 50 ms between positions, which drifts from the
server's ticks: on a busy runner the stab of `arc 8` came 8, 9 or 10 server
ticks after the jump, on vanilla as on Horizon, so the falling and sprinting
motions differed run to run (#4526's parity run failed on it). Each position
now waits until the game time moves past the latest `time query gametime`
reply, so the stab of `arc N` lands N ticks after the jump on both servers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:49:29 +01:00
Neferr 74351c8057 Key names follow the keyboard layout: an AZERTY player's Q key shows A
Keys are kept by position (SDL scancodes), as 26.3 keeps them, but named by what the US layout writes. Each key press now records what the player's layout writes on that key (winit's key_without_modifiers), and display_name shows it, as SDL_GetKeyFromScancode names keys with SDL's default keycode options: letter and symbol keys only, Latin letters only on a letter key, digits kept on the number row. Fixes #4592.
2026-10-30 20:46:36 +01:00
levzzz 8486a32926 Merge pull request #4620 from levzzz5154/parity/21-antenna
Copper golem antenna: apply the item's block_state component
2026-10-31 00:46:17 +05:00
levzzzandClaude Sonnet 5.5 31300e6846 Merge hr/main into parity/38-srvpkts
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:45:58 +05:00
Ludovic MarieandClaude Opus 5.5 cf6e5c2474 Merge main: mod inventories and menus took API 16 and 17, these hooks are API 18
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:45:35 +01:00
levzzzandClaude Sonnet 5.5 deec9041ac Merge main; neighbour and block-state helpers are API 18
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:44:25 +05:00
levzzz c822f6eb82 Merge pull request #4072 from Sylviromi/native-mod-menus
Native mods: menus for mod containers (vanilla slot grids and furnaces)
2026-10-31 00:43:16 +05:00
Łukasz KopańskiandCursor e1314bbcc9 Give raid captains the ominous banner stack for clients.
Fixes #4567: wear_banner only set the white_banner id, so SetEquipment
sent a plain banner and clients without PatrolLeader drew white cloth.
Keep Raid.getOminousBannerInstance on armor[0] and treat that stack as
captain for CustomHeadLayer.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 20:42:23 +01:00
levzzzandClaude Sonnet 5.5 8d82e986f9 Server players: the floating kick, and the build limit told twice at the top
ServerGamePacketListenerImpl.noBlocksAround, getMaximumFlyingTicks and
tickPlayer's clientIsFloating / aboveGroundTickCount check, and
handleUseItemOn's second build-limit message for a failed placement on top
of the world.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:42:12 +05:00
levzzzandClaude Sonnet 5.5 d51a2eb268 Copper golem antenna block takes the item's block_state component (CopperGolemRenderer.extractRenderState)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:39:26 +05:00
levzzz b9f756ebad Merge pull request #4612 from levzzz5154/parity/20-render
Cat: lie on top of a sleeping player like vanilla
2026-10-31 00:36:20 +05:00
Ludovic Marie cf38f00b54 Merge pull request #3724 from aangeraakt/claude/kind-dijkstra-aaxkhw
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. Main moved and conflicted in `NetConfig`: its new `horizon_clients_only` field now sits beside `owner_profile` (faf7d52f2). Rechecked on main: `parity.py --changed` 110/110, `pr_ready --quick` pass, lan_e2e `tab`, `self` and `chest` pass. Queued again.
2026-10-30 20:36:09 +01:00
levzzzandClaude Sonnet 5.5 76ab943b40 Merge main; menus for mod containers are API 17
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:35:35 +05:00
levzzz 6f846cf6a5 Merge pull request #4611 from levzzz5154/parity/39-build-limit-shoulder
Server players: build limit overlay and shoulder parrot ambient sound
2026-10-31 00:35:18 +05:00
Łukasz KopańskiandCursor d0fbd3f638 Draw ToastManager toasts over the title screen.
Fixes #4428: the title early-return skipped advancement, tutorial and
pack toasts, so a resource-reload failure never appeared there.

Co-Authored-By: Cursor <[email protected]>
2026-10-30 20:33:44 +01:00
levzzz a14c9886ce Merge pull request #4055 from Sylviromi/native-mod-inventories
Native mods: item slots for mod block entities (hoppers, comparators, drops)
2026-10-31 00:33:32 +05:00
Ludovic Marie 07641bb0b2 Merge pull request #4607 from horizon-rust/feat/anticheat-framework
🤖 Agent: pr_ready --quick green on af5528ef2; dedicated_server, server_entities, server_player_damage pass; anticheat_e2e: a 1.5x timer bot is flagged, set back, alerted and kicked, 6 legit bots never flagged, nothing runs when [anticheat] is absent; 60-bot A/B: off = main within noise
2026-10-30 20:32:35 +01:00
Ludovic MarieandClaude Opus 5.5 bf29bd9166 Merge main: block entities took API 15, these hooks are API 16
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:30:00 +01:00
levzzzandClaude Sonnet 5.5 820913dc4c Cat: a lying cat on top of a sleeping player sits 0.15 further along (CatRenderer.setupRotations)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:27:53 +05:00
Ludovic MarieandClaude Opus 5.5 e021395210 Native mods: native_action_bar, which reaches the acting player in their hook
native_tell reaches nobody inside a player's swap (the host is out of
self.lan there): a claim's refusal is told on the action bar instead,
through the acting guest's queued packets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:26:46 +01:00
levzzzandClaude Sonnet 5.5 e311967b29 Server players: build limit overlay, shoulder parrot ambient sound
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:26:45 +05:00
levzzzandClaude Sonnet 5.5 5edf728ee3 Merge main; item slots for mod block entities are API 16; a block entity that breaks its own block drops its contents
The tick takes the block entity out of the world while the mod runs. If the
mod removed or replaced its block meanwhile, the saved items were dropped with
the taken value. They now spill like Containers.dropContents does on removal.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:23:21 +05:00
levzzz 6e8e088c25 Merge pull request #3996 from Sylviromi/native-mod-block-entities
Native mods: block entities for mod blocks (create, tick, save)
2026-10-31 00:21:02 +05:00
Ludovic Marie c27092a567 Merge remote-tracking branch 'origin/main' into HEAD 2026-10-30 20:17:29 +01:00
levzzz 17b1f8fd19 Merge pull request #4598 from levzzz5154/registry/form-links
Mod registry form: list a project by its repository, no updates through the form
2026-10-31 00:16:49 +05:00
Federico TaglianiandClaude Sonnet 5.5 6b3aacf619 Piglins open the doors on their path (InteractWithDoor)
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 20:15:46 +01:00
levzzzandClaude Sonnet 5.5 f2d9c04794 Merge main; block entity hooks are API 15 (main is at 14)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:11:56 +05:00
Ludovic Marie 19ca1c8b33 Merge pull request #3762 from spidercraft/fsr-optical-flow
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. Gate for this part (1/3), on 785796786 merged with main, Mac mini M4 Pro (Metal). **License**, checked against CONTRIBUTING's new rule on other projects' code (#4482): - FidelityFX SDK v1.1.4 is commit c6efa6bf7f20. At that commit, `LICENSE.txt` and the header of every file this port follows are AMD's MIT text, copyright line included: all ten `sdk/include/FidelityFX/gpu/opticalflow/*.h`, `sdk/src/components/opticalflow/ffx_opticalflow.cpp` and `gpu/spd/ffx_spd.h`. - `optical_flow.rs` follows `ffx_opticalflow.cpp` (its passes, their order and their dispatch sizes). So I added the same notice at its top and named that file in `THIRD_PARTY_NOTICES.md`, with a link to the SDK at the commit (785796786). Nothing else changed. #3763 and #3764 contain the old head and need this merged in. **Proof:** - `pr_ready --quick` passes. `parity.py --changed` has nothing to run: the selector maps these files only to lan_integration, which runs when named or nightly. - `shader_validates` and `finds_a_shift_and_a_scene_change` pass on Metal. **Cost per frame:** - Off: zero. `OpticalFlow` is constructed nowhere until part 3. - On: measured locally with a throwaway test (not pushed) that dispatches it on two alternating frames, 100 frames after a warm-up: | size | GPU, median (p90) | CPU encode (debug build) | |---|---|---| | 1280×720 | 2.33 ms (2.96) | 0.27 ms | | 1920×1080 | 3.30 ms (3.76) | 0.29 ms | | 2560×1440 | 4.68 ms (5.53) | 0.31 ms | | 3840×2160 | 8.48 ms (9.35) | 0.32 ms | Part 3 should keep this in mind: on an M4 Pro the flow alone costs a fifth of a 60 fps frame at 1080p, before the interpolation, so frame generation only gains where the real frame takes much longer than that. Keep it off by default, as planned. - Smaller notes, none blocking: - `dispatch` creates the `prepare_luma` bind group every frame; it could be cached per colour view. - The scene-change "last workgroup" handoff relies on relaxed WGSL atomics being visible across workgroups, which holds on current drivers. Queued for the merger (`skip-release`: players see no change).
2026-10-30 20:10:55 +01:00
levzzzandClaude Sonnet 5.5 b781de899d Form service: list a project by its repository, take no updates for it
A submission with repo (and no file) opens a pull request adding an entry that
links the public GitHub repository; its versions are the repository's releases.
An update through the form of a project that has a repo is refused with
'publish a GitHub release in <repo>'. Projects without a repo keep the file path.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 00:08:06 +05:00
levzzz af8f2fb7c2 Merge pull request #4593 from levzzz5154/registry/entry-repo-releases
Registry: an entry is a repository link and a page; versions come from its releases
2026-10-31 00:08:02 +05:00
levzzz 04687c99c6 Merge pull request #4589 from levzzz5154/registry/links-and-releases
Launcher: accept a GitHub release asset as a registry file
2026-10-31 00:06:45 +05:00
Ludovic Marie b90b7d8aac Merge pull request #3743 from UMCEKO/mods/equipment-every-namespace
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. I merged main into this branch (97f2c57e5, 6350e5219). On main, an equipment kind now holds every layer (leather's overlay is layer 1). So the namespace resolution now runs for each layer's texture, and `content_mods_equipment_loads_under_its_namespace` expects a list of layers. `hud.rs` keeps your namespaced sprite names with main's decoded image. Proof, on main with #3741 and #3742: - `parity.py --changed` 110/110; - `pr_ready --quick` pass, including both new tests. - In the game: pr-triage-3's pack test at your heads (comment on #3740) showed amethyst, copper and chorundum armour drawing their own textures. - A root without content mods: `content_namespaces::namespaces` is empty, so no extra folder is walked, and every vanilla texture still takes the `strip_prefix(textures)` branch. Same sprites as before. Queued after #3742.
2026-10-30 20:06:28 +01:00
Ludovic Marie 03331b3d52 Merge pull request #3742 from UMCEKO/mods/port-item-stats
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. I merged main into this branch through #3741's carry (no conflicts of its own). It's proven with #3741 and #3743 on main: `parity.py --changed` 110/110 and `pr_ready --quick` pass. Queued after #3741.
2026-10-30 20:06:23 +01:00
Ludovic Marie bf4e738618 Merge pull request #3741 from UMCEKO/mods/harness-item-components
🤖 Agent: pr-triage-1 (Ludovic's AI agent, Claude), not Ludovic himself. I merged main into this branch (fd41f0e36; `mod_port_probe.rs` now uses main's `PackOverlay` loaders). I also made the one change pr-triage-3 asked for in its pack test on #3740 (a502eccc9): the attribute normaliser drops 1.21.1's `player.` prefix too, so spears' `entity_interaction_range` matches the oracle. Proven together with #3742 and #3743 on main: `parity.py --changed` 110/110 and `pr_ready --quick` pass. Queued for the merger; #3742 and #3743 follow.
2026-10-30 20:04:07 +01:00
Ludovic MarieandClaude Opus 5.5 1de9f9fc69 Native mods: right click on a block, the acting player, server commands (API 15)
native_right_click_block hears a player's right click on a block before
the block's own use and the item's, and may cancel it; native_acting_player
says who acts inside a player's hook; native_run_command runs a command as
the server's console (teleports across levels); server players carry
their health. What a server mod needs for land claims and teleports.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 20:03:12 +01:00
Federico TaglianiandClaude Sonnet 5.5 2b85acfeaf Mobs re-path when a block changes near their route (PathNavigation.shouldRecomputePath)
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 20:03:07 +01:00
Muhammad AsifandOhMyAgent e74907f551 Give each server sound packet its own seed
Hub::sound used the server tick as ClientboundSoundPacket.seed, so two
sounds in the same tick picked the same client variant. Level.playSound
draws soundSeedGenerator.nextLong per call.

Closes #4576.

Co-Authored-By: OhMyAgent (grok-4.6) <[email protected]>
2026-10-31 00:00:27 +05:00
levzzzandClaude Sonnet 5.5 85e7517b76 Registry: an entry is a link and a page; its versions are the repository's releases
An entry names its GitHub repository (repo). The page fields may come from
horizon-registry.toml in that repository (the entry's own win), and the versions
the registry's CI read from the repository's releases are kept in
versions/<id>.json and listed beside the entry's own [[versions]], which become
optional. cargo horizon-mod registry release|page run a submission's checks on a
release and print the version/page for that cache. The index schema is unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:59:13 +05:00
Ludovic MarieandClaude Opus 5.5 faf7d52f20 Merge main into claude/kind-dijkstra-aaxkhw
Conflict: main's horizon_clients_only beside owner_profile in NetConfig.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:57:26 +01:00
Ludovic Marie cb2a7fb1b6 Merge pull request #4561 from horizon-rust/docs/smp-gambling
🤖 Agent: docs only (docs/smp.md), checked at 4cbfa78: the gambling rules match the orchestrator's decision of 2026-10-30 (on for the official server only, off in the mod by default; in-game currency only, never bought with real money or cashed out, no paid random items), and the coinflip lines match horizon-mods #58 as merged; pr_ready and CI green.
2026-10-30 19:56:36 +01:00
Ludovic MarieandClaude Opus 5.5 7857967863 Optical flow: AMD's notice on the Rust side too, and the SDK commit
optical_flow.rs follows ffx_opticalflow.cpp (passes, order, dispatch
sizes), so it keeps the SDK's copyright line and MIT notice like the WGSL
port; THIRD_PARTY_NOTICES names that file and links the SDK at the v1.1.4
commit, as CONTRIBUTING's rule on other projects' code asks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:56:02 +01:00
levzzzandClaude Sonnet 5.5 4fabf2025c Launcher: install registry files from a GitHub release asset
The registry's index will list the release assets of a mod's own repository
(https://github.com/<owner>/<repo>/releases/download/<tag>/<name>), so an author
ships a version without a registry pull request. The launcher accepted only the
registry's own releases; it now also accepts that exact address shape. The
index's SHA-256 still pins the bytes.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:55:22 +05:00
levzzz 3c580814c8 Merge pull request #4483 from ludovicthenot/native-block-hooks
Native mods: hook points for breaking and placing a block (API 6)
2026-10-30 23:52:42 +05:00
Ludovic MarieandClaude Opus 5.5 876d0003d5 Anticheat framework, off by default, with a timer check
A new crate, horizon-anticheat, holds server.toml's [anticheat] settings, each player's violation
levels (they decay every second) and the checks' rules, with no game state. The dedicated server's
hub runs the checks where it handles packets and carries out a flag: send the player back, alert the
console, the log file and online operators, then kick; never a ban. Each check has its own toggle and
thresholds. Without [anticheat] (the default, and the generated server.toml only has a commented
example) the hub holds no anticheat and the server does what vanilla's does.

The first check is timer: vanilla's client ends each tick with ServerboundClientTickEndPacket at the
server's tick rate, so a client further ahead of the wall clock than 300 ms is flagged, with a capped
balance for lag spikes and slow server ticks.

horizon-bots gets --timer F (a sped-up client), tools/anticheat_e2e.py runs a real dedicated server
with legit and cheating bots, and server_bench.py --anticheat measures the cost.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:49:49 +01:00
Claude 72f66bb71c Draw map markers on the cartography table's preview
MapRenderer.render in a screen draws the map's decorations over its image,
all of them since the map isn't in an item frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rx5M6NS763D2WVnb2HmcTH
2026-10-30 18:42:28 +00:00
levzzzandClaude Sonnet 5.5 f969e62a56 Merge main: block hooks are API 14
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 23:42:16 +05:00
Claude aced527885 Draw the enchanting table's book in its screen
EnchantmentScreen.tickBook/extractBook: the book opens while an offer has a
cost and turns to a new page when the enchanted item changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Rx5M6NS763D2WVnb2HmcTH
2026-10-30 18:26:59 +00:00
Claude 648531c6da Play the happy ghast's riding sound while the player rides one
LocalPlayer.startRiding starts a RidingEntitySoundInstance for a happy
ghast (entity.happy_ghast.riding, volume 5 * clampedLerp(speed, 0, 1)),
as it does the two inside sounds for a minecart; Horizon only had the
minecart's. Port the instance: it stops when the player gets off or the
ghast is silenced, and is silent under water and below 0.01 speed.

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
DUzzL c08c051a92 Document the menu-transfer check 2026-10-30 19:24:04 +01:00
Ludovic MarieandClaude Opus 5.5 3b284f173f Merge mods/port-item-stats into mods/equipment-every-namespace
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:24:00 +01:00
Ludovic MarieandClaude Opus 5.5 18a900a360 Merge mods/harness-item-components into mods/port-item-stats
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:24:00 +01:00
Ludovic MarieandClaude Opus 5.5 a502eccc92 Mod port harness: drop 1.21.1's player. attribute prefix too
1.21.2 renamed player.entity_interaction_range, player.block_interaction_range
and the other player.* attributes along with generic.*, so a spear's
interaction range never matched the oracle's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:24:00 +01:00
Federico TaglianiandClaude Sonnet 5.5 8dff50318a Port map: the frog and goat long jump rows point at frog.rs (ported in #3946)
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:22:06 +01:00
Federico TaglianiandClaude Sonnet 5.5 c8c02381b5 Baby armadillos follow the nearest visible adult (AdultSensor, BabyFollowAdult 5..16)
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:18:27 +01:00
CocofireHDandHermes Agent c1a5052c43 A pillager shows the arrow in its loaded crossbow
The crossbow model picks its charged form from the stack's
`charged_projectiles` component. Nothing set it for a mob that loads its own
crossbow, and the mob renderer and the equipment packet kept only the item
name, so a mob's components never reached the model at all. Set and clear the
component with the pillager's charge state, draw the stack the mob actually
holds, and keep the stack the equipment packet sends.

Fixes #4521.

Co-Authored-By: Hermes Agent (deepseek-v4.1-flash) <[email protected]>
2026-10-30 19:13:35 +01:00
Muhammad AsifandOhMyAgent fd855f8815 Bounce dropped items on slime, including piston-moved slime
ItemEntity.tick zeroed Y on any vertical collision, so items never
got slime restitution. A moving piston carrying slime has no
bounceRestitution of its own. Landing now bounces at 0.8 (non-living
Entity.restituteMovementAfterCollisions), and a piston slime launch
clears on_ground so ItemEntity.tick does not skip movement.

Vanilla: ItemEntity.tick, Entity.move, PistonMovingBlockEntity.causeBounce.

Co-Authored-By: OhMyAgent (grok-4.6) <[email protected]>
2026-10-30 23:11:12 +05:00
AurelienBelloirandClaude Opus 5.5 c1db3bad48 Leave ExactTeleport out of an end gateway's update tag when unset
TheEndGatewayBlockEntity.saveAdditional writes Age and exit_portal, then
puts ExactTeleport (as true) only when exactTeleport is set. getUpdateTag
is saveCustomOnly, so vanilla's chunk packets and BlockEntityData never
carry ExactTeleport = false. entity_tag wrote the key unconditionally;
it now only writes it when the flag is set, as save.rs already does.
The test checks both cases round-trip.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:10:37 +01:00
Ludovic MarieandClaude Opus 5.5 4cbfa78bee docs/smp.md: the gambling rules, on for the official server only
In-game currency only, never bought with real money, never cashed out, no
paid random items; off in the mod by default, on for the official server.
How a coinflip's stakes are held, as the mod does it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:08:30 +01:00
Federico TaglianiandClaude Sonnet 5.5 a9b932e4c2 Frogs breed: slimeball feeding, pregnancy, and frogspawn laid on water
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 19:07:17 +01:00
AurelienBelloirandClaude Opus 5.5 0c9ac0cfae Send beacon, end gateway and creaking heart update tags
BeaconBlockEntity, TheEndGatewayBlockEntity and CreakingHeartBlockEntity
override getUpdatePacket (ClientboundBlockEntityDataPacket.create) with
getUpdateTag = saveCustomOnly, so vanilla puts their data in the chunk
packet (BlockEntityInfo.create) and in the BlockEntityData that
ChunkHolder.broadcastBlockEntityIfNeeded sends for a block the level
reports changed (sendBlockUpdated, /data merge block). The LAN host's
entity_tag had no tag for them, so guests got none: a gateway's Age (its
spawning beam) and a beacon's effects never reached them.

entity_tag now gives the three their saveAdditional fields: primary and
secondary effect and Levels; Age, exit_portal and ExactTeleport; the
creaking UUID. The rest of the row from #3520 was already in place: the
host's block log is its sendBlockUpdated, and sign edits, sign dyes, ink
and wax, shelves, campfires, vaults, spawners, trial spawners, decorated
pots and /data already push to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:05:45 +01:00
Ludovic MarieandClaude Opus 5.5 e51783d5bd Launcher: Sign in with Microsoft and loader logos (redesign R8c)
Every button that signs in is now Microsoft's "Sign in with Microsoft",
drawn to Microsoft's published branding (41 pixels, the four-square
symbol from its official SVG, dark #2F2F2F / light white with a #8C8C8C
border, semibold 15 pixels): the Overview's account row, preparing
without the game, the sign-in sheet and Settings. The New instance
dialog's loader cards show Fabric's and Quilt's logos.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:01:11 +01:00
AurelienBelloirandClaude Opus 5.5 9b47769757 Send a team leave before the join that moves a player
ServerScoreboard.addPlayerToTeam sends the old team's leave
(createPlayerPacket REMOVE) before the new team's join, and a client's
Scoreboard.removePlayerFromTeam throws IllegalStateException when the
player already sits in another team. The LAN host's scoreboard diff sent
teams in name order with joins before leaves, so moving a player to a
team sorting earlier (red to blue) made a vanilla guest drop the packet
with an error and Horizon's remote client take the player out of the
new team. All leaves now go before any add or join.

The remote client's handleSetPlayerTeamPacket leave now only takes a
player out of the named team, as removePlayerFromTeam(name, team) does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:55:55 +01:00
Ludovic MarieandClaude Opus 5.5 b6e7d3e7c0 Launcher: instance icons, pictures and loader marks (redesign R8b)
An instance's icon, in the sidebar, Home's hero and its dialog, is now its
own picture first: one the player chose (Choose image…, scaled to 128
pixels and kept as icon.png in its game folder) or its modpack's icon,
written when the Mods page installs a pack. Else the block or colour
chosen in the dialog; else its mark: the loader's logo (Quilt's and
NeoForge's shipped under CC0 and CC BY 4.0, Fabric's and Forge's fetched
from their own sites), the grass block for plain Minecraft, Horizon's for
Horizon. The letter tile is the last resort.

The dialog's icon tiles sit in rows of eleven: a wrapping row was
measured as one line and the plate ended above the buttons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:46:24 +01:00
Ludovic MarieandClaude Opus 5.5 6350e5219d Equipment test: a kind holds every layer, as on main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:35:44 +01:00
Ludovic MarieandClaude Opus 5.5 97f2c57e5d Merge main into mods/equipment-every-namespace
Through mods/port-item-stats with main merged. Conflicts: main's
equipment layers became every layer of a kind (leather's overlay), so each
layer's texture now resolves its namespace there; hud.rs keeps the
namespaced sprite names with main's decoded image.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:35:24 +01:00
AurelienBelloirandClaude Opus 5.5 4b32fb8cf7 Name the world preset test's folders without a colon
each_world_preset_writes_its_own_generator joined the preset id
(`minecraft:normal`) to its temporary folder, and Windows refuses `:` in
a file name (os error 267), so the test failed there on every run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:34:48 +01:00
Ludovic MarieandClaude Opus 5.5 5d7c622895 Merge main into mods/port-item-stats
Through mods/harness-item-components with main merged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:34:42 +01:00
Ludovic MarieandClaude Opus 5.5 fd41f0e36f Merge main into mods/harness-item-components
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:34:29 +01:00
Ludovic MarieandClaude Opus 5.5 9845e2cd9b Fit SyncData::empty to main's SyncData
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:31:24 +01:00
Ludovic MarieandClaude Opus 5.5 b01025353e Merge main into claude/kind-dijkstra-aaxkhw
Conflicts: main's integrated_open (owner_profile joins its NetConfig), the
level shapes beside owner_profile in NetConfig, SyncData::load's pack
overlay, and main's lazy skin drawing in update_remote_skins (the offline
skin is stored when its slot is gone, as the offline cape is). Also brings
back conn.rs's prompts_become_the_components_vanilla_sends test, which the
branch had replaced by mistake.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:30:29 +01:00
Dmytro ChuprynaandClaude Opus 5.5 907b6e1dcc Use workspace base64 and sha1 crates in skins.rs
Replace hand-rolled base64 decoding and SHA-1 hashing in crates/horizon-client-render/src/skins.rs with the base64 and sha1 crates already used across the workspace, preserving the existing decoding fallbacks and compatibility tests.

Fixes #4431

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:21:05 +01:00
Ludovic MarieandClaude Opus 5.5 8fcfb3b8f4 Run parity with the run-parity label (the check this PR fixes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:18:42 +01:00
Ludovic MarieandClaude Opus 5.5 2e97b2f5f9 horizon-prepare: OUTPUT_VERSION 3 (the creative tabs exporter's Operator Utilities tab)
#4052 changed VanillaCreativeTabsExporter, which the preparer runs, so the
prepared output changed (digest 5e50c240...) while OUTPUT_VERSION stayed 2.
Every parity run on the Mac mini's runner since fails "the prepared output
changed", and players would keep resources prepared before it. The digest is
the one the runner's own preparation of main gave, twice.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:15:32 +01:00
Federico TaglianiandClaude Sonnet 5.5 8373160c35 mob_ai: baby villagers play tag, checked tick for tick against vanilla
Refs #3521

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 18:12:27 +01:00
DUzzL ddbaa5a64c Cover the menu reset on a server transfer with a lan_e2e check 2026-10-30 18:09:40 +01:00
DUzzLandOpenAI Codex GPT-6 01e9ac5702 Reset the client menu when the server replaces the player
Co-Authored-By: OpenAI Codex GPT-6 <[email protected]>
2026-10-30 18:09:40 +01:00
Ludovic MarieandClaude Opus 5.5 f429f93420 Launcher: mod icons and a revalidating image cache (redesign R8a)
Every mod row shows the mod's own icon: Modrinth's for a mod the browser
installed (one bulk request per instance, the id-to-URL map kept in
mod-browser/project-icons.json), else the icon the jar names in its
fabric.mod.json, quilt.mod.json or mods.toml, else a glyph. Icons are
decoded off the UI thread and scaled to the row's size.

The image cache moves under the launcher's data folder, keeps each
image's ETag/Last-Modified and revalidates week-old images with a
conditional request. The Mods tab builds only its visible rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:08:26 +01:00
Federico TaglianiandClaude Sonnet 5.5 c96733f3c6 Copper golems open the doors on their path (InteractWithDoor)
CopperGolemAi's core activity has InteractWithDoor at priority 3: it opens
the doors on the path and closes the ones passed. The brain had none, so a
golem whose way between two chests ran through a closed door never got
through. The mob_ai probe now plays the sound of a door a mob opens or
closes, with the pitch drawn from the level random.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 18:06:05 +01:00
DUzzL 5459f6e232 Invisible players keep their armour, elytra and held items 2026-10-30 18:05:37 +01:00
arismoko a02d0c4a1a Route server block changes through level updates 2026-10-30 11:43:09 -05:00
Federico TaglianiandClaude Sonnet 5.5 46dfd693f5 Iron golems draw the sprint particle's randoms; mob_ai village scenarios
IronGolem.canSpawnSprintParticle: a golem that moves draws nextInt(5) each
tick and, one time in five, the particle's two coordinates, even though a
server drops the particle. Horizon drew none, so a golem's random drifted
from vanilla's. iron_golem_village_0/1 and iron_golem_outside_village
(GolemRandomStrollInVillageGoal, MoveBackToVillageGoal) now match vanilla.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 17:40:44 +01:00
Ludovic MarieandClaude Opus 5.5 5a7cfb443f lan_e2e: check that a sprinting guest's hit slows it
The guest sprints at the host, releases Ctrl (the sprint holds while W
stays down), and left-clicks the host. Vanilla's attacker side of
causeExtraKnockback cuts its horizontal speed to 0.6 and ends the sprint:
the speed drops below the walking speed and climbs back to it. On main
the guest keeps its sprint speed (0.1532) after the hit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:37:36 +01:00
Federico TaglianiandClaude Sonnet 5.5 b947048ad6 Fish schools recruit in vanilla's entity order; mob_ai school scenarios
The row said FollowFlockLeaderGoal was missing: it is ported (#3026) but had
no parity scenario. cod_school, salmon_school and tropical_fish_school
(five fish each, 400 ticks) found two differences: a leader recruits the
fish in vanilla's getEntitiesOfClass order (sections by x, z, y, then
entry order: Mob.section), which decides who is left out of a school of
at most five; and the oracle and probe draw the goal's first start delay
again once the mob's random is seeded. All three scenarios now match.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 17:27:36 +01:00
Federico TaglianiandClaude Sonnet 5.5 511b974107 Dolphins swim after the boat or nautilus a player steers (FollowPlayerRiddenEntityGoal)
Dolphin.registerGoals adds FollowPlayerRiddenEntityGoal for AbstractBoat and
AbstractNautilus at priority 8. The player the mob sees tells the vehicle it
steers (PlayerView.vehicle). mob_ai scenarios dolphin_boat_* (the oracle
gets a boat the script moves) match vanilla tick for tick; Mob.moveRelative
adds its zero vector like vanilla (-0.0 becomes 0.0).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 17:13:26 +01:00
Neferr fbac0ae2d9 Native mods: hook points for breaking and placing a block (API 6)
native_destroy_block hears a player break a block once the game allowed it (ServerPlayerGameMode.destroyBlock) and may cancel it, which keeps the block and its drops; native_place_block hears a block placed from an item (BlockItem.place). A mod stops a placement by claiming native_use_item_on, which runs before it. Fixes #4474.
2026-10-30 16:43:43 +01:00
ElyTheCat 548fca6308 Merge remote-tracking branch 'origin/main' into fix/armor-rendering
# Conflicts:
#	crates/horizon-client/src/game/armor_stands.rs
#	crates/horizon-client/src/game/entity_model_loading_tests.rs
#	crates/horizon-client/src/game/perspective.rs
2026-10-30 18:55:54 +03:30
ElyTheCat 60203586ca Fix syntax error and Opt::available in options_ui.rs after merge 2026-10-30 18:43:15 +03:30
bloodulon 17aab6b601 Merge branch 'pr/interaction-entities' into pr/display-passengers
# Conflicts:
#	crates/horizon-client/src/game/remote.rs
2026-10-30 20:11:03 +05:00
bloodulon 8aff0594b5 rustfmt fix in remote.rs 2026-10-30 20:10:06 +05:00
bloodulon aa284acb23 Merge remote-tracking branch 'origin/main' into pr/display-passengers
# Conflicts:
#	crates/horizon-client/src/game/remote_vehicles.rs
2026-10-30 20:02:42 +05:00
bloodulon 1167274823 Merge remote-tracking branch 'origin/main' into pr/interaction-entities 2026-10-30 20:00:26 +05:00
ElyTheCat 7ba8d7ee65 Merge branch 'main' into feat/quit-shortcuts 2026-10-30 18:27:01 +03:30
DenisHumenandClaude Opus 5.5 6a066d9076 Save and restore the player's attribute bases (#4425)
LivingEntity.addAdditionalSaveData writes AttributeMap.pack as the
player's `attributes` list and readAdditionalSaveData applies it before
the health. Horizon kept the list it loaded and never wrote the live map,
so /attribute base changes (scale, max_health, ...) were lost on reopen.

The host writes the live attributes in sync_level and applies the saved
list when its first body loads; a LAN guest does the same through
playerdata::store and its first body after joining.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:51:25 +02:00
Михаил РоговцевandOpenAI Codex 6b5428915b Keep the swap-scale PR narrowly scoped
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-30 17:20:10 +03:00
ElyTheCatandAntigravity 59a1f51d34 Controls: implement Quit Shortcuts toggle and window-close blocking
In vanilla Minecraft 26.3, the Quit Shortcuts option (options.quitShortcuts,
default true) controls whether keyboard shortcuts (Alt+F4 on Windows/Linux,
Cmd+Q on macOS) can close the game window. When disabled, close requests
initiated via quit shortcuts are ignored to prevent accidental quits, while
window close buttons continue to work.

Horizon already loaded and serialized quitShortcuts in options.txt and
had the cycle button wired in options_ui, but the setting was excluded from
Opt::available and the close handler never checked InputQuirks.isQuitShortcutDown.

- Add is_quit_shortcut_down in horizon-client-input::input checking
  platform modifiers and physical keys (Command+Q on macOS, Alt+F4 elsewhere).
- Check is_quit_shortcut_down on WindowEvent::CloseRequested in game.rs
  and startup_menu.rs when quit_shortcuts is disabled.
- Enable Opt::QuitShortcuts in Opt::available so the toggle is clickable in
  Controls with its vanilla tooltip.
- Add unit tests in horizon-client-input, horizon-client-render, and horizon-client.

Fixes #4336.
Part of #3518.

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 15:29:45 +03:30
MSedgeMC 6a03966205 Encode cooking recipes into the recipe book
AbstractCookingRecipe.display() gives smelting, blasting, smoking and
campfire one and the same FurnaceRecipeDisplay: the ingredient, any fuel,
the result, the station, the cooking time and the experience. Only the
station and the book category differ, and the category comes from the
recipe, not from the client.

book_entries encoded shaped and shapeless crafting only and returned an
empty list for everything else, so the furnace, blast furnace and smoker
books were empty for every player whose window is a client: single player
on its server thread, LAN guests and dedicated players alike.

One shared arm now writes that display, and the category switches follow
the jar: smelting food/blocks/other, blasting blocks and one misc tab,
smoking, and campfire (which no book tab lists, as in vanilla).
2026-10-30 16:26:42 +05:30
SylviromiandClaude Opus 5.5 d3d714e9eb Native mods: native_data, a data file of the resources, content mods' included (API 9)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 13:45:33 +03:00
unknownandClaude b3f866c5a6 Combat: the hurt player hears the own hurt sound again
The server thread plays every guest's MotionEvent::HurtSound into its
own silent Audio, so single player and multiplayer heard nothing (#4149);
the client's ClientboundDamageEventPacket handler never played it either,
unlike vanilla's LivingEntity.handleDamageEvent.

Vanilla splits the sound in two: the hurt player's own client plays it
from handleDamageEvent (Player.getHurtSound: the damage type's effects,
volume 1.0, a pitch that walks 1.0 ± 0.2 on the level random), while the
server's Entity.playSound reaches every other player around but that one.

The DamageEvent handler now resolves the packet's damage type through the
synchronized registry and plays hurt_sound(effects) at the player, and
tick_guests sends the server's sound to the hub for everyone but the hurt
player (Some(conn), the same split the death sound and the honey slide
already use).

Fixes #4149

Co-Authored-By: Claude <[email protected]>
2026-10-30 13:42:57 +03:00
simonandClaude Opus 5.5 61066655fe Mirror the attacking client's Player.attack, not the server's, and add its harness
The no-damage guard and the knockback check read the held item, effects and
enchantments. On a client none of them count: items and effects only change
attributes on a server, ATTACK_DAMAGE and ATTACK_KNOCKBACK aren't synced, only
ServerPlayer overrides getEnchantedDamage, and getKnockback only adds
enchantments on a ServerLevel. So an empty-hand sprint hit under Weakness does
slow a vanilla client, and a Knockback sword alone doesn't.

test_remote_attack_parity runs the real Player.attack on a LocalPlayer shell
against a RemotePlayer and a zombie and compares the attack strength, velocity
and sprint flag: 51,840 cases identical on 26.3.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:55:21 +10:00
SylviromiandClaude Opus 5.5 3df5f5d5c4 Native mods: native_block, the block at a position (API 9)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 12:34:16 +03:00
MSedgeMC c0f16e65e2 Hang nametags from the pose box, not the sneak flag
Vanilla anchors a name tag at the entity's NAME_TAG attachment,
(0, bbHeight, 0) of the box of the current pose, and the submitter lifts it
0.5 more. The sneak flag only decides isDiscrete (the see-through pass).

Horizon took the height from the flag for players, so a player swimming or
gliding with shift held (pose box 0.6, shared crouch bit set) got a tag at
2.0 instead of 1.1, and riding with the flag gave 2.0 against vanilla's
2.3. The height now comes from m.height(), which reads the pose box.

That branch was only needed because the local player's model never filled
extra.avatar_pose (guests and server players get it from DATA_POSE), so
tick_player_model now stores the pose it already computes. Crouching and
standing heights are unchanged at 2.0 and 2.3.
2026-10-30 14:52:26 +05:30
MSedgeMC 57eac62cd2 Lift the thrown eye of ender by the item ground transform
Vanilla draws eye of ender with ThrownItemRenderer(context, 1.0F, true),
which only scales and billboards; the visible lift comes from the item
model. item/ender_eye parents item/generated with no display override, so
its ground transform's translation [0, 2, 0] applies: 2/16 of a block,
outside the 0.5 scale, along the camera's up axis.

Horizon's hand-rolled billboard centred on the entity, so the eye sat 0.125
blocks low. The quad maths moves into eye_quad with a named GROUND_LIFT and
tests that pin both the offset and its axis (a tilted camera must move the
quad along its own up, not world Y).
2026-10-30 14:47:34 +05:30
ElyTheCatandAntigravity 18f79e7683 Merge fork/fix/armor-rendering into fix/armor-rendering
Co-Authored-By: Antigravity <[email protected]>
2026-10-30 12:32:50 +03:30
SylviromiandClaude Opus 5.5 0c9468753d Native mods: buttons, tooltips and item icons on mod screens
native_screen_click turns a press on a mod screen into a menu button id,
sent as ContainerButtonClick; native_menu_button runs it on the server for
the mod block entity behind the menu. native_screen_tooltip shows lines
under the mouse. NativeScreen gets its width, the inventory's left column
and own_labels, so a mod can lay out a window that isn't vanilla's. Helpers:
native_item (an item's icon, any size), native_cursor, native_screen_title,
native_block_property, native_insert_item (into a container, as a hopper).
API 9.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 11:26:11 +03:00
ElyTheCatandAntigravity 0887fd0c69 Merge branch 'origin/main' into fix/armor-rendering
Drop CustomHeadLayer changes now merged in main (#3605), keeping
multi-layer equipment parsing and rendering for leather overlay.

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 11:52:05 +03:30
DUzzL ca04a6de62 Draw creative tab and trash tooltips with the item tooltip box 2026-10-30 09:12:31 +01:00
DUzzL dab1861c21 Let a candle be placed on an uneaten cake (CakeBlock.useItemOn)
Fixes #4266
2026-10-30 09:06:38 +01:00
DenisHumenandClaude Opus 5.5 c7cfd91f14 Keep the mouse wheel off the hotbar while a book or lectern is open
MouseHandler.onScroll gives the wheel to the open screen and turns the
hotbar only when there is none. Books, lecterns, signs, server dialogs
and the link and pack prompts are drawn over gameplay while the screen
stays Playing, so the wheel still changed the selected slot under them.

Fixes #4253

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:58:55 +02:00
DUzzL 8e10fc9924 Lectern: close the book screen after Take Book 2026-10-30 08:50:52 +01:00
DUzzL bf103fc383 Keep GUI blend ranges aligned when clip_since rewrites vertices 2026-10-30 08:50:52 +01:00
DUzzL f69bf3deb5 Clip GUI item triangles at the scissor instead of dropping them by centre
Advancement icons at the top and bottom of the tree lost one of their two
triangles when the scissor cut through them. HudBatch::clip_since now clips
non-rectangular geometry as polygons, interpolating UV, glint UV and depth.

Refs #4248
2026-10-30 08:42:28 +01:00
DenisHumenandClaude Opus 5.5 393d90d870 Return to Sender: a fireball struck back credits the player's kill
The large and small fireball hits on a mob didn't record the player who
owned the fireball (LivingEntity.hurtServer's setLastHurtByPlayer), as an
arrow's hit does. On the default single player the host plays as a guest
on its server thread, so a ghast killed by its own fireball, turned back,
was credited to nobody: no Return to Sender, Monster Hunter or kill
statistic.

tools/test_return_to_sender_parity.py strikes a still fireball into a
ghast with no AI on both single-player paths and expects the advancement.

Fixes #4212

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:34:07 +02:00
SylviromiandClaude Opus 5.5 7255ecd851 Native mods: mod screens are API 8
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:27:06 +03:00
Sylviromi a1e033003a Merge branch 'native-mod-neighbours' into native-mod-screens 2026-10-30 10:27:06 +03:00
SylviromiandClaude Opus 5.5 a3aa60d7bc Native mods: neighbour helpers are API 7
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:27:05 +03:00
SylviromiandClaude Opus 5.5 0e573b8e25 Merge native-mod-inventories; menus are API 6
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:26:57 +03:00
Sylviromi b4181cf43a Merge branch 'native-mod-menus' into native-mod-neighbours 2026-10-30 10:26:57 +03:00
SylviromiandClaude Opus 5.5 14d7f036c3 Native mods: item slots are API 5
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:26:49 +03:00
SylviromiandClaude Opus 5.5 c89bf6241d Native mods: a mod screen takes data only for its own window
ContainerSetData for another window (an old or replaced one) no longer
reaches the open mod screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:26:36 +03:00
DenisHumenandClaude Opus 5.5 0271c62825 Falling blocks keep their random model offset (pointed dripstone)
FallingBlockRenderer draws the block through
ModelBlockRenderer.tesselateBlock, which moves it by
BlockState.getOffset(BlockPos.containing(x, maxY, z)). Horizon drew
falling blocks centred, so a pointed dripstone (OffsetType.XZ) jumped
sideways when it broke off and fell.

Fixes #4102

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 09:25:16 +02:00
Sylviromi 0bebf89220 Merge branch 'native-mod-neighbours' into native-mod-screens 2026-10-30 10:24:48 +03:00
SylviromiandClaude Opus 5.5 3c2a49c4ef Native mods: neighbour changes notify after the change; properties update neighbours
native_with_mod_block_entity runs the mod's change first, then marks the
chunk changed and, when items moved, lets comparators read the container
again (native_mod_block_entity_at notified before). native_set_block_property
goes through Level.setBlock with UPDATE_ALL, so neighbours and observers hear
of it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:24:44 +03:00
Sylviromi 1eeda33ae5 Merge branch 'native-mod-menus' into native-mod-neighbours 2026-10-30 10:21:47 +03:00
SylviromiandClaude Opus 5.5 aba4ee3d56 Native mods: a mod menu shows only its container's slots
A menu reads, writes and sends only the declared slot count; items saved
past it (a container that shrank) stay saved and untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:21:46 +03:00
DUzzL 803bff885b lan_e2e: level_respawn_points leaves no Overworld spawnpoint (#4234) 2026-10-30 08:20:27 +01:00
Sylviromi 51b39ee118 Merge branch 'native-mod-inventories' into native-mod-menus
# Conflicts:
#	crates/horizon-client-render/src/chunk/block_entity.rs
#	crates/horizon-client-render/src/containers.rs
#	crates/horizon-client/src/game/native_hooks.rs
2026-10-30 10:19:41 +03:00
SylviromiandClaude Opus 5.5 4459fe3989 Native mods: a mod's own Items stays its own
Items become slots only when they are an item list saved exactly so; any
other Items stays in the mod's compound. A block without a container gets
its Items back in its compound before its tick, and breaking it drops
nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:19:08 +03:00
Sylviromi 027aef4b3b Merge branch 'native-mod-block-entities' into native-mod-inventories
# Conflicts:
#	crates/horizon-client-render/src/containers.rs
#	crates/horizon-client/src/game/native_hooks.rs
2026-10-30 10:15:56 +03:00
SylviromiandClaude Opus 5.5 2de673c4f8 Merge main; block entity hooks are API 4 (#3706 took 3)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 10:15:13 +03:00
bloodulonandQoder 912a9febc7 Seat displays and hitboxes on whatever carries them
ModelEngine mounts a model's item displays (its bones) and interaction
hitboxes (its click boxes) on a pivot, usually an area effect cloud, and
moves only the pivot. #3776 seats text displays on mobs and the local
player through the passenger attachment; an item display riding a pivot
display or an area effect cloud, and a hitbox riding a pivot, had no
seat at all and stayed where the server first spawned them.

Every display and interaction box now asks its vehicle where to sit:
mobs through their passenger attachment, boats and minecarts from their
seat, armor stands from their box, another display or interaction from
its position, and any other tracked entity (the area effect cloud) from
its feet. Riders of riders settle over three passes, as mobs already did.

Co-Authored-By: Qoder <[email protected]>
2026-10-30 12:02:24 +05:00
bloodulonandQoder 558a9a13e9 Draw the click boxes of server interaction entities
Servers (Denizen, Nexo, ModelEngine) spawn minecraft:interaction entities:
invisible boxes with a width, height and response flag that a script
answers. The client knew nothing of them, so clicks aimed at the box hit
the block behind or nothing, and the map's NPC models could not be
clicked.

A spawned interaction becomes a RemoteInteraction (SetEntityData ids 8,
9 and 10), pickable for use and attack from its feet up its height, and
its teleports replace its position at once (Entity's no-op
InterpolationHandler). The click ends at the box: SUCCESS when it
responds, and the arm swings; CONSUME otherwise. Either way the Interact
packet carries the hand, nothing behind the box is used, and pick-empty
leaves no entity to pick.

Co-Authored-By: Qoder <[email protected]>
2026-10-30 11:55:42 +05:00
DenisHumenandClaude Opus 5.5 6f42cbb13f Lower the shield when a server stops the player's use
On a server, the client ignored DATA_LIVING_ENTITY_FLAGS for its own
player. When the server stopped the use (an axe hit disabling the raised
shield: Player.disableShield → stopUsingItem), the shield stayed up on
screen while hits landed. LocalPlayer.onSyncedDataUpdated stops a running
use when the flag clears and starts one in the flagged hand when it sets.

Fixes #4115

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:35:32 +02:00
GIGABAIT93andClaude Opus 5.5 6ffe36494e port_publish: read gh's and Kira's output as UTF-8
gh() and kira() ran subprocess with text=True and no encoding, so on
Windows Python decoded their UTF-8 output with the code page: an issue
body's → came back as →, and merged() wrote it back on the next update.
Follows d3grando's review on #3587.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 08:24:38 +02:00
Kacpherek c1c7cdb3c1 Clear a passenger's ground state every tick
Player.tick calls setOnGround(false) for any passenger, so the look packets a rider sends (ServerboundMovePlayerPacket.Rot) never carry onGround. Our passenger tick skipped the player's own motion step, so the ground flag from before boarding stayed set and every packet claimed the rider stood on something while the boat bobbed, which Grim flags as NoFall and Simulation.

Fixes #3793
2026-10-30 07:00:49 +01:00
Kacpherek 8880df58c1 End a glide when a player boards a boat
A player who boarded a boat while gliding kept the FallFlying pose: passenger_pose_tick cleared swimming but not fall_flying, so update_player_pose still picked FallFlying and fall_fly_ticks kept counting. Vanilla LivingEntity.updateFallFlying (in aiStep, run by rideTick's tick) clears the glide flag once canGlide fails for a passenger (canGlide needs !isPassenger()), and LivingEntity.tick resets fallFlyTicks when not gliding (LivingEntity.java).

Fixes #3093
2026-10-30 06:39:28 +01:00
DUzzL 6b0d88bf88 Send a cleared end crystal beam to LAN guests (#4190)
A crystal whose beam target went back to none was left out of the entity state with no listed default, so guests kept the respawn beam.
2026-10-30 06:32:09 +01:00
Kacpherek f83be65d79 Send creative inventory edits with the click, not at tick end
A creative click on a server changed the local slot but sent it at the end of the tick, while a thrown stack went out at once. A slot update the server sent in between (the pickup of an item a held Q had thrown) replaced the unsent change and was marked as synced, so the throw was never taken off the stack and the count climbed. CreativeModeInventoryScreen.slotClicked ends in broadcastChanges (CreativeInventoryListener.slotChanged -> handleCreativeModeItemAdd), so the changed slots follow the click immediately; Horizon now sends them right behind it.

Fixes #3090
2026-10-30 06:30:25 +01:00
DUzzL effec4887d Time out stalled server resource pack downloads
The download queue's HTTP agent had no connect or read timeout, so a server
that accepted the connection and went silent blocked the single queue thread
and the pack screen forever. Fail the request after 30s instead.

Fixes #4168
2026-10-30 04:20:36 +01:00
DUzzL 73632dae7e lan_e2e: use vanilla ExactTeleport key for the End gateway in guest_end
Fixes #4145
2026-10-30 02:59:29 +01:00
DUzzL 8ec6e3d9f4 Update port ledger note for slot_at hover margin 2026-10-30 02:53:03 +01:00
DUzzL a97afdc93f Hover the nearest slot when the pointer is in the gap between slots
Vanilla's AbstractContainerScreen.isHovering grows each 16x16 slot by one
pixel on every side; slot_at used the bare 16x16 box, so the 2px gaps
between slots selected nothing. Fixes #4134.
2026-10-30 02:46:03 +01:00
DUzzL 557c4c5cf9 Start remote end crystal spin from a random phase
A crystal spawned from an add-entity packet kept time 0, so every crystal
placed in multiplayer or LAN looked identically rotated. Vanilla's
EndCrystal constructor draws time from nextInt(100000).
2026-10-30 02:36:05 +01:00
DUzzL 29363ee3e9 Apply the held item's main-hand modifiers to the player's attributes
/attribute reported the base attack damage, attack speed and reach with a
weapon in hand because apply_equipment skipped the main-hand slot. Apply
them and refresh when the held item changes. Fixes #4118.
2026-10-30 02:19:33 +01:00
Benflex 7cb03beeed Play the merchant's yes sound on a shift-clicked trade
MerchantMenu.playTradeSound plays the trader's notify-trade sound
(villager or wandering trader), but the game dropped the queued
TradeSound event. Play it at the trade's origin with volume and pitch 1.

Part of #3515.
2026-10-30 02:17:10 +01:00
Sylviromi 7aaa68865f Merge branch 'native-mod-neighbours' into native-mod-screens 2026-10-30 03:38:55 +03:00
SylviromiandClaude Opus 5.5 723a2a44cb Native mods: set a block's property from a tick (a machine's lit)
native_set_block_property (a helper, API 3): as a furnace's tick sets
its lit, so a machine shows when it works.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:38:55 +03:00
DUzzL d2e26c32d4 Players with a missing skin sprite draw the default skin, not nothing
A remote player or mannequin whose skin location had no atlas sprite
(a stored slot without its upload, an evicted skin) drew no body. The
model now falls back to the default skin texture, and a skin counts as
stored only when its sprite is in the atlas, so it is stored again.

Refs #4096
2026-10-30 01:30:27 +01:00
SylviromiandClaude Opus 5.5 7df9ed0545 Native mods: screens for mod blocks, drawn with the mod's GUI textures
A content mod's textures/gui files are GUI sprites under its namespace
(mymod:gui/...). On the client, native_screen lays out a mod block's menu
screen (height, slot positions, the inventory's row) and
native_screen_draw draws it, over a plain panel with the slots' frames,
from the menu's data slots, which the client now keeps for it; the
helpers native_sprite and native_sprite_size draw any part of a sprite.
The server's menu is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:25:06 +03:00
DUzzL 2c803ae875 fix(client): send lingering potion cloud colour to clients
The area_effect_cloud entity states are built while the LAN hub is out of
`self.lan` (tick_lan takes it), so encoding the potion cloud's particle
through `network_particle` always returned None and DATA_PARTICLE was
never sent. Clients then fell back to AreaEffectCloud.DEFAULT_PARTICLE, the
white entity effect, for every potion. Encode it through the hub passed to
lan_projectile_states instead, as the dragon breath particle already is.

Closes #4090
2026-10-30 01:15:30 +01:00
DUzzL baa05a30ec Send the stored Main Hand option in ClientInformation
The Skin Customization Main Hand setting was saved to options.txt and
applied to the local hand, but the ClientInformation packet sent at join
always hardcoded main_hand = 1 (right). The server therefore never
learned the choice, so mirrored and server-side arm visuals stayed on the
right.

Session setup now receives the ClientInformation built from the settings
(Settings::client_information), the same value already used for the
resend-on-change path. Adds round-trip and mapping tests (LEFT = 0,
RIGHT = 1).
2026-10-30 01:10:05 +01:00
arismoko 94e167b796 Keep stopped server list test offline 2026-10-29 19:09:58 -05:00
SylviromiandClaude Opus 5.5 dc925bf620 Native mods: reach a neighbouring mod block entity from a tick
native_mod_block_entity_at (a helper, API 3): the mod block entity at a
position, to read or change from another's tick, as a generator charging
the machine beside it. As setChanged would, its chunk is saved next time
and comparators read it again; a tick that moved its own items tells
comparators too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:50:39 +03:00
Sylviromi b916a49fbf Merge branch 'native-mod-inventories' into native-mod-menus 2026-10-30 02:47:20 +03:00
Sylviromi 99791ea225 Merge branch 'native-mod-block-entities' into native-mod-inventories 2026-10-30 02:47:20 +03:00
SylviromiandClaude Opus 5.5 807901034b Native mods: a mod block entity's tick marks its chunk changed
Review follow-up: as a ticking block entity's setChanged does, so a save
after an autosave still writes what the tick changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:47:12 +03:00
SylviromiandClaude Opus 5.5 3cb4177026 Native mods: furnace menus for mod containers, with their data slots
A mod container may open a furnace's menu (furnace, blast furnace,
smoker): its block entity's data slots, which the mod sets in its tick and
nothing saves, draw the flame and the arrow as a furnace's dataAccess
does. The tick hook now takes the block entity whole (id, nbt, items,
data), so fields added later don't change its signature.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:46:50 +03:00
Federico TaglianiandClaude Sonnet 5.5 e4b5b0f07f Inventory preview: the player is lit as Lighting.Entry.ENTITY_IN_UI
The shading was a rough guess (front 1.0, top 0.4); it is now
minecraft_mix_light with the two inventory lights, ambient 0.4 and power 0.6.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 00:45:21 +01:00
Federico Tagliani df19d46a5a Merge remote-tracking branch 'origin/main' into fix/ctrl-click-right-click 2026-10-30 00:27:40 +01:00
Federico TaglianiandClaude Sonnet 5.5 d9d0bd3289 Right Click Emulation: Control + left click is a right click on macOS
The option was in Options > Controls and saved, but nothing read it.
MacosUtil.setCtrlClickEmulatesRightClick makes the click a right click, and
its release too.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-30 00:22:16 +01:00
SylviromiandClaude Opus 5.5 def1dbef33 Native mods: a mod container opens a vanilla slot menu
ModContainer.menu names a plain slot grid (hopper, generic_3x3,
generic_9x1..9x6) with as many slots; using the block opens it over the
block entity's slots, through the menu code vanilla's containers use, titled
with the block's name (Block.getDescriptionId).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 02:21:47 +03:00
SylviromiandClaude Opus 5.5 f57fe836c4 Native mods: item slots for mod block entities (hoppers, comparators, drops)
A mod block's block entity gets item slots when its mod says the block has
a container (native_block_entity_container: the slot count, the slots each
face reaches, and which slots take input or give output, as a
WorldlyContainer). Hoppers, droppers and comparators use them through the
container code vanilla's blocks use, breaking the block drops them
(Containers.dropContents), they save as Items, and the tick hook gets them.

Fixes #4034.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:59:56 +03:00
ElyTheCat 0239b3c97f Merge branch 'main' into fix/armor-rendering 2026-10-30 02:09:12 +03:30
SylviromiandClaude Opus 5.5 27c3c0be31 Native mods: the block entity hooks in the hook catalog
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:28:25 +03:00
Sylviromi 52399eb28a Merge main into native-mod-block-entities: the hook catalog 2026-10-30 01:27:37 +03:00
ElyTheCatandAntigravity f3a3c1291b Render: custom head items and multi-layer equipment
In vanilla Minecraft (26.3), items equipped in the head slot without an
armour asset (such as carved pumpkins, player heads, and mob skulls) are
rendered via CustomHeadLayer with ItemDisplayContext.HEAD transform
(down 0.25, rotated 180 degrees, scaled 0.625; skulls scaled 1.1875) for
both the local player, remote players/guests, and humanoid mobs.

Additionally, equipment assets can specify multiple layers under their
layer kinds (e.g. equipment/leather.json has layer 0 leather and layer 1
leather_overlay). Horizon previously only parsed the first layer index 0,
causing overlays like leather_overlay to be dropped.

- ArmorStandAssets: load all humanoid and wings layers in equipment JSON
  definitions into a list of layers.
- In armor_layers and armor_stand_geometry: render all humanoid layers
  for equipped items in order, including non-dyeable overlays.
- custom_head_geometry: implement CustomHeadLayer for worn non-armour
  head items and skulls, drawing worn skulls with their special model
  (including player profile skin texture) and items (e.g. carved pumpkin)
  via the item rendering pipeline.
- Hook custom_head_geometry into player model rendering, inventory preview,
  remote players/mobs, and LAN guests.
- Update port ledger mappings for CustomHeadLayer and EquipmentLayerRenderer.

Co-Authored-By: Antigravity <[email protected]>
2026-10-30 01:50:07 +03:30
SylviromiandClaude Opus 5.5 da98be3276 Native mods: a save leaves out a mod block entity mid-tick
Review of #3996: while native_block_entity_tick runs, the block entity
at its position is an empty-id placeholder; a save then skips it rather
than writing `id: ""`. The hook's doc says so, and that clients (the
window's own in single player too) don't get mod block entities yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:14:23 +03:00
SylviromiandClaude Opus 5.5 967ec25579 Native mods: block entities for mod blocks (hook points API 3)
A block entity whose id is a mod's loads as BlockEntity::Mod (its id and
saved compound) instead of staying raw chunk data, and saves back as it
was. Three hook points let a native mod give its blocks block entities
(native_block_entity), say which tick (native_block_entity_ticks) and
tick them on the server in vanilla's order (native_block_entity_tick).
Player placement, /setblock, dispensers and horizon-ctl make them through
one Game::new_block_entity.

Fixes #3966.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 01:01:49 +03:00
Михаил РоговцевandOpenAI Codex 880eb7350f Cover spear swing across a held item switch
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-30 00:04:53 +03:00
Михаил РоговцевandOpenAI Codex 15a22366e3 Test swap scale from displayed item models
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 23:53:29 +03:00
Михаил РоговцевandOpenAI Codex a554d6e7c4 Fix item model hand swap animation scale
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-29 23:39:54 +03:00
spiderandClaude Opus 5.5 e716347329 Play with Friends: leaving the world ends its invite code
On the server thread (the default single player), Save & Quit goes
through leave_server, which stopped the integrated server but kept the
sharing state: back in a world, the old code still showed with nothing
behind it. leave_server now ends the code, as lan_close already did for
the in-frame path. test_friends checks it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:06:22 +01:00
DenisHumenandClaude Opus 5.5 6774e47aae Launcher: moving a world to the trash no longer closes it on Windows
The trash crate starts COM on the calling thread and panics when that
thread already runs COM in the other mode (RPC_E_CHANGED_MODE). Deleting a
world runs on a worker of GPUI's thread pool, which can, and a panic in that
callback cannot unwind, so the launcher aborted and the world stayed.
The system trash now runs on a thread of its own, and a panic there comes
back as an error.

Fixes #3784

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:56:57 +02:00
GIGABAIT93andClaude Opus 5.5 e855ded821 Test that an offscreen game never grabs, warps or hides the pointer
The capture fixture compiles the client's own capture body; it now has
the game's options, and an offscreen game must keep its grab as its own
state without touching the window's pointer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:44:20 +02:00
GIGABAIT93andClaude Opus 5.5 4264d34f1c --offscreen: hide the window and leave the system pointer alone
An offscreen game renders into a texture, so its window only ever showed
white, yet entering a world grabbed the system pointer: on Windows it was
clipped to one pixel even while the window wasn't in front, until the
person alt-tabbed. The window is now created hidden, the grab is the
game's own state (agents still turn the view with mouse-move), and the
real mouse's raw motion never steers an offscreen game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 20:32:38 +02:00
Claude f4bdc66781 Never wait on the owner's profile fetch: list its skin once it lands
The owner's login to its server thread is main's again: no properties
and no wait. The player list gives the integrated server's owner the
signed-in account's properties instead (Hub::player_info), taken from
the background fetch only if it has already answered, so a slow, failing
or absent network can't delay opening a world. A LAN guest joins after
the world is open, well after the fetch.

Tests: the owner's login doesn't wait on a fetch that never answers; the
player list's owner entry before, after and without the fetch; and in
tests/lan_bot.rs (needs the prepared resources) a TCP guest's player
list carries the owner's textures and none of its own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CxQvrTahuwEpRxJpTvWpjG
2026-10-29 17:15:39 +00:00
DenisHumenandClaude Opus 5.5 e4c1af1fff Launcher: the version and account menus close on a click outside them
Both menus only closed from their own button or a row. They now close on
a mouse-down anywhere outside them; a press that does so on the menu's
own button no longer reopens it when it ends. Fixes #3216.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 19:02:25 +02:00
levzzzandClaude Opus 5.5 0503ee0cbf gfx: rustfmt's order for the frame_gen module
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:40:52 +05:00
spiderandClaude Opus 5.5 a91763379f FSR 3.1 optical flow, ported to WGSL (frame generation 1/3)
AMD FidelityFX SDK 1.1.4's optical flow (MIT): luma pyramids, scene change
detection, and a 7-level block search with filter and scale, as WGSL compute
passes that run on WebGPU's default limits. Not used by the game yet; the
interpolation and the setting follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 16:32:10 +01:00
umcekoandClaude Opus 5.5 a9d14449e8 Equipment assets and their textures load from content mods' namespaces
Worn armour found its equipment asset only under minecraft, and the atlas
packed only minecraft's entity textures, so a content mod's armour could
not draw as itself. EquipmentAssetManager reads `equipment/` in every
namespace: the armour layers now load from the content mods' namespaces
too, under `<ns>:<name>`, and their textures pack as
`<ns>:entity/equipment/<layer>/<path>`. A root without content mods reads
exactly what it did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:13:19 +03:00
umcekoandClaude Opus 5.5 d39715cca0 Port pack: items of vanilla tool and armour classes keep their stats
With the oracle's facts, every item came over as a stick: a ported sword
had no damage, no durability and no mining rules. An item of a vanilla
tool or armour class now gets the iron item of its class and slot as its
template, and the oracle's durability, attribute modifiers (1.21.1's ids
upgraded), tool rules, enchantability and repair items as a component
patch, minus what the template already has. The mod's tool rules replace
the template's whole (its tier names its own incorrect-for-drops tag);
what 1.21.1 kept in the class, a sword sparing blocks in creative, stays
the template's. Simplest Emerald Gear's item stats: 4/49 -> 64/64 against
the mod on NeoForge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:13:19 +03:00
umcekoandClaude Opus 5.5 f904f44047 Mod port harness: compare items' stats with the NeoForge oracle
The items check only saw an item exist with its stack size, so a ported
sword carried as a stick passed. The probe now dumps what Horizon's loaders
make of each item (durability, attribute modifiers, tool rules,
enchantability, repair items, equipment slot, food) and the harness checks
them against the oracle's components, as `item_stats`, with 1.21.1's
`generic.` attribute ids read as 26.3's and vanilla's newer tool rules
allowed beside the mod's own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 17:13:19 +03:00
Claude 26fa54369c Show your own skin: offline skins from the launcher, the host's skin on LAN
- The game now reads HORIZON_OFFLINE_SKIN / HORIZON_OFFLINE_SKIN_MODEL,
  which the launcher already sets for an offline name, and draws that skin
  (slim or classic) on the local player where neither the server nor a
  signed-in account gives one. It was read for capes only.
- On the window's own server thread (single player, and the world opened
  to LAN), the owner's memory connection now logs in with the signed-in
  account's profile properties (IntegratedServer.getSingleplayerProfile),
  so the player list carries the owner's signed skin to LAN guests instead
  of the default one. The profile is fetched while the world loads; the
  login waits for it at most 10 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CxQvrTahuwEpRxJpTvWpjG
2026-10-29 12:55:49 +00:00
martipopsandClaude Opus 5.5 88e7afbedf Controller movement on servers: the stick moves as the keys it sets do
A server only hears the movement keys (ServerboundPlayerInputPacket), and
vanilla's client moves exactly as they do (KeyboardInput.tick: 8 directions,
full speed). With analog movement a player walked at angles and speeds the
keys can't produce, and a server that predicts movement from them could pull
it back. On a server that isn't this game's own server thread the stick now
moves as its keys; single player keeps analog movement.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82HU35NWsN14K4taHT
2026-10-29 07:57:25 -04:00
Claude 0e4c964d0a Keep Player.attack's no-damage guard before the attacker's sprint-hit cut
Player.attack ends before hurtOrSimulate and causeExtraKnockback when
neither the scaled ATTACK_DAMAGE (clamped to [0, 2048], so Weakness can't
push it below 0) nor the scaled enchantment damage is left. The remote
path cut the attacker's speed on knockback alone, so an empty-hand sprint
hit under Weakness I still slowed the client.

The decision moves to attack_strength::attacker_slows_after_hit, with the
knockback formula shared with the local path as
attack_strength::extra_knockback, and unit tests for a charged and an
uncharged sprint hit, a knockback weapon, no sprint, a no-damage hit
under Weakness, enchantment damage alone, and a server-raised attack
speed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018ksHJwFp11RfYk6S9xnyCf
2026-10-29 14:06:14 +10:00
Claude 2228a7456e Apply the attacker's 0.6 speed cut after a knockback hit on another player
MultiPlayerGameMode.attack runs Player.attack on the client too. For a
mob the client's hurtOrSimulate fails, so the hit has no attacker side,
but RemotePlayer.hurtClient is true: a sprinting (or knockback-enchanted)
hit on another player runs causeExtraKnockback's attacker side on the
client, cutting horizontal speed to 0.6 and stopping the sprint. The
remote attack path only sent the packet, so after such a hit the client
kept its speed while the server's player slowed, and the server pulled
the player back (seen on Hypixel when sprint-hitting a nearby player).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018ksHJwFp11RfYk6S9xnyCf
2026-10-29 13:57:52 +10:00
356 changed files with 37954 additions and 2159 deletions

No files matched your search

+2 -1
View File
@@ -52,6 +52,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
@@ -135,7 +136,7 @@ the same `horizon_client::<module>` paths), `G:` = bin (`crates/horizon-client/s
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
Generated
+12
View File
@@ -1074,6 +1074,14 @@ dependencies = [
"digest",
]
[[package]]
name = "horizon-anticheat"
version = "0.1.0"
dependencies = [
"serde",
"toml",
]
[[package]]
name = "horizon-assets"
version = "0.1.0"
@@ -1111,6 +1119,7 @@ dependencies = [
"flate2",
"gilrs",
"glam",
"horizon-anticheat",
"horizon-assets",
"horizon-bench",
"horizon-client-core",
@@ -1175,6 +1184,7 @@ name = "horizon-client-render"
version = "0.1.0"
dependencies = [
"ab_glyph",
"base64",
"bytemuck",
"chrono",
"cpal",
@@ -1203,6 +1213,7 @@ dependencies = [
"rustix 1.1.5",
"serde",
"serde_json",
"sha1",
"sha2",
"symphonia",
"unicode-bidi",
@@ -1350,6 +1361,7 @@ name = "horizon-server"
version = "0.1.0"
dependencies = [
"chrono",
"horizon-anticheat",
"horizon-bench",
"horizon-release-sign",
"horizon-server-files",
+1
View File
@@ -13,6 +13,7 @@ members = [
"crates/horizon-command",
"crates/horizon-items",
"crates/horizon-server-files",
"crates/horizon-anticheat",
"crates/horizon-server",
"crates/horizon-bots",
"crates/horizon-assets",
+39
View File
@@ -19,3 +19,42 @@ by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1
[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT
license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). `star.svg` is Horizon's own, drawn on
the same grid.
## AMD FidelityFX SDK (FSR 3.1 frame generation)
`crates/horizon-client-render/src/gfx/frame_gen/optical_flow.wgsl` is a port to WGSL of the
optical flow shaders of the [AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK)
1.1.4 (`sdk/include/FidelityFX/gpu/opticalflow`, at commit
[c6efa6bf7f20](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK/tree/c6efa6bf7f2027b3ec94f28578bb5965eabb9e55)),
and `optical_flow.rs` follows the SDK's `sdk/src/components/opticalflow/ffx_opticalflow.cpp`
(its passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
Inc., under the MIT license, whose text heads both files. AMD and FidelityFX are AMD's
trademarks; Horizon is not affiliated with AMD.
## Mod loader logos
The launcher shows the logo of the mod loader an instance plays with.
- `launcher/assets/logos/quilt.png` is the Quilt mini icon (`brand/128png/quilt_mini_icon_transparent.png`)
from [QuiltMC/art](https://github.com/QuiltMC/art), by the Quilt Project, dedicated to the public
domain under [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/).
- `launcher/assets/logos/neoforge.png` is NeoForged's icon (`art/neoforged_icon_16x16.png`) from
[neoforged/.github](https://github.com/neoforged/.github/tree/main/art), © the NeoForged team,
created by Ridanisaurus, under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). It is
unmodified (the launcher scales it up by whole pixels).
- Fabric's and Forge's icons are not shipped: neither publishes a license for its logo. The launcher
fetches them from their own sites when an instance needs them
([fabric-loader's icon](https://github.com/FabricMC/fabric-loader/blob/master/src/main/resources/assets/fabricloader/icon.png),
[files.minecraftforge.net's](https://files.minecraftforge.net/)).
Each logo belongs to its project. Showing it says which loader an instance runs; it does not mean
the project endorses Horizon.
## Sign in with Microsoft
The launcher's sign-in buttons follow Microsoft's published branding guidelines for apps that sign in
with Microsoft accounts
([Sign in with Microsoft branding](https://learn.microsoft.com/en-us/entra/identity-platform/howto-add-branding-in-apps)).
The four-square Microsoft symbol in `launcher/src/microsoft.rs` reproduces the squares, positions and
colours of Microsoft's official `ms-symbollockup_mssymbol_19.svg` from that page, unmodified. Microsoft
and its logo are trademarks of the Microsoft group of companies; Horizon is not endorsed by Microsoft.
+71 -1
View File
@@ -13,7 +13,9 @@ const SITE: &str = "https://horizon-rust.github.io/horizon-mods/";
const USAGE: &str = "usage: cargo horizon-mod registry build <checkout> --files <dir> --out <index.json> [--release <url>] [--site <url>] [--generated <time>]
cargo horizon-mod registry review <base checkout> <head checkout> --author <login> [--json]
cargo horizon-mod registry check <base checkout> <head checkout> --files <dir>
cargo horizon-mod registry list <checkout>";
cargo horizon-mod registry list <checkout>
cargo horizon-mod registry release <checkout> <id> <file> --tag <tag> --published <YYYY-MM-DD> [--notes <file>] [--signature <file>] [--game <version>]
cargo horizon-mod registry page <checkout> <id> <horizon-registry.toml>";
fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
super::flag(args, name)
@@ -154,6 +156,74 @@ pub fn main(args: &[String]) -> Result<(), String> {
}
Ok(())
}
// A release of a project's repository, downloaded: the checks of a submission, then the
// version as the index lists it (JSON on stdout), for the registry's release sync.
Some("release") => {
let [checkout, id, file] = pos[..] else {
return Err(USAGE.into());
};
let (tag, published) = (
flag(rest, "--tag").ok_or(USAGE)?,
flag(rest, "--published").ok_or(USAGE)?,
);
let read = |name: &str| {
flag(rest, name)
.map(|p| std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}")))
.transpose()
};
let (notes, signature) = (read("--notes")?, read("--signature")?);
let entry = entry_of(checkout, id)?;
let path = Path::new(file);
let cache = registry::Cache::read(Path::new(checkout), id)?;
let entry = entry.resolved(&cache.page);
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
crate::check::check(
path,
entry.kind.clone(),
entry.key.as_deref(),
signature.as_deref(),
)?;
let version = registry::release_version(
&entry,
&registry::Release {
tag,
published,
notes: notes.as_deref().unwrap_or(""),
asset: flag(rest, "--name").unwrap_or(name),
game: flag(rest, "--game").unwrap_or("26.3"),
signature: signature.as_deref(),
},
path,
)?;
println!(
"{}",
serde_json::to_string(&version).map_err(|e| e.to_string())?
);
Ok(())
}
// The page file of a project's repository, checked against its entry: the page as JSON.
Some("page") => {
let [checkout, id, file] = pos[..] else {
return Err(USAGE.into());
};
let entry = entry_of(checkout, id)?;
let text = std::fs::read_to_string(file).map_err(|e| format!("{file}: {e}"))?;
let page = registry::page(&entry, &text)?;
println!(
"{}",
serde_json::to_string(&page).map_err(|e| e.to_string())?
);
Ok(())
}
_ => Err(USAGE.into()),
}
}
/// `mods/<id>.toml` of the checkout, which must name a repository.
fn entry_of(checkout: &str, id: &str) -> Result<registry::Entry, String> {
let (entries, _) = registry::load(Path::new(checkout)).map_err(lines)?;
entries
.into_iter()
.find(|e| e.id == id && e.repo.is_some())
.ok_or_else(|| format!("{id}: no entry with a repo"))
}
+14
View File
@@ -0,0 +1,14 @@
[package]
name = "horizon-anticheat"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version = "1.85"
description = "The dedicated server's anticheat: its [anticheat] settings, violation levels and the checks' rules, with no game state (docs/anticheat.md)"
[dependencies]
serde = { version = "1", features = ["derive"] }
toml = "1.1"
[lints]
workspace = true
+303
View File
@@ -0,0 +1,303 @@
//! The dedicated server's anticheat (docs/anticheat.md), without the game: the `[anticheat]` section of
//! `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.
//!
//! 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 timer;
/// What one check's violation level leads to.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Actions {
pub setback: f32,
pub alert: f32,
pub kick: f32,
/// Violation level lost each second.
pub decay: f32,
}
macro_rules! checks {
($(
$(#[$doc:meta])*
$field:ident: $Check:ident => $Config:ident [$setback:expr, $alert:expr, $kick:expr, $decay:expr] {
$( $(#[$xdoc:meta])* $extra:ident: $ty:ty = $default:expr, )*
}
)*) => {
/// Every check, in the order of the players' violation levels.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Check { $($Check,)* }
impl Check {
pub const ALL: &[Check] = &[$(Check::$Check,)*];
/// Its name in `server.toml` and in alerts.
pub fn name(self) -> &'static str {
match self { $(Check::$Check => stringify!($field),)* }
}
}
$(
$(#[$doc])*
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct $Config {
pub enabled: bool,
pub setback: f32,
pub alert: f32,
pub kick: f32,
pub decay: f32,
$( $(#[$xdoc])* pub $extra: $ty, )*
}
impl Default for $Config {
fn default() -> Self {
Self {
enabled: true,
setback: $setback,
alert: $alert,
kick: $kick,
decay: $decay,
$($extra: $default,)*
}
}
}
)*
/// `server.toml`'s `[anticheat]`: off by default.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub enabled: bool,
/// Tell online operators of an alert in their chat (the console and the log always are).
pub alerts: bool,
/// The permission level a player needs to see alerts.
pub alert_level: u8,
/// Every flag, one line each, relative to the server's folder; empty: no file.
pub log: String,
$(pub $field: $Config,)*
}
impl Default for Config {
fn default() -> Self {
Self {
enabled: false,
alerts: true,
alert_level: 2,
log: "logs/anticheat.log".into(),
$($field: $Config::default(),)*
}
}
}
impl Config {
/// Whether `check` runs: the anticheat and the check both on.
pub fn runs(&self, check: Check) -> bool {
self.enabled && match check { $(Check::$Check => self.$field.enabled,)* }
}
pub fn actions(&self, check: Check) -> Actions {
match check {
$(Check::$Check => {
let c = &self.$field;
Actions { setback: c.setback, alert: c.alert, kick: c.kick, decay: c.decay }
})*
}
}
}
};
}
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] {
/// 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
/// spike's backlog arrives at once).
max_lag_ms: f64 = 1000.0,
}
/// 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] {}
}
/// How many checks there are.
pub const CHECKS: usize = Check::ALL.len();
impl Config {
/// The `[anticheat]` section of a whole `server.toml`; the default (off) when it has none.
pub fn from_server_toml(text: &str) -> Result<Self, String> {
#[derive(Deserialize)]
struct File {
#[serde(default)]
anticheat: Config,
}
// Other sections are the supervisor's to check: only this one is read here.
let mut table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
let mut only = toml::Table::new();
if let Some(section) = table.remove("anticheat") {
only.insert("anticheat".into(), section);
}
let file: File = only
.try_into()
.map_err(|e: toml::de::Error| format!("[anticheat]: {e}"))?;
file.anticheat.check()?;
Ok(file.anticheat)
}
/// Thresholds can't be negative.
pub fn check(&self) -> Result<(), String> {
for &c in Check::ALL {
let a = self.actions(c);
if [a.setback, a.alert, a.kick, a.decay]
.iter()
.any(|v| !v.is_finite() || *v < 0.0)
{
return Err(format!(
"anticheat.{}: setback, alert, kick and decay are numbers from 0 up",
c.name()
));
}
}
Ok(())
}
}
/// What a flag leads to.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Verdict {
/// The check's violation level after the flag.
pub vl: f32,
/// Cancel the move or action.
pub setback: bool,
/// Tell operators and the console (at most once a second per check).
pub alert: bool,
pub kick: bool,
}
/// One player's anticheat state. Clean players cost nothing: levels only change when a check flags.
#[derive(Clone, Debug, Default)]
pub struct Player {
vl: [f32; CHECKS],
/// The server tick of each check's last flag (its decay runs from there).
flagged: [u64; CHECKS],
/// The server tick of each check's last alert.
alerted: [Option<u64>; CHECKS],
pub timer: timer::Balance,
/// The server tick and height where the ground last held the player.
pub ground: Option<(u64, f64)>,
}
impl Player {
/// The check's violation level at `tick`, with its decay since the last flag.
pub fn vl(&self, config: &Config, check: Check, tick: u64) -> f32 {
let i = check as usize;
let seconds = tick.saturating_sub(self.flagged[i]) as f32 / 20.0;
(self.vl[i] - config.actions(check).decay * seconds).max(0.0)
}
/// `check` failed with this weight at server tick `tick`.
pub fn flag(&mut self, config: &Config, check: Check, weight: f32, tick: u64) -> Verdict {
let i = check as usize;
let a = config.actions(check);
let vl = self.vl(config, check, tick) + weight;
self.vl[i] = vl;
self.flagged[i] = tick;
let reached = |threshold: f32| threshold > 0.0 && vl >= threshold;
let alert = reached(a.alert) && self.alerted[i].is_none_or(|t| tick >= t + 20);
if alert {
self.alerted[i] = Some(tick);
}
Verdict {
vl,
setback: reached(a.setback),
alert,
kick: reached(a.kick),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn on() -> Config {
Config {
enabled: true,
..Config::default()
}
}
#[test]
fn off_by_default_and_without_a_section() {
assert!(!Config::default().enabled);
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));
}
#[test]
fn reads_its_section() {
let text = "[server]\nport = 1\n[anticheat]\nenabled = true\n[anticheat.timer]\nkick = 0\ntolerance_ms = 500\n";
let c = Config::from_server_toml(text).unwrap();
assert!(c.runs(Check::Timer));
assert_eq!(c.timer.kick, 0.0);
assert_eq!(c.timer.tolerance_ms, 500.0);
assert_eq!(c.timer.alert, TimerConfig::default().alert);
let off = "[anticheat]\nenabled = true\n[anticheat.timer]\nenabled = false\n";
assert!(!Config::from_server_toml(off).unwrap().runs(Check::Timer));
}
#[test]
fn refuses_unknown_keys_and_negative_levels() {
assert!(Config::from_server_toml("[anticheat]\nenable = true\n").is_err());
assert!(Config::from_server_toml("[anticheat.timer]\nkick = -1\n").is_err());
}
#[test]
fn levels_rise_decay_and_act_in_order() {
let mut c = on();
c.timer = TimerConfig {
setback: 2.0,
alert: 3.0,
kick: 5.0,
decay: 1.0,
..TimerConfig::default()
};
let mut p = Player::default();
let v = p.flag(&c, Check::Timer, 1.0, 100);
assert_eq!((v.setback, v.alert, v.kick), (false, false, false));
let v = p.flag(&c, Check::Timer, 1.0, 100);
assert_eq!((v.vl, v.setback, v.alert), (2.0, true, false));
let v = p.flag(&c, Check::Timer, 1.0, 100);
assert!(v.alert && !v.kick);
// At most one alert a second.
assert!(!p.flag(&c, Check::Timer, 0.5, 110).alert);
// 3 at tick 110 (half a level gone, half added); two seconds later two more are gone.
assert_eq!(p.vl(&c, Check::Timer, 150), 1.0);
assert_eq!(p.vl(&c, Check::Timer, 10_000), 0.0);
let v = p.flag(&c, Check::Timer, 5.0, 10_000);
assert!(v.kick && v.alert);
}
#[test]
fn zero_turns_an_action_off() {
let mut c = on();
c.timer.kick = 0.0;
let mut p = Player::default();
assert!(!p.flag(&c, Check::Timer, 1000.0, 1).kick);
}
}
+111
View File
@@ -0,0 +1,111 @@
//! The timer check's balance: how far a client's ticks are behind (positive) or ahead (negative) of the
//! server's clock, in milliseconds.
//!
//! The server adds the wall-clock time of each of its ticks, and every ServerboundClientTickEndPacket
//! takes away the length of one tick at the server's tick rate (vanilla's client ticks at that rate,
//! `TickRateManager.millisecondsPerTick`). The balance is capped at `max_lag_ms` *after* the tick's
//! packets are counted: a slow server tick that handles a second of packets at once also added that
//! second, and a quiet connection banks at most `max_lag_ms` for the burst that ends a lag spike.
use crate::TimerConfig;
#[derive(Clone, Debug, Default)]
pub struct Balance {
pub ms: f64,
}
impl Balance {
/// The start of a server tick that came `elapsed_ms` after the previous one.
pub fn elapse(&mut self, elapsed_ms: f64, config: &TimerConfig) {
self.ms = self.ms.min(config.max_lag_ms) + elapsed_ms;
}
/// One client tick ended, at a tick rate of `tick_rate` a second. Some(how far ahead, in ms) when the
/// client is further ahead than the tolerance; the balance starts again from zero then, so a
/// sustained speed-up is flagged again each time it gets that far ahead.
pub fn tick_end(&mut self, tick_rate: f32, config: &TimerConfig) -> Option<f64> {
self.ms -= 1000.0 / f64::from(tick_rate.max(1.0));
if self.ms < -config.tolerance_ms {
let ahead = -self.ms;
self.ms = 0.0;
return Some(ahead);
}
None
}
/// The tick rate changed or the player arrived: start again.
pub fn reset(&mut self) {
self.ms = 0.0;
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `seconds` of server ticks of `server_ms` each, with `per_tick` client ticks handled in each.
fn run(b: &mut Balance, server_ms: f64, per_tick: f64, seconds: f64) -> usize {
let c = TimerConfig::default();
let mut owed = 0.0;
let mut flags = 0;
for _ in 0..(seconds * 1000.0 / server_ms) as usize {
b.elapse(server_ms, &c);
owed += per_tick;
while owed >= 1.0 {
owed -= 1.0;
flags += usize::from(b.tick_end(20.0, &c).is_some());
}
}
flags
}
#[test]
fn a_vanilla_client_is_never_flagged() {
let mut b = Balance::default();
assert_eq!(run(&mut b, 50.0, 1.0, 600.0), 0);
// A server that runs at 10 ticks a second handles two client ticks in each.
assert_eq!(run(&mut b, 100.0, 2.0, 600.0), 0);
}
#[test]
fn a_slow_server_tick_with_its_backlog_is_not_flagged() {
let (mut b, c) = (Balance::default(), TimerConfig::default());
b.elapse(3000.0, &c);
for _ in 0..60 {
assert_eq!(b.tick_end(20.0, &c), None);
}
}
#[test]
fn a_short_network_stall_is_not_flagged() {
let (mut b, c) = (Balance::default(), TimerConfig::default());
// 800 ms with nothing arriving, then the 16 ticks sent meanwhile.
for _ in 0..16 {
b.elapse(50.0, &c);
}
b.elapse(50.0, &c);
for _ in 0..17 {
assert_eq!(b.tick_end(20.0, &c), None);
}
}
#[test]
fn a_sped_up_client_is_flagged_again_and_again() {
let mut b = Balance::default();
// 10 % faster: 5 ms ahead each tick, 300 ms after 3 s, so close to 20 flags a minute.
let flags = run(&mut b, 50.0, 1.1, 60.0);
assert!((15..=20).contains(&flags), "{flags}");
// Twice as fast: a flag every 7 ticks.
assert!(run(&mut Balance::default(), 50.0, 2.0, 10.0) >= 25);
}
#[test]
fn a_pause_cant_be_saved_for_a_burst() {
let (mut b, c) = (Balance::default(), TimerConfig::default());
for _ in 0..200 {
b.elapse(50.0, &c);
}
let flagged = (0..60).any(|_| b.tick_end(20.0, &c).is_some());
assert!(flagged);
}
}
+25 -3
View File
@@ -72,6 +72,10 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
--name PREFIX bot names: PREFIX0, PREFIX1...; default Bot
--brand B the client brand sent in configuration (minecraft:brand); default none
--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)
--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)
";
@@ -129,10 +133,14 @@ struct Options {
attack_after: f64,
flood: usize,
no_read: bool,
height: f64,
ground: bool,
name: String,
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
report: Option<String>,
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
timer: f64,
}
fn options() -> Result<Options, String> {
@@ -150,9 +158,12 @@ fn options() -> Result<Options, String> {
attack_after: 5.0,
flood: 100,
no_read: false,
height: 24.0,
ground: false,
name: "Bot".into(),
report: None,
brand: None,
timer: 1.0,
};
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
@@ -164,6 +175,10 @@ fn options() -> Result<Options, String> {
o.no_read = true;
continue;
}
if arg == "--ground" {
o.ground = true;
continue;
}
let value = args.next().ok_or(format!("{arg} needs a value"))?;
let number = |what: &str| format!("{arg} needs {what}, not {value:?}");
match arg.as_str() {
@@ -187,6 +202,13 @@ fn options() -> Result<Options, String> {
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--brand" => o.brand = Some(value),
"--height" => o.height = value.parse().map_err(|_| number("blocks"))?,
"--timer" => {
o.timer = value.parse().map_err(|_| number("a speed, like 1.5"))?;
if !(o.timer >= 0.1 && o.timer <= 20.0) {
return Err(format!("--timer {value}: use 0.1 to 20"));
}
}
_ => return Err(format!("unknown option {arg}; --help lists them")),
}
}
@@ -715,7 +737,7 @@ fn run_bot(
let mut sequence = 0;
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
let cruise = spawn[1] + 24.0;
let cruise = spawn[1] + o.height;
let heading = random.unit() * std::f64::consts::TAU;
let mut target = pos;
let mut next = Instant::now();
@@ -847,7 +869,7 @@ fn run_bot(
x: pos[0],
y: pos[1],
z: pos[2],
flags: u8::from(behaviour == Behaviour::Stand),
flags: u8::from(behaviour == Behaviour::Stand || o.ground),
})
.encode(),
) && send(raw(sb::CLIENT_TICK_END, |_| {}));
@@ -855,7 +877,7 @@ fn run_bot(
bot.online.store(false, Ordering::SeqCst);
break;
}
next += Duration::from_millis(50);
next += Duration::from_secs_f64(0.05 / o.timer);
let now = Instant::now();
if next > now {
thread::sleep(next - now);
+87 -18
View File
@@ -1,32 +1,68 @@
//! Key display names (`InputConstants.Key.getDisplayName`), shared by key
//! mappings and `keybind` text components.
use crate::text::Text;
use std::{collections::BTreeMap, sync::RwLock};
/// InputConstants.Key.getDisplayName: one-character keys as the upper-case
/// character, others by translation.
/// The keys that write a symbol, with what they write on a US layout.
const SYMBOLS: [(&str, &str); 11] = [
("key.keyboard.minus", "-"),
("key.keyboard.equal", "="),
("key.keyboard.left.bracket", "["),
("key.keyboard.right.bracket", "]"),
("key.keyboard.backslash", "\\"),
("key.keyboard.semicolon", ";"),
("key.keyboard.apostrophe", "'"),
("key.keyboard.grave.accent", "`"),
("key.keyboard.comma", ","),
("key.keyboard.period", "."),
("key.keyboard.slash", "/"),
];
/// What the player's keyboard layout writes on each key that writes
/// something, by key name, as the window reported it (`learn_layout`).
static LAYOUT: RwLock<BTreeMap<String, String>> = RwLock::new(BTreeMap::new());
/// The keyboard layout writes `text` on the key `key` (`key.keyboard.q`):
/// its name from now on, as `SDL_GetKeyFromScancode` names it with SDL's
/// default keycode options. Only letter and symbol keys take it; a letter
/// key keeps its Latin letter on a non-Latin layout (`latin_letters`), and
/// the number row keeps its digits (`french_numbers`).
pub fn learn_layout(key: &str, text: &str) {
let Some(rest) = key.strip_prefix("key.keyboard.") else {
return;
};
let mut chars = text.chars();
let (Some(c), None) = (chars.next(), chars.next()) else {
return;
};
let letter_key = rest.len() == 1 && rest.as_bytes()[0].is_ascii_lowercase();
let latin = c.is_ascii() || ('\u{C0}'..='\u{24F}').contains(&c);
if c.is_control()
|| c.is_whitespace()
|| !(letter_key || SYMBOLS.iter().any(|(k, _)| *k == key))
|| (letter_key && c.is_alphabetic() && !latin)
{
return;
}
if let Ok(mut layout) = LAYOUT.write() {
layout.insert(key.to_owned(), c.to_uppercase().collect());
}
}
/// InputConstants.Key.getDisplayName: what the layout writes on the key,
/// upper-case, else the US layout's character, else by translation.
pub fn display_name(key: &str) -> Text {
if let Some(label) = LAYOUT.read().ok().and_then(|l| l.get(key).cloned()) {
return Text::literal(label);
}
if let Some(rest) = key.strip_prefix("key.keyboard.")
&& rest.chars().count() == 1
{
let c = rest.to_uppercase();
return Text::literal(c);
}
let symbol = match key {
"key.keyboard.minus" => Some("-"),
"key.keyboard.equal" => Some("="),
"key.keyboard.left.bracket" => Some("["),
"key.keyboard.right.bracket" => Some("]"),
"key.keyboard.backslash" => Some("\\"),
"key.keyboard.semicolon" => Some(";"),
"key.keyboard.apostrophe" => Some("'"),
"key.keyboard.grave.accent" => Some("`"),
"key.keyboard.comma" => Some(","),
"key.keyboard.period" => Some("."),
"key.keyboard.slash" => Some("/"),
_ => None,
};
if let Some(s) = symbol {
return Text::literal(s);
if let Some((_, s)) = SYMBOLS.iter().find(|(k, _)| *k == key) {
return Text::literal(*s);
}
if let Some(n) = key.strip_prefix("key.mouse.")
&& n.parse::<u32>().is_ok()
@@ -36,3 +72,36 @@ pub fn display_name(key: &str) -> Text {
}
Text::translate(key, &[])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keys_show_what_the_layout_writes() {
// The layout table is shared: these keys are this test's alone.
assert_eq!(display_name("key.keyboard.q").plain(), "Q");
// AZERTY: A on the Q key, M on the semicolon key, a dead ^ on the
// left bracket, a comma on the M key.
learn_layout("key.keyboard.q", "a");
learn_layout("key.keyboard.semicolon", "m");
learn_layout("key.keyboard.left.bracket", "^");
learn_layout("key.keyboard.m", ",");
assert_eq!(display_name("key.keyboard.q").plain(), "A");
assert_eq!(display_name("key.keyboard.semicolon").plain(), "M");
assert_eq!(display_name("key.keyboard.left.bracket").plain(), "^");
assert_eq!(display_name("key.keyboard.m").plain(), ",");
// The number row keeps its digits; a Cyrillic letter, nothing
// printable and keys that write nothing are left alone.
learn_layout("key.keyboard.1", "&");
learn_layout("key.keyboard.f", "а");
learn_layout("key.keyboard.g", "\u{8}");
learn_layout("key.keyboard.tab", "\t");
learn_layout("key.keyboard.h", "ab");
assert_eq!(display_name("key.keyboard.1").plain(), "1");
assert_eq!(display_name("key.keyboard.f").plain(), "F");
assert_eq!(display_name("key.keyboard.g").plain(), "G");
assert_eq!(display_name("key.keyboard.h").plain(), "H");
assert_eq!(display_name("key.keyboard.slash").plain(), "/");
}
}
+45
View File
@@ -2,6 +2,9 @@
//! Item-use and sneak multipliers come from the player's current state; this
//! module does not implement sprint eligibility, poses or the input lifecycle.
use std::collections::HashSet;
use winit::keyboard::{KeyCode, ModifiersState};
fn length(v: [f32; 2]) -> f32 {
f64::from(v[0] * v[0] + v[1] * v[1]).sqrt() as f32
}
@@ -65,3 +68,45 @@ fn square_movement(v: [f32; 2]) -> [f32; 2] {
},
)
}
/// `InputQuirks.isQuitShortcutDown`: checks whether the platform's window-closing
/// keyboard shortcut is held.
/// - On macOS: Command (`super_key()`) + `Q`.
/// - On Windows / Linux / other: Alt (`alt_key()`) + `F4`.
pub fn is_quit_shortcut_down(modifiers: ModifiersState, keys: &HashSet<KeyCode>) -> bool {
if cfg!(target_os = "macos") {
modifiers.super_key() && keys.contains(&KeyCode::KeyQ)
} else {
modifiers.alt_key() && keys.contains(&KeyCode::F4)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn quit_shortcut_follows_platform_modifiers_and_keys() {
let mut keys = HashSet::new();
let alt = ModifiersState::ALT;
let super_key = ModifiersState::SUPER;
assert!(!is_quit_shortcut_down(ModifiersState::empty(), &keys));
keys.insert(KeyCode::F4);
if cfg!(target_os = "macos") {
assert!(!is_quit_shortcut_down(alt, &keys));
keys.remove(&KeyCode::F4);
keys.insert(KeyCode::KeyQ);
assert!(is_quit_shortcut_down(super_key, &keys));
assert!(!is_quit_shortcut_down(alt, &keys));
} else {
assert!(is_quit_shortcut_down(alt, &keys));
assert!(!is_quit_shortcut_down(super_key, &keys));
keys.remove(&KeyCode::F4);
keys.insert(KeyCode::KeyQ);
assert!(!is_quit_shortcut_down(alt, &keys));
assert!(!is_quit_shortcut_down(super_key, &keys));
}
}
}
@@ -284,6 +284,26 @@ pub fn mouse_button(name: &str) -> Option<MouseButton> {
}
pub use horizon_client_core::key_display::display_name;
/// A key event: what the layout writes on its key, without modifiers, names
/// that key from now on (`key_display::learn_layout`), so an AZERTY player's
/// Q key shows A.
pub fn learn_layout(event: &winit::event::KeyEvent) {
use winit::keyboard::{Key, PhysicalKey};
use winit::platform::modifier_supplement::KeyEventExtModifierSupplement;
let PhysicalKey::Code(code) = event.physical_key else {
return;
};
let Some(name) = key_name(code) else {
return;
};
let learn = horizon_client_core::key_display::learn_layout;
match event.key_without_modifiers() {
Key::Character(text) => learn(name, &text),
Key::Dead(Some(c)) => learn(name, c.encode_utf8(&mut [0; 4])),
_ => {}
}
}
/// Current bindings by mapping name.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct KeyBinds {
+39
View File
@@ -342,6 +342,28 @@ pub fn mouse_position_after_grab_change(
}
}
/// `MacosUtil.setCtrlClickEmulatesRightClick`: with the option on (and only on
/// macOS), a left click made with Control held is a right click, and so is
/// its release, whatever Control does in between.
#[derive(Clone, Copy, Debug, Default)]
pub struct RightClickEmulation {
emulating: bool,
}
impl RightClickEmulation {
/// Whether the left button's `pressed` or released event is a right click.
/// `enabled` is the option on macOS.
pub fn is_right_click(&mut self, pressed: bool, control: bool, enabled: bool) -> bool {
if pressed {
self.emulating = enabled && control;
}
let right = self.emulating;
if !pressed {
self.emulating = false;
}
right
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -526,4 +548,21 @@ mod tests {
None
);
}
#[test]
fn control_click_is_a_right_click_until_released() {
let mut e = RightClickEmulation::default();
// The option off, or no Control: a left click stays one.
assert!(!e.is_right_click(true, true, false));
assert!(!e.is_right_click(false, true, false));
assert!(!e.is_right_click(true, false, true));
assert!(!e.is_right_click(false, true, true));
// Control held at the press: the press and the release are right clicks,
// even when Control was let go before the release.
assert!(e.is_right_click(true, true, true));
assert!(e.is_right_click(false, false, true));
// The next left click is its own.
assert!(!e.is_right_click(true, false, true));
assert!(!e.is_right_click(false, false, true));
}
}
+246 -1
View File
@@ -284,12 +284,18 @@ impl FramerateLimiter {
/// (`Window.updateFullscreenState` -> `Minecraft.fullscreenStateChanged`), and
/// the window is only asked to change when the option itself changes
/// (`Window.updateFullscreenIfChanged`), never re-forced to the option.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FullscreenState {
/// The state the window system last reported (`Window.fullscreen`).
actual: bool,
/// The state last asked of the window system, or adopted from it.
requested: bool,
/// `Window.exclusiveFullscreen`.
exclusive: bool,
/// `Window.preferredFullscreenVideoMode`.
mode: Option<VideoMode>,
/// `Window.dirty`: the preferred mode changed and the screen has not been left yet.
mode_dirty: bool,
}
impl FullscreenState {
@@ -298,9 +304,70 @@ impl FullscreenState {
Self {
actual: false,
requested,
exclusive: false,
mode: None,
mode_dirty: false,
}
}
/// `Window.updateFullscreenIfChanged` with the exclusive option and preferred mode: asks the
/// window to enter, leave or change its fullscreen only when one of them changed.
/// `in_video_screen` holds a changed mode back until the Video Settings screen closes
/// (`changeFullscreenVideoMode`).
pub fn apply(
&mut self,
window: &winit::window::Window,
wanted: bool,
exclusive: bool,
preferred: Option<VideoMode>,
in_video_screen: bool,
) {
let request = self.request(wanted);
let reapply = self.set_exclusive(exclusive);
self.set_preferred_mode(preferred);
let mode_change = !in_video_screen && self.take_mode_change();
match request {
Some(false) => window.set_fullscreen(None),
Some(true) => window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
))),
None if reapply || mode_change => window.set_fullscreen(Some(fullscreen_request(
window,
exclusive,
preferred.as_ref(),
))),
None => {}
}
}
/// `Window.setExclusiveFullscreen`: true when the option changed while fullscreen is
/// requested, so the window has to enter the other kind of fullscreen at once.
pub fn set_exclusive(&mut self, exclusive: bool) -> bool {
let changed = self.exclusive != exclusive;
self.exclusive = exclusive;
changed && self.requested
}
/// `Window.setPreferredFullscreenVideoMode`: a changed mode waits for `take_mode_change`.
pub fn set_preferred_mode(&mut self, mode: Option<VideoMode>) {
if self.mode != mode {
self.mode = mode;
self.mode_dirty = true;
}
}
/// `Window.changeFullscreenVideoMode` (the Video Settings screen closing): true once when
/// a changed mode has to be applied to a fullscreen window.
pub fn take_mode_change(&mut self) -> bool {
let apply = self.requested && self.mode_dirty;
if apply {
self.mode_dirty = false;
}
apply
}
/// `Window.updateFullscreenIfChanged`: the state to ask of the window
/// when the option `wanted` differs from what was last asked or adopted.
pub fn request(&mut self, wanted: bool) -> Option<bool> {
@@ -321,6 +388,132 @@ impl FullscreenState {
}
}
/// `VideoMode`: one display mode of a monitor, as `fullscreenResolution` in options.txt keeps it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct VideoMode {
pub width: i32,
pub height: i32,
pub red_bits: i32,
pub green_bits: i32,
pub blue_bits: i32,
pub refresh_rate: f32,
}
impl VideoMode {
/// `VideoMode.read`: the JSON object `write` makes; any other string is None.
pub fn read(text: &str) -> Option<Self> {
let number = |key: &str| -> Option<f64> {
let at = text.find(&format!("\"{key}\""))? + key.len() + 2;
let rest = text[at..].trim_start().strip_prefix(':')?.trim_start();
let end = rest
.find(|c: char| !(c.is_ascii_digit() || matches!(c, '-' | '+' | '.' | 'e' | 'E')))
.unwrap_or(rest.len());
rest[..end].parse().ok()
};
if !text.trim_start().starts_with('{') {
return None;
}
Some(Self {
width: number("width")? as i32,
height: number("height")? as i32,
red_bits: number("red_bits")? as i32,
green_bits: number("green_bits")? as i32,
blue_bits: number("blue_bits")? as i32,
refresh_rate: number("refresh_rate")? as f32,
})
}
/// `VideoMode.write`: the Codec's JSON through Gson (a float prints as `60.0`).
pub fn write(&self) -> String {
let rate = if self.refresh_rate == self.refresh_rate.trunc() {
format!("{:.1}", self.refresh_rate)
} else {
self.refresh_rate.to_string()
};
format!(
"{{\"width\":{},\"height\":{},\"red_bits\":{},\"green_bits\":{},\"blue_bits\":{},\"refresh_rate\":{rate}}}",
self.width, self.height, self.red_bits, self.green_bits, self.blue_bits
)
}
/// `VideoMode.refreshRateLabel`: a whole rate without decimals, any other with two.
pub fn refresh_rate_label(&self) -> String {
if self.refresh_rate == self.refresh_rate.round_ties_even() {
(self.refresh_rate as i32).to_string()
} else {
format!("{:.2}", self.refresh_rate)
}
}
/// The colour depth `options.fullscreen.entry` shows.
pub fn bits(&self) -> i32 {
self.red_bits + self.green_bits + self.blue_bits
}
/// A winit mode: its depth is the total, split evenly over the channels.
pub fn from_winit(mode: &winit::monitor::VideoModeHandle) -> Self {
let depth = i32::from(mode.bit_depth());
let size = mode.size();
Self {
width: size.width as i32,
height: size.height as i32,
red_bits: depth / 3,
green_bits: depth / 3,
blue_bits: depth - 2 * (depth / 3),
refresh_rate: mode.refresh_rate_millihertz() as f32 / 1000.0,
}
}
}
/// `Monitor.videoModes`: the modes a monitor offers.
pub fn monitor_video_modes(monitor: &winit::monitor::MonitorHandle) -> Vec<VideoMode> {
monitor
.video_modes()
.map(|m| VideoMode::from_winit(&m))
.collect()
}
/// `Monitor.currentMode`: the monitor's desktop mode; winit has no such query, so the mode of
/// the monitor's own size with the highest refresh rate and depth.
fn monitor_current_mode(
monitor: &winit::monitor::MonitorHandle,
) -> Option<winit::monitor::VideoModeHandle> {
let size = monitor.size();
monitor
.video_modes()
.filter(|m| m.size() == size)
.max_by_key(|m| (m.refresh_rate_millihertz(), m.bit_depth()))
}
/// `Window.applyFullscreen`: the fullscreen the window asks for. Exclusive fullscreen takes
/// `Monitor.getPreferredVideoMode` (the preferred mode if the monitor has it, else the desktop
/// mode); a monitor with no usable mode falls back to borderless, as vanilla does.
pub fn fullscreen_request(
window: &winit::window::Window,
exclusive: bool,
preferred: Option<&VideoMode>,
) -> winit::window::Fullscreen {
use winit::window::Fullscreen;
if !exclusive {
return Fullscreen::Borderless(None);
}
let Some(monitor) = window
.current_monitor()
.or_else(|| window.primary_monitor())
else {
return Fullscreen::Borderless(None);
};
let wanted = preferred.and_then(|p| {
monitor
.video_modes()
.find(|m| VideoMode::from_winit(m) == *p)
});
match wanted.or_else(|| monitor_current_mode(&monitor)) {
Some(mode) => Fullscreen::Exclusive(mode),
None => Fullscreen::Borderless(None),
}
}
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
pub fn icon() -> Option<winit::window::Icon> {
@@ -340,11 +533,63 @@ pub fn icon() -> Option<winit::window::Icon> {
mod tests {
use super::*;
#[test]
fn video_mode_round_trips_the_options_json() {
let mode = VideoMode {
width: 1920,
height: 1080,
red_bits: 8,
green_bits: 8,
blue_bits: 8,
refresh_rate: 60.0,
};
let text = mode.write();
assert_eq!(
text,
r#"{"width":1920,"height":1080,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":60.0}"#
);
assert_eq!(VideoMode::read(&text), Some(mode));
assert_eq!(VideoMode::read("1920x1080"), None);
assert_eq!(VideoMode::read(r#"{"width":1}"#), None);
}
#[test]
fn refresh_rate_label_is_whole_or_two_decimals() {
let mut mode = VideoMode::read(
r#"{"width":1,"height":1,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":144.0}"#,
)
.unwrap();
assert_eq!(mode.refresh_rate_label(), "144");
mode.refresh_rate = 59.94;
assert_eq!(mode.refresh_rate_label(), "59.94");
assert_eq!(mode.bits(), 24);
}
#[test]
fn the_window_icon_decodes() {
assert!(icon().is_some());
}
#[test]
fn exclusive_fullscreen_reapplies_only_while_fullscreen_is_requested() {
let mut f = FullscreenState::new(false);
assert!(!f.set_exclusive(true));
assert!(!f.set_exclusive(true));
assert_eq!(f.request(true), Some(true));
assert!(f.set_exclusive(false));
assert!(!f.set_exclusive(false));
}
#[test]
fn a_changed_video_mode_applies_once_when_fullscreen() {
let mode = VideoMode::read(
r#"{"width":1,"height":1,"red_bits":8,"green_bits":8,"blue_bits":8,"refresh_rate":60.0}"#,
);
let mut f = FullscreenState::new(false);
f.set_preferred_mode(mode);
assert!(!f.take_mode_change());
f.request(true);
f.set_preferred_mode(mode);
assert!(f.take_mode_change());
assert!(!f.take_mode_change());
}
#[test]
fn fullscreen_follows_the_option_only_when_it_changes() {
let mut f = FullscreenState::new(false);
+2
View File
@@ -23,6 +23,7 @@ horizon-theme = { path = "../horizon-theme" }
horizon-threads = { path = "../horizon-threads" }
horizon-world = { path = "../horizon-world" }
ab_glyph = "0.2.32"
base64 = "0.22"
bytemuck = { version = "1.24", features = ["derive"] }
# Sound (ALSA on Linux); the `audio` feature.
cpal = { version = "0.16", optional = true }
@@ -36,6 +37,7 @@ png = "0.18"
pollster = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
sha1 = "0.10"
sha2 = "0.10.9"
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
unicode-bidi = "0.3.18"
@@ -23,7 +23,7 @@
//! Which states tick is a table by state id, built by the game from the block
//! registry; whether a slot is made is decided when the slot is promoted at
//! the start of the next tick, from the block state then.
use crate::chunk::block_entity::BlockEntity;
use crate::chunk::block_entity::{BlockEntity, ModContainer};
use crate::fast_hash::{FxHashMap, FxHashSet};
use std::collections::HashMap;
use std::sync::Arc;
@@ -70,6 +70,9 @@ pub struct BlockEntities {
replaced: FxHashSet<Pos>,
/// `Ticking` by block state id; empty until the game builds it.
states: Vec<Ticking>,
/// The containers of mod blocks' block entities, by block state id;
/// built with `states`.
mod_containers: FxHashMap<u32, Arc<ModContainer>>,
}
impl std::ops::Deref for BlockEntities {
@@ -152,6 +155,13 @@ impl BlockEntities {
pub fn ticking(&self, state: u32) -> Ticking {
self.states.get(state as usize).copied().unwrap_or_default()
}
pub fn set_mod_containers(&mut self, containers: FxHashMap<u32, Arc<ModContainer>>) {
self.mod_containers = containers;
}
/// The container of a mod block's block entity in block state `state`.
pub fn mod_container(&self, state: u32) -> Option<&Arc<ModContainer>> {
self.mod_containers.get(&state)
}
fn next(&mut self) -> u32 {
let n = self.next_slot;
@@ -627,6 +627,34 @@ pub fn perspective(
m[11] = -1.0;
m
}
/// `Frustum.getNearPlaneBounds`: the box around the four corners of the
/// culling frustum's NDC `z = -1` plane, relative to the camera (the caller
/// moves it to the eye). The culling projection is `Camera.update`'s
/// `setPerspective(fov, aspect, 0.05, depthFar, zZeroToOne)`.
pub fn near_plane_bounds(
fov_degrees: f32,
width: f32,
height: f32,
yaw: f32,
pitch: f32,
far: f32,
) -> ([f64; 3], [f64; 3]) {
let projection =
glam::Mat4::from_cols_array(&perspective(fov_degrees, width, height, 0.05, far, true));
// Frustum.calculateFrustum: projection * modelView.
let clip_to_world = (projection * view_matrix(yaw, pitch)).inverse();
let mut min = [f64::INFINITY; 3];
let mut max = [f64::NEG_INFINITY; 3];
for (x, y) in [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] {
let p = clip_to_world * glam::Vec4::new(x, y, -1.0, 1.0);
let p = [p.x / p.w, p.y / p.w, p.z / p.w];
for k in 0..3 {
min[k] = min[k].min(f64::from(p[k]));
max[k] = max[k].max(f64::from(p[k]));
}
}
(min, max)
}
/// `GameRenderer.render3dHud`: the first-person hand and held items are
/// drawn with their own projection at `hudFov` (70 degrees, changed only by
/// death and fluids), never the FOV option or the sprint, speed and bow
@@ -834,6 +862,19 @@ pub fn spinning_effect_matrix(intensity: f32, angle_degrees: f32) -> glam::Mat4
#[cfg(test)]
mod tests {
#[test]
fn near_plane_bounds_sit_half_a_near_distance_ahead_of_the_eye() {
// Facing +Z (yaw 0): the zero-to-one culling projection puts NDC
// z = -1 at -far * near / (2 far - near) in front of the camera.
let far = 512.0;
let (min, max) = super::near_plane_bounds(70.0, 1600.0, 900.0, 0.0, 0.0, far);
let depth = f64::from(far * 0.05 / (2.0 * far - 0.05));
assert!((min[2] - depth).abs() < 1e-4 && (max[2] - depth).abs() < 1e-4);
let half_height = depth * 35.0f64.to_radians().tan();
assert!((max[1] - half_height).abs() < 1e-4 && (min[1] + half_height).abs() < 1e-4);
let half_width = half_height * 1600.0 / 900.0;
assert!((max[0] - half_width).abs() < 1e-3 && (min[0] + half_width).abs() < 1e-3);
}
/// `moveCloak` trails a quarter of the way, jumps from 10 blocks away; the
/// cape leans back as the player walks forward (south, body yaw 0).
#[test]
@@ -80,9 +80,63 @@ pub enum BlockEntity {
Shelf(Box<Shelf>),
/// `ChiseledBookShelfBlockEntity`: a `Container` of six one-book slots.
ChiseledBookshelf(Box<ChiseledBookshelf>),
/// A block entity of a mod (`native_block_entity`), kept as its saved
/// compound for the mod to read and change.
Mod(Box<ModBlockEntity>),
/// `PotentSulfurBlockEntity` (`game/potent_sulfur.rs`).
PotentSulfur(PotentSulfur),
/// `StructureBlockEntity` (`structure_block.rs`).
StructureBlock(Box<crate::structure_block::StructureBlock>),
/// `JigsawBlockEntity` (`jigsaw_block.rs`).
JigsawBlock(Box<crate::jigsaw_block::JigsawBlock>),
/// `TestBlockEntity` (`test_block.rs`).
TestBlock(Box<crate::test_block::TestBlock>),
/// `TestInstanceBlockEntity` (`test_instance_block.rs`).
TestInstanceBlock(Box<crate::test_instance_block::TestInstanceBlock>),
}
/// A mod's block entity: its type id (`mymod:machine`), its saved
/// compound without `id`, `x`, `y`, `z` and `Items`, its item slots
/// (`Items`) when its block has a container (`ModContainer`), and the data
/// slots its menu shows (`ContainerData`: a furnace menu's flame and arrow),
/// which the mod sets and nothing saves.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ModBlockEntity {
pub id: String,
pub nbt: Compound,
pub items: Vec<Option<ItemStack>>,
pub data: Vec<i32>,
}
/// The container of a mod block's block entity, as a `WorldlyContainer`
/// says it: its slot count, the slots each face reaches
/// (`getSlotsForFace`, by `Direction`: down, up, north, south, west, east),
/// and the slots hoppers may put into (`canPlaceItemThroughFace`) and take
/// from (`canTakeItemThroughFace`); and the vanilla menu that shows its
/// slots when the block is used, if any (`MOD_MENUS`).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct ModContainer {
pub slots: usize,
pub faces: [Vec<usize>; 6],
pub inputs: Vec<usize>,
pub outputs: Vec<usize>,
pub menu: Option<String>,
}
/// The vanilla menus a mod container can open, with the slot count each
/// shows: slot grids, and the furnaces' (input, fuel, result), whose four
/// data slots draw the flame (`data[0]` of `data[1]`) and the arrow
/// (`data[2]` of `data[3]`), as `AbstractFurnaceBlockEntity.dataAccess`.
pub const MOD_MENUS: [(&str, usize); 11] = [
("minecraft:furnace", 3),
("minecraft:blast_furnace", 3),
("minecraft:smoker", 3),
("minecraft:hopper", 5),
("minecraft:generic_3x3", 9),
("minecraft:generic_9x1", 9),
("minecraft:generic_9x2", 18),
("minecraft:generic_9x3", 27),
("minecraft:generic_9x4", 36),
("minecraft:generic_9x5", 45),
("minecraft:generic_9x6", 54),
];
/// `PotentSulfurBlockEntity`: the seconds left to the geyser's next change of
/// state (`waitingCountdown`, saved as `countdown`) and the game time of its
/// last eruption block event, which the client's plume counts from.
+44 -2
View File
@@ -3,7 +3,8 @@
//! from the data-driven `context_int_provider`/`context_float_provider` files.
pub use crate::chunk::block_entity::{
Banner, Beacon, BlockEntity, Brushable, Campfire, ChiseledBookshelf, Crafter, CreakingHeart,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, PotentSulfur, Shelf, Skull, SkullSkin,
EndGateway, Furnace, FurnaceKind, Jukebox, Lectern, MOD_MENUS, ModBlockEntity, ModContainer,
PotentSulfur, Shelf, Skull, SkullSkin,
};
use crate::items::ItemStack;
use std::collections::HashMap;
@@ -306,6 +307,27 @@ impl BlockEntity {
if name == "minecraft:chiseled_bookshelf" {
return Some(Self::ChiseledBookshelf(Box::default()));
}
if name == "minecraft:structure_block" {
// The entity's mode is the state's (`StructureBlock.MODE`); the block's
// state is not known here, so a new one starts in its default, SAVE.
return Some(Self::StructureBlock(Box::new(
crate::structure_block::StructureBlock::new(
crate::structure_block::StructureMode::Save,
),
)));
}
if name == "minecraft:test_block" {
// The mode is the state's, which is not known here.
return Some(Self::TestBlock(Box::new(
crate::test_block::TestBlock::new(crate::test_block::TestBlockMode::Fail),
)));
}
if name == "minecraft:test_instance_block" {
return Some(Self::TestInstanceBlock(Box::default()));
}
if name == "minecraft:jigsaw" {
return Some(Self::JigsawBlock(Box::default()));
}
if name == "minecraft:potent_sulfur" {
return Some(Self::PotentSulfur(PotentSulfur::default()));
}
@@ -319,7 +341,7 @@ impl BlockEntity {
};
Some(Self::Chest(vec![None; size]))
}
pub fn id(&self) -> &'static str {
pub fn id(&self) -> &str {
match self {
Self::Chest(items) => match items.len() {
HOPPER_SLOTS => "minecraft:hopper",
@@ -352,7 +374,12 @@ impl BlockEntity {
Self::Campfire(_) => "minecraft:campfire",
Self::ChiseledBookshelf(_) => "minecraft:chiseled_bookshelf",
Self::Shelf(_) => "minecraft:shelf",
Self::Mod(m) => &m.id,
Self::PotentSulfur(_) => "minecraft:potent_sulfur",
Self::StructureBlock(_) => "minecraft:structure_block",
Self::JigsawBlock(_) => "minecraft:jigsaw",
Self::TestBlock(_) => "minecraft:test_block",
Self::TestInstanceBlock(_) => "minecraft:test_instance_block",
}
}
pub fn contents(&self) -> Vec<ItemStack> {
@@ -372,8 +399,13 @@ impl BlockEntity {
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
| Self::StructureBlock(_)
| Self::JigsawBlock(_)
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Brushable(_) => Vec::new(),
Self::Mod(m) => m.items.iter().flatten().cloned().collect(),
Self::Lectern(l) => l.book.iter().cloned().collect(),
Self::Sculk(_) => Vec::new(),
Self::Banner(_) => Vec::new(),
@@ -397,11 +429,16 @@ impl BlockEntity {
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
| Self::StructureBlock(_)
| Self::JigsawBlock(_)
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Brushable(_) => &[],
Self::Crafter(c) => &c.items,
Self::ChiseledBookshelf(b) => &b.items,
Self::Shelf(b) => &b.items,
Self::Mod(m) => &m.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
@@ -426,11 +463,16 @@ impl BlockEntity {
| Self::EndGateway(_)
| Self::Skull(_)
| Self::PotentSulfur(_)
| Self::StructureBlock(_)
| Self::JigsawBlock(_)
| Self::TestBlock(_)
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Brushable(_) => &mut [],
Self::Crafter(c) => &mut c.items,
Self::ChiseledBookshelf(b) => &mut b.items,
Self::Shelf(b) => &mut b.items,
Self::Mod(m) => &mut m.items,
Self::CreakingHeart(_)
| Self::Spawner(_)
| Self::TrialSpawner(_)
+28 -1
View File
@@ -496,8 +496,24 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
}
}
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
fn is_worker_thread(name: Option<&str>) -> bool {
name.is_some_and(|n| n.starts_with("horizon-worker-"))
}
/// `Util.onThreadException`: a panic on a worker thread is made a report titled "Exception on
/// worker thread", with the thread's name, as `BlockableEventLoop.relayDelayCrash` hands it to
/// the main thread.
pub fn worker_panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Report {
Report {
description: "Exception on worker thread".into(),
..panic_report(info, side)
}
}
/// Reports panics: on the main thread a crash report (the panic then ends the game, exit code
/// 101); on other threads the error goes to the log, as vanilla's uncaught exception handler
/// 101); on a worker thread (`Util.onThreadException`) a crash report too, and the game ends
/// with it; on other threads the error goes to the log, as vanilla's uncaught exception handler
/// does. The default hook still prints the panic. A panic inside `contain` is only logged.
pub fn install(home: PathBuf, side: &'static str) {
let default = std::panic::take_hook();
@@ -509,6 +525,10 @@ pub fn install(home: PathBuf, side: &'static str) {
let main = std::thread::current().name() == Some("main");
if main {
deliver(&home, panic_report(info, side));
} else if is_worker_thread(std::thread::current().name()) {
deliver(&home, worker_panic_report(info, side));
// The crash ends the game, as the relayed report does on vanilla's main thread.
std::process::exit(101);
} else {
log::error!(
"Caught previously unhandled exception in thread {}: {}",
@@ -734,6 +754,13 @@ mod tests {
assert!(first.contains("crash.rs"), "{}", debug.stack);
}
#[test]
fn worker_threads_are_the_streaming_pool() {
assert!(is_worker_thread(Some("horizon-worker-3")));
assert!(!is_worker_thread(Some("main")));
assert!(!is_worker_thread(None));
}
#[test]
fn a_panic_becomes_a_report() {
// A real panic through a hook, as the game's would be.
+41 -2
View File
@@ -618,6 +618,37 @@ pub fn enabled_ids(world: &Path, packs: &[DataPack]) -> Vec<String> {
enabled
}
/// The level stems of `packs` (`data/<namespace>/dimension/<path>.json`, lowest priority first,
/// as `enabled_ids` orders them): the id `namespace:path` and the parsed JSON, a later pack's
/// file for an id replacing an earlier one's. This is `context.datapackDimensions()` of
/// `WorldOpenFlows.loadWorldStem`.
pub fn dimension_stems(packs: &[&DataPack]) -> BTreeMap<String, serde_json::Value> {
let mut out = BTreeMap::new();
for pack in packs {
for file in pack.list_kinds(&["dimension"]) {
let Some(rest) = file.strip_prefix("data/") else {
continue;
};
let Some((namespace, rest)) = rest.split_once('/') else {
continue;
};
let Some(path) = rest
.strip_prefix("dimension/")
.and_then(|p| p.strip_suffix(".json"))
else {
continue;
};
if let Some(json) = pack
.read(&file)
.and_then(|b| serde_json::from_slice(&b).ok())
{
out.insert(format!("{namespace}:{path}"), json);
}
}
}
out
}
// ---- worldgen ----------------------------------------------------------------
/// The worldgen entries of `packs` (lowest priority first, the built-in pack
@@ -904,9 +935,17 @@ mod tests {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, t).unwrap();
};
write("fns", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
write(
"fns",
"pack.mcmeta",
r#"{"pack":{"min_format":121,"max_format":121}}"#,
);
write("fns", "data/x/function/f.mcfunction", "say hi");
write("off", "pack.mcmeta", r#"{"pack":{"pack_format":121}}"#);
write(
"off",
"pack.mcmeta",
r#"{"pack":{"min_format":121,"max_format":121}}"#,
);
std::fs::create_dir_all(&dir).unwrap();
let mut zip = zip::ZipWriter::new(std::fs::File::create(dir.join("old.zip")).unwrap());
zip.start_file("pack.mcmeta", zip::write::SimpleFileOptions::default())
+21 -1
View File
@@ -171,6 +171,9 @@ pub enum OmenTick {
pub struct PlayerEffects {
pub list: Vec<EffectInstance>,
pub tick_count: i32,
/// How many times `add` added or updated an effect: each is one
/// `onEffectAdded` or `onEffectUpdated` (`EffectsChangedTrigger`).
pub changes: u32,
}
impl PlayerEffects {
pub fn get(&self, id: &str) -> Option<&EffectInstance> {
@@ -181,13 +184,17 @@ impl PlayerEffects {
}
/// `LivingEntity.addEffect`; returns whether anything changed.
pub fn add(&mut self, effect: EffectInstance) -> bool {
match self.list.iter_mut().find(|e| e.id == effect.id) {
let changed = match self.list.iter_mut().find(|e| e.id == effect.id) {
Some(existing) => existing.update(&effect),
None => {
self.list.push(effect);
true
}
};
if changed {
self.changes = self.changes.wrapping_add(1);
}
changed
}
pub fn clear(&mut self) {
self.list.clear();
@@ -482,6 +489,19 @@ pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64,
mod tests {
use super::*;
#[test]
fn adding_counts_a_change_only_when_the_effect_changed() {
let mut fx = PlayerEffects::default();
fx.add(EffectInstance::new("speed", 100, 0));
assert_eq!(fx.changes, 1);
// A longer duration updates the effect (`onEffectUpdated`).
fx.add(EffectInstance::new("speed", 200, 0));
assert_eq!(fx.changes, 2);
// A shorter one with the same amplifier is hidden by nothing: no change.
fx.add(EffectInstance::new("speed", 50, 0));
assert_eq!(fx.changes, 2);
}
#[test]
fn bad_omen_in_a_village_becomes_raid_omen_over_two_ticks() {
let mut fx = PlayerEffects::default();
+263 -17
View File
@@ -75,6 +75,7 @@ pub mod compositor;
mod cull;
mod end_sky;
mod flat;
mod frame_gen;
mod hdr;
pub mod lightning;
mod lod;
@@ -153,14 +154,42 @@ fn world_pass_after_culling<'e>(
..Default::default()
})
}
fn section_instance(origin: [f32; 3]) -> [f32; 4] {
/// A section's instance: its camera-relative origin and its UV step, negated
/// past its fade code while it fades in over far terrain (`lod::instance`).
fn section_instance(origin: [f32; 3], fade: u32, visibility: f32) -> [f32; 4] {
let step = 1.0 / crate::mesher::UV_STEPS;
[
origin[0],
origin[1],
origin[2],
1.0 / crate::mesher::UV_STEPS,
if fade == 0 {
// The step is a power of two, so its mantissa is free: it holds the vanilla
// chunk fade-in's `1 - visibility` in 23 bits (terrain.wgsl's `instance_step` and
// `instance_visibility` read it back). A whole section is the bare step.
let faded = ((1.0 - visibility) * 8_388_608.0)
.round()
.clamp(0.0, 8_388_607.0) as u32;
f32::from_bits(step.to_bits() | faded)
} else {
-(fade as f32 + step)
},
]
}
/// `SectionRenderDispatcher.RenderSection.getVisibility`: how far through its fade-in a section
/// uploaded `elapsed_ms` ago is.
fn chunk_visibility(elapsed_ms: u64, fade_ms: u64) -> f32 {
if elapsed_ms >= fade_ms {
1.0
} else {
elapsed_ms as f32 / fade_ms as f32
}
}
/// `Util.toMillis`: `Mth.floor(seconds * 1000.0)`.
fn fade_millis(seconds: f32) -> u64 {
(f64::from(seconds) * 1000.0).floor().max(0.0) as u64
}
/// `LevelRenderer.CHUNK_VISIBILITY_THRESHOLD`: a section's block entities wait for it.
const CHUNK_VISIBILITY_THRESHOLD: f32 = 0.3;
/// A new value for `Renderer::block_entity_revision`.
fn next_block_entity_revision() -> u64 {
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
@@ -209,6 +238,28 @@ struct SectionGpu {
sorted_indices: Option<(u32, u64)>,
/// Its id for flat draws (`flat::Flat::add`), when they are on.
flat_id: Option<u32>,
/// When it began to fade in over the far terrain it replaces, which
/// stays whole behind it until then: a section new where far tiles are
/// drawn. Meshed again, it stays as it was.
fading: Option<std::time::Instant>,
/// When it began to fade out, its column gone over far terrain that
/// stays behind it (`Renderer::leave_section`); released once it has.
leaving: Option<std::time::Instant>,
}
impl SectionGpu {
/// Its fade code this frame (`lod::fade_code`), 0 once whole. `retiring`:
/// its column has left the render distance, far terrain is coming in
/// under it (`lod::HELD`).
fn fade(&self, now: std::time::Instant, retiring: bool) -> u32 {
match self.leaving {
Some(since) => lod::fade_out_code(since, 0, now),
None if retiring => lod::HELD,
None => self
.fading
.and_then(|since| lod::fade_code(Some(since), 0, now))
.unwrap_or(0),
}
}
}
/// Line segments the hit outline can hold.
const MAX_OUTLINE_EDGES: u64 = 256;
@@ -339,6 +390,11 @@ pub struct Frame<'a> {
/// `Screen.extractBlurredBackground`: blur the frame by this radius
/// (`menuBackgroundBlurriness`) before the screen is drawn over it.
pub menu_blur: Option<f32>,
/// `Options.chunkSectionFadeInTime`, in seconds: how long a section takes to fade in once
/// uploaded (0 draws it whole at once).
pub chunk_fade: f32,
/// `GameRenderer.requestedPostEffects`: the chains that run over the world.
pub post_effects: crate::post_chain::Request<'a>,
}
/// One frame of the world border wall (`WorldBorderRenderer.render`).
#[derive(Clone, Debug)]
@@ -514,6 +570,8 @@ pub struct Renderer {
lod_cutout: crate::fast_hash::FxHashSet<(i32, i32)>,
/// Removed sections of `lod_cutout` columns, released once tiles cover them.
lod_retiring: crate::fast_hash::FxHashSet<SectionKey>,
/// Removed sections fading out over far terrain (`leave_section`).
lod_leaving: Vec<(SectionKey, std::time::Instant)>,
/// Finished tile meshes `upload_lods` has yet to upload.
lod_pending: Option<lod::Pending>,
detail_columns: crate::fast_hash::FxHashMap<(i32, i32), SectionMask>,
@@ -551,6 +609,9 @@ pub struct Renderer {
shadow_runs: page_runs::PageRuns,
/// Block-entity blocks per meshed section (world position, state id).
block_entities: crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
/// When each section was first uploaded (`RenderSection.uploadedTime`): kept across meshing
/// it again, gone with its column. Sections that fade in over far terrain are not in it.
section_uploaded: crate::fast_hash::FxHashMap<SectionKey, std::time::Instant>,
/// Changes whenever `block_entities` does (unique across renderers).
block_entity_revision: u64,
/// VisGraph face connectivity per meshed section (cave culling).
@@ -586,6 +647,8 @@ pub struct Renderer {
pub hud: HudRenderer,
/// Vanilla's menu background blur, made on first use.
menu_blur: Option<crate::menu_blur::MenuBlur>,
/// `post_effect` chains, made on first use.
post_effects: Option<crate::post_chain::PostEffects>,
/// Where a screenshot's frame is drawn when the swapchain cannot be read back.
readback: readback::Readback,
pub info: wgpu::AdapterInfo,
@@ -2796,6 +2859,7 @@ impl Renderer {
lods: HashMap::new(),
lod_cutout: Default::default(),
lod_retiring: Default::default(),
lod_leaving: Vec::new(),
lod_pending: None,
detail_columns: Default::default(),
detail_revision: 0,
@@ -2819,6 +2883,7 @@ impl Renderer {
phase2_runs: page_runs::PageRuns::default(),
shadow_runs: page_runs::PageRuns::default(),
block_entities: Default::default(),
section_uploaded: Default::default(),
block_entity_revision: next_block_entity_revision(),
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
layout: crate::chunk::ColumnLayout::VANILLA,
@@ -2836,6 +2901,7 @@ impl Renderer {
pack_terrain_flat: false,
hud,
menu_blur: None,
post_effects: None,
readback: readback::Readback::default(),
info,
suspended: !drawable_size(size.width, size.height, max_side),
@@ -3100,8 +3166,19 @@ impl Renderer {
if !self.lod_retiring.is_empty() {
self.lod_retiring.remove(&key);
}
let fading = match self.sections.find(key) {
Some(old) => old.fading,
None => (!self.lods.is_empty()).then(std::time::Instant::now),
};
if fading.is_none() {
self.section_uploaded
.entry(key)
.or_insert_with(std::time::Instant::now);
}
self.release_section(key);
let section = self.upload_mesh(mesh);
let section = self
.upload_mesh(mesh)
.map(|section| SectionGpu { fading, ..section });
self.set_detail(
key,
section.is_some() || (mesh.solid.is_empty() && mesh.translucent.is_empty()),
@@ -3191,6 +3268,8 @@ impl Renderer {
),
sorted_indices,
flat_id,
fading: None,
leaving: None,
})
}
/// The section copies recorded since the last submission, closed.
@@ -3255,6 +3334,28 @@ impl Renderer {
pub fn block_entities(&self) -> impl Iterator<Item = &([i32; 3], u32)> {
self.block_entities.values().flatten()
}
/// `block_entities` of the sections far enough into their fade-in to draw them
/// (`LevelExtractor.extractVisibleBlockEntities`: `getVisibility(now, fade) >= 0.3`).
pub fn visible_block_entities(
&self,
fade_seconds: f32,
) -> impl Iterator<Item = &([i32; 3], u32)> {
let fade = fade_millis(fade_seconds);
let now = std::time::Instant::now();
self.block_entities
.iter()
.filter(move |(key, _)| {
chunk_visibility(self.section_elapsed_ms(**key, now), fade)
>= CHUNK_VISIBILITY_THRESHOLD
})
.flat_map(|(_, blocks)| blocks)
}
/// Milliseconds since `key` was uploaded; a section not in `section_uploaded` is old.
fn section_elapsed_ms(&self, key: SectionKey, now: std::time::Instant) -> u64 {
self.section_uploaded.get(&key).map_or(u64::MAX, |t| {
now.saturating_duration_since(*t).as_millis() as u64
})
}
/// A value that changes whenever `block_entities` gives something else.
pub fn block_entity_revision(&self) -> u64 {
self.block_entity_revision
@@ -3262,11 +3363,13 @@ impl Renderer {
/// Drops every section, for a level whose chunks hold `layout`.
pub fn clear_sections(&mut self, layout: crate::chunk::ColumnLayout) {
self.block_entities.clear();
self.section_uploaded.clear();
self.block_entity_revision = next_block_entity_revision();
self.sections.clear();
self.lods.clear();
self.lod_cutout.clear();
self.lod_retiring.clear();
self.lod_leaving.clear();
self.lod_pending = None;
self.detail_columns.clear();
self.detail_revision += 1;
@@ -3325,6 +3428,40 @@ impl Renderer {
/// Every section of these columns goes: one lookup per column where the
/// tables are by column, and the meshes' ranges freed in one batch. A
/// teleport removes a few thousand columns in one frame.
/// A section whose column far terrain now draws fades out over it, chunk by
/// chunk as it came, and is released once it has (`release_left`): taken
/// away at once, the column's detail dropped to the far terrain's in one
/// frame.
fn leave_section(&mut self, key: SectionKey) {
let now = std::time::Instant::now();
if let Some(section) = self.sections.find_mut(key)
&& section.leaving.is_none()
{
section.leaving = Some(now);
self.lod_leaving.push((key, now));
}
}
/// Releases the sections that have faded out (`leave_section`).
fn release_left(&mut self, now: std::time::Instant) {
let mut done = Vec::new();
self.lod_leaving.retain(|&(key, since)| {
let left = now.saturating_duration_since(since) >= lod::FADE;
if left {
done.push((key, since));
}
!left
});
for (key, since) in done {
// Meshed again since (the player came back): it stays.
if self
.sections
.find(key)
.is_some_and(|s| s.leaving == Some(since))
{
self.release_section(key);
}
}
}
pub fn remove_columns(&mut self, columns: impl IntoIterator<Item = (i32, i32)>) {
let mut released = Vec::new();
let mut block_entities = false;
@@ -3337,9 +3474,13 @@ impl Renderer {
for sy in 0..self.layout.sections() as i32 {
let key = (x, sy, z);
block_entities |= self.block_entities.remove(&key).is_some();
self.section_uploaded.remove(&key);
if retiring {
// Distant tiles still leave its column out: drawn until they don't.
self.lod_retiring.insert(key);
} else if !self.lods.is_empty() && self.sections.contains(key) {
// Far terrain draws the column: it fades out over it.
self.leave_section(key);
} else if let Some(section) = self.sections.remove(key) {
released.push(section);
}
@@ -4059,6 +4200,13 @@ impl Renderer {
}
let reachable = &self.occlusion;
let cutout = &self.lod_cutout;
// Sections fading out (`leave_section`) are past the render distance:
// drawn there until they have, or they popped out at once.
let leaving: crate::fast_hash::FxHashSet<(i32, i32)> = self
.lod_leaving
.iter()
.map(|&((x, _, z), _)| (x, z))
.collect();
let mut visible = std::mem::take(&mut self.visible);
visible.clear();
crate::spans::time("visibility", || {
@@ -4067,10 +4215,14 @@ impl Renderer {
(frame.detail_distance / 16.0) as i32,
&view,
|k| !culling || reachable.contains(k),
|x, z| !cutout.is_empty() && cutout.contains(&(x, z)),
|x, z| {
(!cutout.is_empty() && cutout.contains(&(x, z)))
|| (!leaving.is_empty() && leaving.contains(&(x, z)))
},
&mut visible,
)
});
let now = std::time::Instant::now();
let mut visible_lods = std::mem::take(&mut self.visible_lods);
visible_lods.clear();
if frame.title {
@@ -4079,7 +4231,7 @@ impl Renderer {
visible_lods.extend(
self.lods
.iter()
.filter_map(|(&key, tile)| tile.visible(key, camera, frame.distance, &view)),
.filter_map(|(&key, tile)| tile.visible(key, camera, &view, now)),
);
visible_lods.sort_unstable_by_key(|v| (v.distance, v.key));
}
@@ -4132,12 +4284,34 @@ impl Renderer {
});
}
self.origins.clear();
let (sections, retiring) = (&self.sections, &self.lod_retiring);
// `LevelRenderer.extractSectionDrawGroups`: each section's `getVisibility`.
let fade_ms = fade_millis(frame.chunk_fade);
let uploaded = &self.section_uploaded;
let visibility = |key: SectionKey| {
if fade_ms == 0 {
return 1.0;
}
uploaded.get(&key).map_or(1.0, |t| {
chunk_visibility(
now.saturating_duration_since(*t).as_millis() as u64,
fade_ms,
)
})
};
self.origins.extend(
visible
.iter()
.map(|v| section_instance(v.origin))
.chain(visible_lods.iter().map(|v| lod::instance(v.origin)))
.chain(shadow_visible.iter().map(|v| section_instance(v.origin))),
.map(|v| {
let code = sections.get(v.slot).fade(now, retiring.contains(&v.key));
section_instance(v.origin, code, visibility(v.key))
})
.chain(visible_lods.iter().map(|v| lod::instance(v.origin, v.fade)))
.chain(
shadow_visible
.iter()
.map(|v| section_instance(v.origin, 0, visibility(v.key))),
),
);
if !self.origins.is_empty() {
self.uploads.buffer(
@@ -4228,7 +4402,7 @@ impl Renderer {
let tiles = self.draws.len();
self.solid_runs.clear();
for (i, v) in visible_lods.iter().enumerate() {
let s = &self.lods[&v.key].mesh;
let s = self.lods[&v.key].drawn();
let start = self.draws.len();
let instance = (visible.len() + i) as u32;
facing_runs(&s.sides, eye, v.origin, |first, quads| {
@@ -4297,7 +4471,7 @@ impl Renderer {
for (i, v) in visible_lods.iter().enumerate() {
let instance = visible.len() + i;
let tile = self.lods.get_mut(&v.key).expect("visible resident tile");
let s = &mut tile.mesh;
let s = tile.drawn_mut();
if let Some((index_page, index_start)) = s.sorted_indices {
let scheduled = (instance + count - self.translucency_cursor) % count < budget;
if (!s.sort.is_sorted() || scheduled)
@@ -4331,8 +4505,26 @@ impl Renderer {
}
// Near water and distant water share one back-to-front draw order.
if self.translucent_draws.len() > section_translucent {
// Distant water goes after the near chunks', and replaced tiles'
// after all: each is a hair behind the one before, so depth leaves
// it only where that one has none. Back to front together, a far
// tile's water under a near chunk's (one fading in, or brought
// into view by a longer render distance) blended twice and looked
// opaque. The cost: far water just behind near water shows no
// surface of its own there.
let tier = |instance: usize| match instance.checked_sub(visible.len()) {
None => 0,
Some(tile) if visible_lods[tile].old => 2,
Some(_) => 1,
};
self.translucent_draws
.sort_unstable_by(|a, b| b.0.cmp(&a.0).then(b.1.cmp(&a.1)));
.sort_unstable_by_key(|&(distance, instance, ..)| {
(
tier(instance),
std::cmp::Reverse(distance),
std::cmp::Reverse(instance),
)
});
}
for &(_, _, draw, pages) in &self.translucent_draws {
self.draws.push(draw);
@@ -4704,7 +4896,7 @@ impl Renderer {
|| pack_terrain
|| shadow_view.is_some()) =>
{
let sections = &self.sections;
let (sections, retiring) = (&self.sections, &self.lod_retiring);
let lods = &self.lods;
let sorted = &self.sorted_indices;
let phase2 = self.cull.as_ref().filter(|_| culling).map(|c| &c.phase2);
@@ -4723,12 +4915,16 @@ impl Renderer {
.iter()
.chain(&shadow_visible)
.filter_map(|v| {
let id = sections.get(v.slot).flat_id?;
Some((id, section_instance(v.origin)))
let section = sections.get(v.slot);
let code = section.fade(now, retiring.contains(&v.key));
Some((
section.flat_id?,
section_instance(v.origin, code, visibility(v.key)),
))
})
.chain(visible_lods.iter().filter_map(|v| {
let id = lods[&v.key].mesh.flat_id?;
Some((id, lod::instance(v.origin)))
let id = lods[&v.key].drawn().flat_id?;
Some((id, lod::instance(v.origin, v.fade)))
})),
|page| sorted.buffer(page).clone(),
)
@@ -5420,6 +5616,26 @@ impl Renderer {
.screenshot
.take_if(|request| request.size.is_some())
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture));
// GameRenderer.render: applyPostEffects, after the level and before the GUI.
if !frame.title
&& !frame.post_effects.effects.is_empty()
&& target_texture
.usage()
.contains(wgpu::TextureUsages::COPY_SRC)
{
let device = &self.device;
self.post_effects
.get_or_insert_with(|| crate::post_chain::PostEffects::new(device))
.apply(
&self.device,
&self.queue,
&mut encoder,
&target_texture,
&view_target,
[self.config.width, self.config.height],
&frame.post_effects,
);
}
if !frame.screen.is_empty() {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view_target,
@@ -5634,7 +5850,7 @@ fn padded_row(width: u32) -> u32 {
(width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
}
/// Encodes the copy of `texture`'s `size` pixels into a mappable buffer.
fn copy_frame(
pub(crate) fn copy_frame(
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
texture: &wgpu::Texture,
@@ -6137,6 +6353,36 @@ mod tests {
.unwrap();
}
#[test]
fn chunk_fade_is_vanillas_get_visibility() {
use super::{chunk_visibility, fade_millis};
assert_eq!(fade_millis(0.75), 750);
assert_eq!(fade_millis(0.0), 0);
assert_eq!(chunk_visibility(0, 750), 0.0_f32);
assert_eq!(chunk_visibility(375, 750), 0.5);
assert_eq!(chunk_visibility(750, 750), 1.0);
assert_eq!(chunk_visibility(0, 0), 1.0, "Off: whole at once");
assert_eq!(chunk_visibility(u64::MAX, 750), 1.0);
}
#[test]
fn section_instance_carries_the_fade_in_the_step() {
use super::section_instance;
let step = 1.0 / crate::mesher::UV_STEPS;
// Fully visible (the option Off) is exactly the record from before the fade.
assert_eq!(
section_instance([1.0, 2.0, 3.0], 0, 1.0),
[1.0, 2.0, 3.0, step]
);
for visibility in [0.0_f32, 0.25, 0.5, 0.999] {
let w = section_instance([0.0; 3], 0, visibility)[3];
let bits = w.to_bits();
assert_eq!(f32::from_bits(bits & 0xff80_0000), step);
let read = 1.0 - (bits & 0x7f_ffff) as f32 / 8_388_608.0;
assert!((read - visibility).abs() < 1e-6, "{visibility}: {read}");
}
// A section fading in over far terrain keeps its dither code unchanged.
assert_eq!(section_instance([0.0; 3], 7, 0.5)[3], -(7.0 + step));
}
#[test]
fn terrain_attributes_follow_the_terrain_vertex() {
use super::TerrainVertex;
use std::mem::offset_of;
@@ -0,0 +1,7 @@
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
//! two real ones. So far this holds its optical flow; the interpolation and the setting
//! that runs them come next.
// Unused until the interpolation and the renderer use it.
#[allow(dead_code)]
mod optical_flow;
@@ -0,0 +1,653 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions :
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//! FSR 3.1's optical flow (AMD FidelityFX SDK 1.1.4, `ffx_opticalflow*`, MIT), ported to
//! WGSL (`optical_flow.wgsl`): block motion between the previous frame's colour and this
//! one's, and scene change detection, for frame interpolation (`interpolation.rs`).
//!
//! As in `ffx_opticalflow.cpp`: luma at the display size and 6 halvings, ping-ponged
//! between frames; a histogram of the luma for scene change detection; then from the
//! coarsest of 7 levels down, a block search seeded by the level above, a 3x3 filter and
//! a scale to the next level. The ported passes differ only where WGSL needs it: the luma
//! levels are `R32Float` holding AMD's 8-bit integers, wave operations are workgroup
//! atomics, and the luma pyramid is one dispatch per level instead of one for all (SPD).
//! The luma textures aren't cleared on a reset (AMD's are): its first 6 frames report a
//! scene change and no motion, and rewrite them.
//!
//! The vectors point from a block of this frame to where it was in the previous one, in
//! display pixels: content moving right by 4 pixels gives (-4, 0).
/// The size in pixels of the square blocks the flow has one vector for.
pub const BLOCK_SIZE: u32 = 8;
/// `OpticalFlowMaxPyramidLevels`: the search's levels.
const LEVELS: usize = 7;
/// `HistogramBins * HistogramsPerDim²`: the scene change histograms.
const HISTOGRAM_WIDTH: u64 = 256 * 9;
/// `optical_flow.wgsl`'s `Params`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
display: [i32; 2],
level: u32,
level_count: u32,
frame_index: u32,
transfer_function: u32,
min_max_luminance: [f32; 2],
}
struct Pipelines {
prepare_luma: wgpu::ComputePipeline,
luma_pyramid: wgpu::ComputePipeline,
scd_histogram: wgpu::ComputePipeline,
scd_divergence: wgpu::ComputePipeline,
search: wgpu::ComputePipeline,
filter: wgpu::ComputePipeline,
scale: wgpu::ComputePipeline,
}
/// One frame parity's bindings: which luma pyramid is this frame's.
struct Frame {
/// This frame's luma at the display size, where `prepare_luma` writes.
luma0: wgpu::TextureView,
luma_pyramid: [wgpu::BindGroup; LEVELS - 1],
scd_histogram: wgpu::BindGroup,
search: [wgpu::BindGroup; LEVELS],
scale: [wgpu::BindGroup; LEVELS - 1],
}
pub struct OpticalFlow {
display: [u32; 2],
pipelines: Pipelines,
/// One per level, the level's constants (the passes outside the search use level 0's).
params: Vec<wgpu::Buffer>,
/// The bindings of the frames whose luma lands in pyramid 0, and in pyramid 1.
frames: [Frame; 2],
filter: [wgpu::BindGroup; LEVELS],
scd_divergence: wgpu::BindGroup,
scd_histogram: wgpu::Buffer,
scd_previous_histogram: wgpu::Buffer,
scd_temp: wgpu::Buffer,
scd_output: wgpu::Buffer,
vectors: wgpu::TextureView,
flow_sizes: [[u32; 2]; LEVELS],
/// `resourceFrameIndex` and `frameIndex`.
resource_frame: u32,
frame_index: u32,
first: bool,
}
fn texture(
device: &wgpu::Device,
label: &str,
size: [u32; 2],
format: wgpu::TextureFormat,
) -> wgpu::TextureView {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size[0].max(1),
height: size[1].max(1),
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::STORAGE_BINDING
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default())
}
fn buffer(device: &wgpu::Device, label: &str, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST
| wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
})
}
/// Bind `entries` (binding number, resource) for `pipeline`'s own layout.
fn bind(
device: &wgpu::Device,
pipeline: &wgpu::ComputePipeline,
entries: &[(u32, wgpu::BindingResource)],
) -> wgpu::BindGroup {
let entries: Vec<_> = entries
.iter()
.map(|(binding, resource)| wgpu::BindGroupEntry {
binding: *binding,
resource: resource.clone(),
})
.collect();
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Optical flow"),
layout: &pipeline.get_bind_group_layout(0),
entries: &entries,
})
}
fn view(v: &wgpu::TextureView) -> wgpu::BindingResource<'_> {
wgpu::BindingResource::TextureView(v)
}
fn whole(b: &wgpu::Buffer) -> wgpu::BindingResource<'_> {
b.as_entire_binding()
}
impl OpticalFlow {
/// For frames of `display` size.
pub fn new(device: &wgpu::Device, display: [u32; 2]) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Optical flow"),
source: wgpu::ShaderSource::Wgsl(include_str!("optical_flow.wgsl").into()),
});
let pipeline = |entry: &str| {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some(entry),
layout: None,
module: &module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
})
};
let pipelines = Pipelines {
prepare_luma: pipeline("prepare_luma"),
luma_pyramid: pipeline("luma_pyramid"),
scd_histogram: pipeline("scd_histogram_pass"),
scd_divergence: pipeline("scd_divergence_pass"),
search: pipeline("search_pass"),
filter: pipeline("filter_flow"),
scale: pipeline("scale_pass"),
};
let params: Vec<_> = (0..LEVELS)
.map(|_| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Optical flow constants"),
size: std::mem::size_of::<Params>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
})
.collect();
let luma: [Vec<wgpu::TextureView>; 2] = [0, 1].map(|_| {
(0..LEVELS)
.map(|k| {
let size = display.map(|v| (v >> k).max(1));
texture(
device,
"Optical flow luma",
size,
wgpu::TextureFormat::R32Float,
)
})
.collect()
});
let mut flow_sizes = [[0; 2]; LEVELS];
flow_sizes[0] = display.map(|v| v.div_ceil(BLOCK_SIZE));
for l in 1..LEVELS {
flow_sizes[l] = flow_sizes[l - 1].map(|v| v.div_ceil(2));
}
let flow = |label| -> Vec<wgpu::TextureView> {
flow_sizes
.iter()
.map(|&size| texture(device, label, size, wgpu::TextureFormat::Rg32Sint))
.collect()
};
// Per level: the scaled prediction from the level above, the search's vectors and
// the filtered ones (level 0's are the output).
let (predicted, searched, filtered) = (
flow("Optical flow prediction"),
flow("Optical flow search"),
flow("Optical flow"),
);
let scd_histogram = buffer(device, "Scene change histogram", HISTOGRAM_WIDTH * 4);
let scd_previous_histogram = buffer(
device,
"Scene change previous histogram",
HISTOGRAM_WIDTH * 4,
);
let scd_temp = buffer(device, "Scene change temp", 12);
let scd_output = buffer(device, "Scene change detection", 12);
let frame = |current: usize| {
let (cur, prev) = (&luma[current], &luma[1 - current]);
Frame {
luma0: cur[0].clone(),
luma_pyramid: std::array::from_fn(|k| {
bind(
device,
&pipelines.luma_pyramid,
&[(2, view(&cur[k + 1])), (3, view(&cur[k]))],
)
}),
scd_histogram: bind(
device,
&pipelines.scd_histogram,
&[
(0, whole(&params[0])),
(3, view(&cur[0])),
(7, whole(&scd_histogram)),
],
),
search: std::array::from_fn(|l| {
bind(
device,
&pipelines.search,
&[
(0, whole(&params[l])),
(3, view(&cur[l])),
(4, view(&prev[l])),
(5, view(&predicted[l])),
(6, view(&searched[l])),
(10, whole(&scd_output)),
],
)
}),
scale: std::array::from_fn(|i| {
let l = i + 1;
bind(
device,
&pipelines.scale,
&[
(0, whole(&params[l])),
(3, view(&cur[l])),
(4, view(&prev[l])),
(5, view(&filtered[l])),
(6, view(&predicted[l - 1])),
(10, whole(&scd_output)),
],
)
}),
}
};
let frames = [frame(0), frame(1)];
let filter = std::array::from_fn(|l| {
bind(
device,
&pipelines.filter,
&[(5, view(&searched[l])), (6, view(&filtered[l]))],
)
});
let scd_divergence = bind(
device,
&pipelines.scd_divergence,
&[
(7, whole(&scd_histogram)),
(8, whole(&scd_previous_histogram)),
(9, whole(&scd_temp)),
(10, whole(&scd_output)),
],
);
Self {
display,
pipelines,
params,
frames,
filter,
scd_divergence,
scd_histogram,
scd_previous_histogram,
scd_temp,
scd_output,
vectors: filtered[0].clone(),
flow_sizes,
resource_frame: 0,
frame_index: 0,
first: true,
}
}
/// The flow from the previous call's `color` to this one's. `color`: `display` size,
/// a float texture holding Horizon's frame (gamma-encoded RGB, the swapchain's
/// values: AMD's SDR back buffer). `reset`: the previous frame is unrelated (first
/// frame, resize, teleport).
pub fn dispatch(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
reset: bool,
) {
let reset = reset || self.first;
self.first = false;
self.frame_index = if reset { 0 } else { self.frame_index + 1 };
if reset {
for b in [
&self.scd_temp,
&self.scd_output,
&self.scd_histogram,
&self.scd_previous_histogram,
] {
encoder.clear_buffer(b, 0, None);
}
}
for (level, buffer) in self.params.iter().enumerate() {
let params = Params {
display: self.display.map(|v| v as i32),
level: level as u32,
level_count: LEVELS as u32,
frame_index: self.frame_index,
transfer_function: 0,
min_max_luminance: [0.0, 1.0],
};
queue.write_buffer(buffer, 0, bytemuck::bytes_of(&params));
}
let frame = &self.frames[(self.resource_frame & 1) as usize];
let prepare = bind(
device,
&self.pipelines.prepare_luma,
&[
(0, whole(&self.params[0])),
(1, view(color)),
(2, view(&frame.luma0)),
],
);
let p = &self.pipelines;
let [w, h] = self.display;
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Optical flow"),
timestamp_writes: None,
});
let mut run = |pipeline: &wgpu::ComputePipeline, group: &wgpu::BindGroup, x, y, z| {
pass.set_pipeline(pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(x, y, z);
};
run(
&p.prepare_luma,
&prepare,
(w.div_ceil(2)).div_ceil(16),
(h.div_ceil(2)).div_ceil(16),
1,
);
for (k, group) in frame.luma_pyramid.iter().enumerate() {
let size = self.display.map(|v| (v >> (k + 1)).max(1));
run(
&p.luma_pyramid,
group,
size[0].div_ceil(8),
size[1].div_ceil(8),
1,
);
}
run(
&p.scd_histogram,
&frame.scd_histogram,
((w / 4) / 3).div_ceil(32),
16,
9,
);
run(&p.scd_divergence, &self.scd_divergence, 9, 3, 1);
for level in (0..LEVELS).rev() {
let luma = self.display.map(|v| (v >> level).max(1));
run(
&p.search,
&frame.search[level],
(luma[0].div_ceil(4) * 16).div_ceil(64),
luma[1].div_ceil(16),
1,
);
let size = self.flow_sizes[level];
run(
&p.filter,
&self.filter[level],
size[0].div_ceil(16),
size[1].div_ceil(4),
1,
);
if level > 0 {
let next = self.flow_sizes[level - 1];
run(
&p.scale,
&frame.scale[level - 1],
next[0].div_ceil(4),
next[1].div_ceil(4),
1,
);
}
}
drop(pass);
self.resource_frame = (self.resource_frame + 1) % 16;
}
/// This frame's vectors, one per `BLOCK_SIZE` block: `Rg32Sint`, the display size
/// over `BLOCK_SIZE` rounded up,
/// holding the values FSR's `R16G16_SINT` optical flow vector texture holds.
pub fn vectors(&self) -> &wgpu::TextureView {
&self.vectors
}
/// FSR's scene change detection texture (`R32_UINT`, 3 texels) as a storage
/// buffer of 3 `u32`: element `i` holds texel `i`'s value.
pub fn scene_change(&self) -> &wgpu::Buffer {
&self.scd_output
}
}
#[cfg(test)]
pub(super) mod tests {
use super::*;
#[test]
fn shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("optical_flow.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap();
}
/// A device with WebGPU's default limits (what every GPU wgpu runs on has), or `None`
/// without a GPU.
pub 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()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// Noise in 4x4 pixel cells, moved by `shift` pixels: what was at `p` is at `p + shift`.
/// A `seed` of 100 or more gives cells of one primary each.
pub fn pattern(size: [u32; 2], seed: u32, shift: [i32; 2]) -> Vec<u8> {
let mut pixels = Vec::with_capacity((size[0] * size[1] * 4) as usize);
for y in 0..size[1] as i32 {
for x in 0..size[0] as i32 {
let (cx, cy) = ((x - shift[0]).div_euclid(4), (y - shift[1]).div_euclid(4));
let mut h = (cx as u32).wrapping_mul(0x9e37_79b9)
^ (cy as u32).wrapping_mul(0x85eb_ca6b)
^ seed.wrapping_mul(0xc2b2_ae35);
h ^= h >> 15;
h = h.wrapping_mul(0x2c1b_3c6d);
h ^= h >> 12;
let v = (h & 0xff) as u8;
if seed >= 100 {
// Primaries: no two neighbours' colours point the same way.
let mut c = [0, 0, 0, 255];
c[(h >> 8) as usize % 3] = 128 + v / 2;
pixels.extend_from_slice(&c);
} else {
pixels.extend_from_slice(&[v, v.wrapping_add(40), v / 2, 255]);
}
}
}
pixels
}
pub fn upload(
device: &wgpu::Device,
queue: &wgpu::Queue,
size: [u32; 2],
pixels: &[u8],
) -> wgpu::TextureView {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
texture.as_image_copy(),
pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size[0] * 4),
rows_per_image: None,
},
texture.size(),
);
texture.create_view(&Default::default())
}
/// `texture`'s texels (`texel` bytes each), row by row.
pub fn read(
device: &wgpu::Device,
queue: &wgpu::Queue,
texture: &wgpu::Texture,
texel: u32,
) -> Vec<u8> {
let size = texture.size();
let row = (size.width * texel).next_multiple_of(256);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(row * size.height),
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_texture_to_buffer(
texture.as_image_copy(),
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(row),
rows_per_image: None,
},
},
size,
);
queue.submit([encoder.finish()]);
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let mapped = buffer.slice(..).get_mapped_range().unwrap();
mapped
.chunks(row as usize)
.flat_map(|r| r[..(size.width * texel) as usize].to_vec())
.collect()
}
fn run(
device: &wgpu::Device,
queue: &wgpu::Queue,
flow: &mut OpticalFlow,
color: &wgpu::TextureView,
reset: bool,
) {
let mut encoder = device.create_command_encoder(&Default::default());
flow.dispatch(device, queue, &mut encoder, color, reset);
queue.submit([encoder.finish()]);
}
fn vectors(device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow) -> Vec<[i32; 2]> {
read(device, queue, flow.vectors.texture(), 8)
.chunks(8)
.map(|c| {
[
i32::from_le_bytes(c[0..4].try_into().unwrap()),
i32::from_le_bytes(c[4..8].try_into().unwrap()),
]
})
.collect()
}
/// A frame moved right by 16 and down by 8: the blocks inside report where they were
/// (-16, -8), and nothing reads as a scene change. A much darker frame then does.
#[test]
fn finds_a_shift_and_a_scene_change() {
let Some((device, queue)) = device() else {
return;
};
let size = [512, 288];
let still = upload(&device, &queue, size, &pattern(size, 1, [0, 0]));
let moved = upload(&device, &queue, size, &pattern(size, 1, [16, 8]));
// Scene changes are told by the luma's histogram: a much darker frame.
let dark: Vec<u8> = pattern(size, 2, [0, 0]).iter().map(|v| v / 5).collect();
let other = upload(&device, &queue, size, &dark);
let mut flow = OpticalFlow::new(&device, size);
// The first 6 frames after a reset are scene changes.
run(&device, &queue, &mut flow, &still, true);
for _ in 0..6 {
run(&device, &queue, &mut flow, &still, false);
}
run(&device, &queue, &mut flow, &moved, false);
let [w, h] = flow.flow_sizes[0];
let found = vectors(&device, &queue, &flow);
let inside: Vec<_> = (4..h - 4)
.flat_map(|y| (4..w - 4).map(move |x| (x, y)))
.map(|(x, y)| found[(y * w + x) as usize])
.collect();
let right = inside.iter().filter(|v| **v == [-16, -8]).count();
assert!(
right * 10 >= inside.len() * 9,
"{right} of {} blocks: {:?}",
inside.len(),
&inside[..8]
);
let scd = |device: &wgpu::Device, queue: &wgpu::Queue, flow: &OpticalFlow| {
let mut encoder = device.create_command_encoder(&Default::default());
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: 12,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(flow.scene_change(), 0, &buffer, 0, 12);
queue.submit([encoder.finish()]);
buffer.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let bytes = buffer.slice(..).get_mapped_range().unwrap().to_vec();
u32::from_le_bytes(bytes[4..8].try_into().unwrap())
};
assert_eq!(scd(&device, &queue, &flow) & 0xf, 0);
run(&device, &queue, &mut flow, &other, false);
assert_eq!(scd(&device, &queue, &flow) & 1, 1);
assert!(vectors(&device, &queue, &flow).iter().all(|v| *v == [0, 0]));
}
}
@@ -0,0 +1,537 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions :
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
// FidelityFX SDK 1.1.4's optical flow (ffx_opticalflow_*.h, the GLSL path), ported to WGSL
// for Horizon. What differs from AMD's (optical_flow.rs lists why):
// - luma levels are r32float (AMD: r8ui); loads truncate to the same integers;
// - the luma pyramid is one pass per level instead of SPD's single pass;
// - wave sums and minimums are workgroup atomics, and the reductions AMD leaves to a
// wave's lockstep have barriers;
// - the scene change textures are storage buffers;
// - the search reads its prediction from one texture and writes another (rg32sint can't
// be read_write everywhere).
struct Params {
// Display size (cbOF.iInputLumaResolution).
display: vec2<i32>,
level: u32,
level_count: u32,
frame_index: u32,
transfer_function: u32,
min_max_luminance: vec2<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var input_color: texture_2d<f32>;
@group(0) @binding(2) var luma_out: texture_storage_2d<r32float, write>;
// The current frame's luma at this level, and the previous frame's.
@group(0) @binding(3) var luma_in: texture_2d<f32>;
@group(0) @binding(4) var luma_previous: texture_2d<f32>;
// Search: the prediction from the level above; filter: the search; scale: the filtered.
@group(0) @binding(5) var flow_in: texture_2d<i32>;
@group(0) @binding(6) var flow_out: texture_storage_2d<rg32sint, write>;
@group(0) @binding(7) var<storage, read_write> scd_histogram: array<atomic<u32>>;
@group(0) @binding(8) var<storage, read_write> scd_previous_histogram: array<f32>;
@group(0) @binding(9) var<storage, read_write> scd_temp: array<atomic<u32>, 3>;
@group(0) @binding(10) var<storage, read_write> scd_output: array<atomic<u32>, 3>;
const SCD_OUTPUT_SCENE_CHANGE_SLOT: u32 = 0u;
const SCD_OUTPUT_HISTORY_BITS_SLOT: u32 = 1u;
const SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT: u32 = 2u;
fn load_luma(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
let size = vec2<i32>(textureDimensions(t));
if any(p < vec2(0)) || any(p >= size) {
return 0u;
}
return u32(textureLoad(t, p, 0).x);
}
fn load_flow(p: vec2<i32>) -> vec2<i32> {
let size = vec2<i32>(textureDimensions(flow_in));
if any(p < vec2(0)) || any(p >= size) {
return vec2(0);
}
return textureLoad(flow_in, p, 0).xy;
}
fn store_flow(p: vec2<i32>, v: vec2<i32>) {
let size = vec2<i32>(textureDimensions(flow_out));
if all(p >= vec2(0)) && all(p < size) {
textureStore(flow_out, p, vec4(v, 0, 0));
}
}
fn packed_luma(width: i32, x: i32, luma0: u32, luma1: u32, luma2: u32, luma3: u32) -> u32 {
var packed = luma0 | (luma1 << 8u) | (luma2 << 16u) | (luma3 << 24u);
if x < 0 {
let filler = packed & 0xffu;
if x <= -1 {
packed = (packed << 8u) | filler;
}
if x <= -2 {
packed = (packed << 8u) | filler;
}
if x <= -3 {
packed = (packed << 8u) | filler;
}
} else if x > width - 4 {
let filler = packed & 0xff000000u;
if x >= width - 3 {
packed = (packed >> 8u) | filler;
}
if x >= width - 2 {
packed = (packed >> 8u) | filler;
}
if x >= width - 1 {
packed = (packed >> 8u) | filler;
}
}
return packed;
}
fn load_packed(t: texture_2d<f32>, p: vec2<i32>) -> u32 {
let width = params.display.x >> params.level;
let height = params.display.y >> params.level;
let q = vec2(clamp(p.x, 0, width - 4), clamp(p.y, 0, height - 1));
return packed_luma(
width,
p.x,
load_luma(t, q),
load_luma(t, q + vec2(1, 0)),
load_luma(t, q + vec2(2, 0)),
load_luma(t, q + vec2(3, 0)),
);
}
fn sad(a: u32, b: u32) -> u32 {
return u32(abs(i32(a & 0xffu) - i32(b & 0xffu))
+ abs(i32((a >> 8u) & 0xffu) - i32((b >> 8u) & 0xffu))
+ abs(i32((a >> 16u) & 0xffu) - i32((b >> 16u) & 0xffu))
+ abs(i32((a >> 24u) & 0xffu) - i32((b >> 24u) & 0xffu)));
}
fn qsad(a0_in: u32, a1_in: u32, b: u32) -> vec4<u32> {
var a0 = a0_in;
var a1 = a1_in;
var s: vec4<u32>;
s.x = sad(a0, b);
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
a1 = a1 >> 8u;
s.y = sad(a0, b);
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
a1 = a1 >> 8u;
s.z = sad(a0, b);
a0 = (a0 >> 8u) | ((a1 & 0xffu) << 24u);
s.w = sad(a0, b);
return s;
}
fn is_scene_changed() -> bool {
if params.frame_index <= 5u {
return true;
}
return (atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) & 0xfu) != 0u;
}
// ffx_opticalflow_prepare_luma.h, for an SDR back buffer (transfer function 0: Horizon's
// frames); the HDR transfer functions follow AMD's.
fn luminance_to_perceived(luminance: f32) -> f32 {
var perceived: f32;
if luminance <= 216.0 / 24389.0 {
perceived = luminance * (24389.0 / 27.0);
} else {
perceived = pow(luminance, 1.0 / 3.0) * 116.0 - 16.0;
}
return perceived * 0.01;
}
fn linear_from_pq(x: vec3<f32>) -> vec3<f32> {
let p = pow(x, vec3(0.0126833));
return pow(clamp(p - vec3(0.835938), vec3(0.0), vec3(1.0)) / (vec3(18.8516) - 18.6875 * p), vec3(6.27739));
}
@compute @workgroup_size(16, 16)
fn prepare_luma(@builtin(global_invocation_id) gid: vec3<u32>) {
for (var y = 0; y < 2; y++) {
for (var x = 0; x < 2; x++) {
let pos = vec2<i32>(gid.xy) * 2 + vec2(x, y);
if any(pos >= params.display) {
continue;
}
let c = textureLoad(input_color, pos, 0).rgb;
var y_lum = 0.0;
if params.transfer_function == 0u {
y_lum = dot(c, vec3(0.2126, 0.7152, 0.0722));
} else if params.transfer_function == 1u {
let linear = linear_from_pq(c) * (10000.0 / params.min_max_luminance.y);
y_lum = luminance_to_perceived(dot(linear, vec3(0.2627, 0.678, 0.0593)));
} else if params.transfer_function == 2u {
let mn = params.min_max_luminance.x;
let mx = params.min_max_luminance.y;
let linear = (c - vec3(mn / 80.0)) / vec3((mx - mn) / 80.0);
y_lum = luminance_to_perceived(dot(linear, vec3(0.2126, 0.7152, 0.0722)));
}
textureStore(luma_out, pos, vec4(f32(u32(y_lum * 255.0)), 0.0, 0.0, 0.0));
}
}
}
// ffx_opticalflow_compute_luminance_pyramid.h: SPD's average of 2x2, a level per pass.
@compute @workgroup_size(8, 8)
fn luma_pyramid(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
if any(p >= vec2<i32>(textureDimensions(luma_out))) {
return;
}
let s = p * 2;
let v = (textureLoad(luma_in, s, 0).x + textureLoad(luma_in, s + vec2(1, 0), 0).x
+ textureLoad(luma_in, s + vec2(0, 1), 0).x + textureLoad(luma_in, s + vec2(1, 1), 0).x) * 0.25;
textureStore(luma_out, p, vec4(v, 0.0, 0.0, 0.0));
}
// ffx_opticalflow_generate_scd_histogram.h
const LBASE: u32 = 10u;
var<workgroup> scd_buffer: array<atomic<u32>, 2560>;
@compute @workgroup_size(32, 8)
fn scd_histogram_pass(
@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_index) local_index: u32,
) {
let histograms_per_dim = 3u;
let div = vec2<u32>(params.display) / histograms_per_dim;
let nx = gid.z % histograms_per_dim;
let ny = gid.z / histograms_per_dim;
let start = div * vec2(nx, ny);
let stop = start + div;
let offset = local_index * LBASE;
for (var i = 0u; i < LBASE; i++) {
atomicStore(&scd_buffer[offset + i], 0u);
}
workgroupBarrier();
var coord = vec2<i32>(i32(start.x + 4u * gid.x), i32(start.y + gid.y));
if u32(coord.x) < stop.x {
for (; u32(coord.y) < stop.y; coord.y += 128) {
let color = vec4(
load_luma(luma_in, coord),
load_luma(luma_in, coord + vec2(1, 0)),
load_luma(luma_in, coord + vec2(2, 0)),
load_luma(luma_in, coord + vec2(3, 0)),
) * LBASE;
let scrambling = local_index % LBASE;
atomicAdd(&scd_buffer[color.x + scrambling], 1u);
atomicAdd(&scd_buffer[color.y + scrambling], 1u);
atomicAdd(&scd_buffer[color.z + scrambling], 1u);
atomicAdd(&scd_buffer[color.w + scrambling], 1u);
}
}
workgroupBarrier();
var value = 0u;
for (var i = 0u; i < LBASE; i++) {
value += atomicLoad(&scd_buffer[offset + i]);
}
let histogram_start = 256u * gid.z;
atomicAdd(&scd_histogram[histogram_start + local_index], value);
}
// ffx_opticalflow_compute_scd_divergence.h
var<workgroup> source_histogram: array<f32, 256>;
var<workgroup> filtered_histogram: array<f32, 256>;
var<workgroup> temp_buffer: array<f32, 256>;
var<workgroup> temp_buffer2: array<vec2<f32>, 256>;
@compute @workgroup_size(256)
fn scd_divergence_pass(
@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_index) local_index: u32,
) {
let factor = 1000000.0;
let where_to_stop = 3u * 9u - 1u;
let histogram_count = 9.0;
let kernel = array(0.0088122291, 0.027143577, 0.065114059, 0.12164907, 0.17699835, 0.20056541);
let li = i32(local_index);
source_histogram[li] = f32(atomicLoad(&scd_histogram[gid.x]));
workgroupBarrier();
let read = li - 5;
var val = 0.0;
val += kernel[0] * source_histogram[clamp(read + 0, 0, 255)];
val += kernel[1] * source_histogram[clamp(read + 1, 0, 255)];
val += kernel[2] * source_histogram[clamp(read + 2, 0, 255)];
val += kernel[3] * source_histogram[clamp(read + 3, 0, 255)];
val += kernel[4] * source_histogram[clamp(read + 4, 0, 255)];
val += kernel[5] * source_histogram[clamp(read + 5, 0, 255)];
val += kernel[4] * source_histogram[clamp(read + 6, 0, 255)];
val += kernel[3] * source_histogram[clamp(read + 7, 0, 255)];
val += kernel[2] * source_histogram[clamp(read + 8, 0, 255)];
val += kernel[1] * source_histogram[clamp(read + 9, 0, 255)];
val += kernel[0] * source_histogram[clamp(read + 10, 0, 255)];
val += 1.0;
if gid.y == 0u {
if li == 0 {
filtered_histogram[255] = 1.0;
} else {
filtered_histogram[li - 1] = val;
}
} else if gid.y == 1u {
filtered_histogram[li] = val;
} else if gid.y == 2u {
if li == 255 {
filtered_histogram[0] = 1.0;
} else {
filtered_histogram[li + 1] = val;
}
}
workgroupBarrier();
temp_buffer[li] = filtered_histogram[li];
workgroupBarrier();
for (var stride = 128; stride > 0; stride >>= 1u) {
if li < stride {
temp_buffer[li] += temp_buffer[li + stride];
}
workgroupBarrier();
}
filtered_histogram[li] /= temp_buffer[0];
let current = filtered_histogram[li];
let previous = scd_previous_histogram[gid.x];
temp_buffer2[li] = vec2(current * log(current / previous), previous * log(previous / current));
workgroupBarrier();
for (var stride = 128; stride > 0; stride >>= 1u) {
if li < stride {
temp_buffer2[li] += temp_buffer2[li + stride];
}
workgroupBarrier();
}
if li == 0 {
let sum = temp_buffer2[0];
let res_float = 1.0 - exp(-(abs(sum.x) + abs(sum.y)));
let res_uint = u32((res_float / histogram_count) * factor);
atomicAdd(&scd_temp[gid.y], res_uint);
let finished = atomicAdd(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 1u);
if finished == where_to_stop {
let res0 = atomicLoad(&scd_temp[0]);
let res1 = atomicLoad(&scd_temp[1]);
let res2 = atomicLoad(&scd_temp[2]);
let scene_change = f32(min(res0, min(res1, res2))) / factor;
var history = atomicLoad(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT]) << 1u;
if scene_change > 0.45 {
history |= 1u;
}
atomicStore(&scd_output[SCD_OUTPUT_SCENE_CHANGE_SLOT], bitcast<u32>(scene_change));
atomicStore(&scd_output[SCD_OUTPUT_HISTORY_BITS_SLOT], history);
atomicStore(&scd_output[SCD_OUTPUT_COMPLETED_WORKGROUPS_SLOT], 0u);
atomicStore(&scd_temp[0], 0u);
atomicStore(&scd_temp[1], 0u);
atomicStore(&scd_temp[2], 0u);
}
}
if gid.y == 1u {
scd_previous_histogram[gid.x] = current;
atomicStore(&scd_histogram[gid.x], 0u);
}
}
// ffx_opticalflow_compute_optical_flow_v5.h: block matching of 8x8 blocks over +-8 pixels,
// 2x2 blocks per workgroup of 64.
var<workgroup> pixels: array<array<u32, 2>, 8>;
// SearchBufferSizeY (24) rows of SearchBufferSizeX (6) packed lumas.
var<workgroup> search_buffer: array<u32, 144>;
var<workgroup> sad_map: array<array<array<u32, 5>, 16>, 4>;
var<workgroup> wave_min: atomic<u32>;
var<workgroup> wave_sum: atomic<u32>;
var<workgroup> scene_changed: u32;
fn abs_2(v: vec2<i32>) -> vec2<u32> {
let mask = v >> vec2(31u);
return vec2<u32>((v + mask) ^ mask);
}
// FFX_OPTICALFLOW_FIX_TOP_LEFT_BIAS: the nearest of equal SADs wins.
fn encode_search_coord(coord: vec2<i32>) -> u32 {
let a = abs_2(coord - 8);
return (a.y << 12u) | (a.x << 8u) | (u32(coord.y) << 4u) | u32(coord.x);
}
fn decode_search_coord(bits: u32) -> vec2<i32> {
return vec2(i32(bits & 0xfu) - 8, i32((bits >> 4u) & 0xfu) - 8);
}
fn calculate_qsads2(search_id: vec2<i32>) -> vec4<u32> {
var s = vec4(0u);
for (var dy = 0; dy < 8; dy++) {
let row = (search_id.y + dy) * 6;
let a0 = search_buffer[row + search_id.x];
let a1 = search_buffer[row + search_id.x + 1];
let a2 = search_buffer[row + search_id.x + 2];
s += qsad(a0, a1, pixels[dy][0]);
s += qsad(a1, a2, pixels[dy][1]);
}
return s;
}
@compute @workgroup_size(64)
fn search_pass(
@builtin(workgroup_id) group_id: vec3<u32>,
@builtin(local_invocation_index) local_index: u32,
) {
let li = i32(local_index);
let search_id = vec2(li & 3, (li >> 2u) & 15);
let lane_block = ((li >> 1u) & 1) | (((li >> 5u) & 1) << 1u);
let group = vec2<i32>(group_id.xy);
let px = (group << vec2(4u)) + search_id * vec2(4, 1);
if local_index == 0u {
scene_changed = u32(is_scene_changed());
}
if workgroupUniformLoad(&scene_changed) != 0u {
if (search_id.y & 7) == 0 && (search_id.x & 1) == 0 {
store_flow(px >> vec2(3u), vec2(0));
}
return;
}
let use_prediction = params.level != params.level_count - 1u;
let packed = load_packed(luma_in, px);
let sad_4blocks = sad(packed, load_packed(luma_previous, px));
let of_group = group << vec2(1u);
let pixel_group = group << vec2(4u);
for (var by = 0; by < 2; by++) {
for (var bx = 0; bx < 2; bx++) {
let block = vec2(bx, by);
var current = vec2(0);
if use_prediction {
current = load_flow(of_group + block);
}
if lane_block == by * 2 + bx {
pixels[search_id.y & 7][search_id.x & 1] = packed;
}
// LoadSearchBuffer
let base = pixel_group + block * 8 + current - vec2(8, 8);
for (var id = li; id < 144; id += 64) {
search_buffer[id] = load_packed(luma_previous, vec2(base.x + (id % 6) * 4, base.y + id / 6));
}
if local_index == 0u {
atomicStore(&wave_min, 0xffffffffu);
atomicStore(&wave_sum, 0u);
}
workgroupBarrier();
let q = calculate_qsads2(search_id);
// PrepareSadMap
let sx = search_id.x;
let sy = search_id.y;
sad_map[0][sy][sx] = (q.x << 16u) | encode_search_coord(vec2(sx * 4 + 0, sy));
sad_map[1][sy][sx] = (q.y << 16u) | encode_search_coord(vec2(sx * 4 + 1, sy));
sad_map[2][sy][sx] = (q.z << 16u) | encode_search_coord(vec2(sx * 4 + 2, sy));
sad_map[3][sy][sx] = (q.w << 16u) | encode_search_coord(vec2(sx * 4 + 3, sy));
workgroupBarrier();
// SadMapMinReduction256 and BlockSad64 (FFX_LOCAL_SEARCH_FALLBACK)
let min01 = min(sad_map[0][sy][sx], sad_map[1][sy][sx]);
let min23 = min(sad_map[2][sy][sx], sad_map[3][sy][sx]);
atomicMin(&wave_min, min(min01, min23));
atomicAdd(&wave_sum, select(0u, sad_4blocks, lane_block == bx + by * 2));
workgroupBarrier();
let min_sad = atomicLoad(&wave_min);
let block_sad_sum = atomicLoad(&wave_sum);
var v = current + decode_search_coord(min_sad);
if params.level == 0u && block_sad_sum <= (min_sad >> 16u) {
v = vec2(0);
}
if local_index == 0u {
store_flow(of_group + block, v);
}
// The next block reuses the shared buffers and counters.
workgroupBarrier();
}
}
}
// ffx_opticalflow_filter_optical_flow_v5.h: the vector of the 3x3 nearest to all the others.
@compute @workgroup_size(16, 4)
fn filter_flow(@builtin(global_invocation_id) gid: vec3<u32>) {
let p = vec2<i32>(gid.xy);
var mv: array<vec2<i32>, 9>;
var idx = 0;
for (var xx = -1; xx < 2; xx++) {
for (var yy = -1; yy < 2; yy++) {
mv[idx] = load_flow(p + vec2(xx, yy));
idx++;
}
}
var ret = 0xffffffffu;
for (var i = 0; i < 9; i++) {
var tmp = 0u;
for (var j = 0; j < 9; j++) {
let delta = mv[i] - mv[j];
tmp = u32(delta.x * delta.x) + (u32(delta.y * delta.y) + tmp);
}
ret = min((tmp << 4u) | u32(i), ret);
}
store_flow(p, mv[ret & 0xfu]);
}
// ffx_opticalflow_scale_optical_flow_advanced_v5.h: the next level's vectors, each the
// best of its 4 nearest at this level.
var<workgroup> nearest_vectors: array<array<array<vec2<i32>, 4>, 4>, 4>;
var<workgroup> local_region: array<array<array<u32, 4>, 4>, 4>;
var<workgroup> sads: array<array<array<u32, 4>, 4>, 4>;
@compute @workgroup_size(4, 4, 4)
fn scale_pass(
@builtin(global_invocation_id) gid: vec3<u32>,
@builtin(local_invocation_id) lid: vec3<u32>,
@builtin(local_invocation_index) local_index: u32,
) {
if local_index == 0u {
scene_changed = u32(is_scene_changed());
}
if workgroupUniformLoad(&scene_changed) != 0u {
store_flow(vec2<i32>(gid.xy), vec2(0));
return;
}
let x_offset = i32(lid.z % 2u) - 1 + i32(gid.x % 2u);
let y_offset = i32(lid.z / 2u) - 1 + i32(gid.y % 2u);
let src = vec2(i32(gid.x / 2u) + x_offset, i32(gid.y / 2u) + y_offset);
let nearest = load_flow(src);
nearest_vectors[lid.z][lid.y][lid.x] = nearest * 2;
let max_y = 4u;
for (var n = lid.z; n < max_y; n += 4u) {
let luma_pos = vec2(i32(gid.x) * 4, i32(gid.y * max_y + n));
local_region[n][lid.y][lid.x] = load_packed(luma_in, luma_pos);
}
workgroupBarrier();
var s = 0u;
for (var n = 0u; n < max_y; n++) {
let luma_pos = vec2(i32(gid.x) * 4, i32(gid.y * max_y + n)) + nearest;
s += sad(local_region[n][lid.y][lid.x], load_packed(luma_previous, luma_pos));
}
sads[lid.z][lid.y][lid.x] = s;
workgroupBarrier();
if lid.z == 0u {
var best_sad = 0xffffffffu;
var best = 0u;
for (var n = 0u; n < 4u; n++) {
if sads[n][lid.y][lid.x] < best_sad {
best_sad = sads[n][lid.y][lid.x];
best = n;
}
}
store_flow(vec2<i32>(gid.xy), nearest_vectors[best][lid.y][lid.x]);
}
}
+305 -26
View File
@@ -3,7 +3,7 @@
use super::{Frustum, Renderer, SectionGpu};
use crate::{
fast_hash::{FxHashMap, FxHashSet},
lod::TileKey,
lod::{MAX_LEVEL, TileKey},
mesher::SectionMesh,
};
use glam::{DVec3, Vec3};
@@ -45,6 +45,47 @@ impl Pending {
pub(super) struct Tile {
pub mesh: SectionGpu,
bounds: Bounds,
/// When it began to fade in; none while the rest of its job uploads.
shown: Option<Instant>,
/// When its replacements began to fade in; it goes once they have.
dying: Option<Instant>,
/// Dying with nothing to replace it (out of range): it fades out.
alone: bool,
/// Its mesh before near chunks took some of its columns, drawn in its
/// place until they have faded in (from the job's end, the instant), so
/// no hole shows through them.
previous: Option<(SectionGpu, Option<Instant>)>,
}
/// How long a tile takes to fade in over the ones it replaces.
pub(super) const FADE: Duration = Duration::from_millis(600);
/// A tile or section fading in: 0 drawn whole, else 1 + `level` * 129 +
/// `shown`, `shown` 1 to 63 sixty-fourths of `FADE` (`fade_out_code`: 65 to
/// 128, fading out). Chunk by chunk (columns of its own grid, wider for
/// coarse tiles: 2^`level` blocks), each starting in turn over the first half
/// and fading over the second, as a fine ordered pattern fixed to the terrain
/// (terrain.wgsl's `faded`). It is drawn a hair toward the camera, the newest
/// most, over the solid tiles it replaces, which stay until it is whole
/// (`upload_lods`): no pixel fought over, no hole. None: nothing to draw yet.
pub(super) fn fade_code(shown: Option<Instant>, level: u8, now: Instant) -> Option<u32> {
let faded = now.saturating_duration_since(shown?).as_secs_f32() / FADE.as_secs_f32();
let shown = (faded * 64.0) as u32 + 1;
Some(if shown >= 64 {
0
} else {
1 + u32::from(level) * 129 + shown
})
}
/// A section past the render distance that far terrain is coming in under:
/// whole, a fading-out code at its start (terrain.wgsl's `fade_depth` draws
/// leaving sections in front of far terrain).
pub(super) const HELD: u32 = 1 + 64;
/// `fade_code` for one leaving since `since`, chunk by chunk as it came, over
/// what stays behind it; once `FADE` has passed it draws nothing.
pub(super) fn fade_out_code(since: Instant, level: u8, now: Instant) -> u32 {
let faded = now.saturating_duration_since(since).as_secs_f32() / FADE.as_secs_f32();
1 + u32::from(level) * 129 + 64 + ((faded * 64.0) as u32 + 1).min(64)
}
#[derive(Clone, Copy)]
@@ -72,13 +113,13 @@ impl Bounds {
Some(bounds)
}
fn visible(self, origin: DVec3, distance: f32, frustum: &Frustum) -> Option<u64> {
/// In view: the plan alone decides which tiles are in range (a circle),
/// so a tile leaving it fades out (`upload_lods`). Cut here at the range,
/// tiles popped out as the player moved, before the plan dropped them.
fn visible(self, origin: DVec3, frustum: &Frustum) -> Option<u64> {
let min = origin + self.min.as_dvec3();
let max = origin + self.max.as_dvec3();
let nearest = DVec3::ZERO.clamp(min, max);
if nearest.x * nearest.x + nearest.z * nearest.z > f64::from(distance).powi(2)
|| !frustum.intersects(min.as_vec3(), max.as_vec3())
{
if !frustum.intersects(min.as_vec3(), max.as_vec3()) {
return None;
}
Some(((min + max) * 0.5).length_squared().to_bits())
@@ -86,12 +127,19 @@ impl Bounds {
}
/// A tile's instance: like a section's, with the UV step of merged faces.
pub(super) fn instance(origin: [f32; 3]) -> [f32; 4] {
/// A fading tile's step is negated past its `fade_code`, which the shaders
/// read back as the whole part (they take the step as `fract(abs(w))`).
pub(super) fn instance(origin: [f32; 3], fade: u32) -> [f32; 4] {
let step = 1.0 / crate::mesher::TILED_UV_STEPS;
[
origin[0],
origin[1],
origin[2],
1.0 / crate::mesher::TILED_UV_STEPS,
if fade == 0 {
step
} else {
-(fade as f32 + step)
},
]
}
@@ -99,45 +147,119 @@ pub(super) struct Visible {
pub key: TileKey,
pub distance: u64,
pub origin: [f32; 3],
pub fade: u32,
/// Replaced, standing until its replacements are whole: its water goes
/// after theirs (`Renderer::render`'s water tiers), so depth leaves it only
/// where they have none yet.
pub old: bool,
}
impl Tile {
/// The mesh drawn this frame: the previous one while it stands in.
pub fn drawn(&self) -> &SectionGpu {
self.previous.as_ref().map_or(&self.mesh, |(mesh, _)| mesh)
}
pub fn drawn_mut(&mut self) -> &mut SectionGpu {
match &mut self.previous {
Some((mesh, _)) => mesh,
None => &mut self.mesh,
}
}
pub fn visible(
&self,
key: TileKey,
camera: DVec3,
distance: f32,
frustum: &Frustum,
now: Instant,
) -> Option<Visible> {
let fade = match self.dying {
Some(since) if self.alone => fade_out_code(since, key.level, now),
_ => fade_code(self.shown, key.level, now)?,
};
let origin = DVec3::from(key.origin().map(f64::from)) - camera;
Some(Visible {
key,
distance: self.bounds.visible(origin, distance, frustum)?,
distance: self.bounds.visible(origin, frustum)?,
origin: origin.as_vec3().to_array(),
fade,
old: self.dying.is_some() || self.previous.is_some(),
})
}
}
impl Renderer {
pub fn upload_lod(&mut self, key: TileKey, mesh: &SectionMesh) {
self.remove_lod(key);
if let Some(bounds) = Bounds::mesh(mesh)
&& let Some(mesh) = self.upload_mesh(mesh)
{
self.lods.insert(key, Tile { mesh, bounds });
/// A tile meshed again (finer detail) takes its place at once, but for
/// one meshed around near chunks or finer tiles that are new (`keep`):
/// its old mesh stands in while they fade in. A new one waits for the rest of its job
/// (`upload_lods`), then fades in.
pub fn upload_lod(&mut self, key: TileKey, mesh: &SectionMesh, keep: bool) {
let (shown, previous) = match self.lods.remove(&key) {
Some(old) if old.dying.is_none() => match old.previous {
Some(previous) => {
self.release_mesh(old.mesh);
(old.shown, Some(previous))
}
None if keep => (old.shown, Some((old.mesh, None))),
None => {
self.release_mesh(old.mesh);
(old.shown, None)
}
},
Some(old) => {
self.release_tile(old);
(None, None)
}
None => (None, None),
};
match Bounds::mesh(mesh).zip(self.upload_mesh(mesh)) {
Some((bounds, mesh)) => {
self.lods.insert(
key,
Tile {
mesh,
bounds,
shown,
dying: None,
alone: false,
previous,
},
);
}
None => {
if let Some((previous, _)) = previous {
self.release_mesh(previous);
}
}
}
}
fn release_tile(&mut self, tile: Tile) {
self.release_mesh(tile.mesh);
if let Some((previous, _)) = tile.previous {
self.release_mesh(previous);
}
}
pub fn remove_lod(&mut self, key: TileKey) {
if let Some(tile) = self.lods.remove(&key) {
self.release_mesh(tile.mesh);
self.release_tile(tile);
}
}
pub fn clear_lods(&mut self) {
self.lod_pending = None;
// No far terrain left to fade out over.
for (key, since) in std::mem::take(&mut self.lod_leaving) {
if self
.sections
.find(key)
.is_some_and(|s| s.leaving == Some(since))
{
self.release_section(key);
}
}
for (_, tile) in std::mem::take(&mut self.lods) {
self.release_mesh(tile.mesh);
self.release_tile(tile);
}
self.set_lod_cutout(Default::default());
}
@@ -160,14 +282,134 @@ impl Renderer {
}
/// Uploads queued tile meshes for about `budget` (at least one), then,
/// once all are in, retires the tiles they replace.
/// once all are in, fades them in over the tiles they replace.
pub fn upload_lods(&mut self, budget: Duration) {
let start = Instant::now();
self.release_left(start);
let faded: Vec<_> = self
.lods
.iter()
.filter(|(_, tile)| tile.dying.is_some_and(|d| start - d >= FADE))
.map(|(&key, _)| key)
.collect();
for key in faded {
self.remove_lod(key);
}
let stood_in: Vec<_> = self
.lods
.values_mut()
.filter_map(|tile| match tile.previous {
Some((_, Some(since))) if start - since >= FADE => tile.previous.take(),
_ => None,
})
.collect();
for (previous, _) in stood_in {
self.release_mesh(previous);
}
// The columns near chunks take in this job, and the tiles holding the
// job's new finer ones: a tile meshed again around them keeps its
// old mesh until they are in, so no hole shows through them.
let mut around_new = FxHashSet::default();
let new = self
.lod_pending
.iter()
.flat_map(|pending| pending.meshes.keys())
.filter(|key| !self.lods.contains_key(key))
.chain(
self.lods
.iter()
.filter(|(_, tile)| tile.shown.is_none())
.map(|(key, _)| key),
);
for &key in new {
let mut key = key;
while key.level < MAX_LEVEL && around_new.insert(key.parent()) {
key = key.parent();
}
}
let taken: Vec<_> = self
.lod_pending
.iter()
.flat_map(|pending| pending.cutout.iter().flatten())
.filter(|column| !self.lod_cutout.contains(column))
.copied()
.collect();
while let Some(pending) = &mut self.lod_pending {
let Some(&key) = pending.meshes.keys().next() else {
let pending = self.lod_pending.take().expect("pending tiles");
// A tile fades in over the ones it replaces. One over nothing
// shows whole: its hidden chunks would open holes to the sky
// or the sea bed (dark patches, as far terrain first loads).
let replaced: FxHashSet<TileKey> = pending
.removed
.iter()
.copied()
.filter(|key| self.lods.contains_key(key))
.collect();
let mut above_replaced = FxHashSet::default();
for &key in &replaced {
let mut key = key;
while key.level < MAX_LEVEL && above_replaced.insert(key.parent()) {
key = key.parent();
}
}
let over_replaced = |key: TileKey| {
let mut ancestor = key;
replaced.contains(&key)
|| above_replaced.contains(&key)
|| (key.level..MAX_LEVEL).any(|_| {
ancestor = ancestor.parent();
replaced.contains(&ancestor)
})
};
// A removed tile no new tile overlaps, and no near chunk takes
// a column of, leaves the plan (out of range): it fades out.
// Over near chunks fading in it stays whole: two patterns
// fading at once left holes between them.
// Two tiles overlap when one is the other or its ancestor:
// `kept` holds the new tiles and the near chunks' columns
// (level 0), `above` their ancestors.
let mut kept: FxHashSet<TileKey> = self
.lods
.iter()
.filter(|(_, tile)| tile.shown.is_none())
.map(|(&key, _)| key)
.collect();
let cut = pending.cutout.as_ref().unwrap_or(&self.lod_cutout);
kept.extend(cut.iter().map(|&(x, z)| TileKey { x, z, level: 0 }));
let mut above = FxHashSet::default();
for &key in &kept {
let mut key = key;
while key.level < MAX_LEVEL && above.insert(key.parent()) {
key = key.parent();
}
}
let alone = |key: TileKey| {
let mut ancestor = key;
!kept.contains(&key)
&& !above.contains(&key)
&& !(key.level..MAX_LEVEL).any(|_| {
ancestor = ancestor.parent();
kept.contains(&ancestor)
})
};
let whole = start.checked_sub(FADE).unwrap_or(start);
for (&key, tile) in &mut self.lods {
if tile.shown.is_none() {
tile.shown = Some(if over_replaced(key) { start } else { whole });
}
if let Some((_, since)) = &mut tile.previous {
since.get_or_insert(start);
}
}
for key in pending.removed {
self.remove_lod(key);
let alone = alone(key);
if let Some(tile) = self.lods.get_mut(&key)
&& tile.dying.is_none()
{
tile.dying = Some(start);
tile.alone = alone;
}
}
if let Some(cutout) = pending.cutout {
self.set_lod_cutout(cutout);
@@ -175,7 +417,13 @@ impl Renderer {
return;
};
let mesh = pending.meshes.remove(&key).expect("pending tile");
self.upload_lod(key, &mesh);
let [x, _, z] = key.origin();
let size = key.size();
let keep = around_new.contains(&key)
|| taken.iter().any(|&(cx, cz)| {
(x..x + size).contains(&(cx * 16)) && (z..z + size).contains(&(cz * 16))
});
self.upload_lod(key, &mesh, keep);
if start.elapsed() >= budget {
return;
}
@@ -194,7 +442,7 @@ impl Renderer {
.collect();
for key in released {
self.lod_retiring.remove(&key);
self.release_section(key);
self.leave_section(key);
}
}
}
@@ -204,6 +452,36 @@ mod tests {
use super::*;
use crate::mesher::TerrainVertex;
#[test]
fn tiles_fade_in_on_their_own_grid() {
let start = Instant::now();
let at = |ms| start + Duration::from_millis(ms);
// terrain.wgsl's `faded` reads the level and sixty-fourths back.
let decode = |code: u32| ((code - 1) / 129, (code - 1) % 129);
assert_eq!(fade_code(None, 0, at(0)), None, "waits for its job");
assert_eq!(fade_code(Some(start), 3, at(0)).map(decode), Some((3, 1)));
let mut last = 0;
for ms in [0, 100, 300, 500, 590] {
let (level, shown) = decode(fade_code(Some(start), 10, at(ms)).unwrap());
assert_eq!(level, 10);
assert!(shown > last && shown < 64, "{shown} at {ms} ms");
last = shown;
}
assert_eq!(fade_code(Some(start), 10, at(600)), Some(0), "whole");
// Fading out: 65 to 128, then nothing drawn (128) until released.
let mut last = 64;
for ms in [0, 100, 300, 500, 590] {
let (level, out) = decode(fade_out_code(start, 3, at(ms)));
assert_eq!(level, 3);
assert!(out > last && out < 128, "{out} at {ms} ms");
last = out;
}
assert_eq!(decode(fade_out_code(start, 3, at(700))), (3, 128));
// A section's largest code keeps its UV step exact (terrain_uv).
let w = 129.0f32 + 1.0 / 32768.0;
assert_eq!(w.fract(), 1.0 / 32768.0);
}
#[test]
fn joined_jobs_apply_as_if_in_turn() {
let key = |x| TileKey { x, z: 0, level: 0 };
@@ -244,16 +522,17 @@ mod tests {
* crate::camera::view_matrix(0.0, 0.0),
),
};
// The tile origin is farther than the range, but its far edge reaches
// the camera. Negative world origins therefore remain visible.
// The tile origin is far behind, but its far edge reaches the camera.
// Negative world origins therefore remain visible.
assert!(
bounds
.visible(DVec3::new(-1000.0, -80.0, -512.0), 256.0, &view)
.visible(DVec3::new(-1000.0, -80.0, -512.0), &view)
.is_some()
);
// Wholly behind the camera: out of view.
assert!(
bounds
.visible(DVec3::new(-2000.0, -80.0, -512.0), 256.0, &view)
.visible(DVec3::new(-512.0, -80.0, -2000.0), &view)
.is_none()
);
assert!(Bounds::mesh(&SectionMesh::default()).is_none());
@@ -176,7 +176,7 @@ impl PackAtlas {
hz_cell = vec2(float(hz_layer % {columns}u), float(hz_layer / {columns}u));\n \
hz_scale = vec2({:?}, {:?});\n \
hz_Vertex = vec4(hz_v_pos + hz_origin.xyz, 1.0);\n \
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * hz_origin.w, 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * (hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w))), 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = vec4(hz_v_color.rgb, 1.0);\n \
@@ -57,6 +57,14 @@ impl<T> Resident<T> {
pub fn values(&self) -> impl Iterator<Item = &T> {
self.items.iter().map(|(_, v)| v)
}
/// `key`'s value, when it is resident.
pub fn find(&self, key: SectionKey) -> Option<&T> {
self.slots.get(&key).map(|&slot| self.get(slot))
}
pub fn find_mut(&mut self, key: SectionKey) -> Option<&mut T> {
let slot = *self.slots.get(&key)?;
Some(self.get_mut(slot))
}
/// The section in `slot` (`Visible::slot`).
pub fn get(&self, slot: u32) -> &T {
&self.items[slot as usize].1
+206 -12
View File
@@ -455,18 +455,39 @@ impl HudAtlas {
for folder in folders {
walk(&textures.join(folder), &mut files);
}
// Content mods' equipment textures, which their equipment assets name as `<ns>:<path>`.
if folders.contains(&"entity") {
for namespace in horizon_items::content_namespaces::namespaces(root) {
walk(
&root.join(format!("assets/{namespace}/textures/entity/equipment")),
&mut files,
);
}
}
let assets = root.join("assets");
// Content mods' GUI textures, for their screens: `<namespace>:gui/...`.
if full && let Ok(namespaces) = fs::read_dir(&assets) {
for ns in namespaces.flatten() {
if ns.file_name() != "minecraft" {
walk(&ns.path().join("textures/gui"), &mut files);
}
}
}
files.retain(|p| {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
});
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>, bool);
let decode_one = |path: &PathBuf| -> Option<Decoded> {
let name = path
.strip_prefix(&textures)
.ok()?
.with_extension("")
.to_string_lossy()
.replace('\\', "/");
let name = match path.strip_prefix(&textures) {
Ok(rel) => rel.with_extension("").to_string_lossy().replace('\\', "/"),
Err(_) => {
let rel = path.strip_prefix(&assets).ok()?.with_extension("");
let rel = rel.to_string_lossy().replace('\\', "/");
let (namespace, rest) = rel.split_once("/textures/")?;
format!("{namespace}:{rest}")
}
};
let image = decode(path).ok()?;
let mcmeta = fs::read_to_string(path.with_extension("png.mcmeta")).ok();
let animation = animation_of(mcmeta.as_deref());
@@ -674,6 +695,45 @@ pub struct HudVertex {
/// unless `uv.x >= 2`.
pub glint_uv: [f32; 2],
}
/// Sutherland-Hodgman against one half plane, interpolating every attribute.
fn clip_polygon(poly: &[HudVertex], axis: usize, limit: f32, keep_below: bool) -> Vec<HudVertex> {
let inside = |v: &HudVertex| {
if keep_below {
v.pos[axis] < limit
} else {
v.pos[axis] >= limit
}
};
let lerp = |a: &HudVertex, b: &HudVertex, t: f32| {
let mix = |p: f32, q: f32| p + (q - p) * t;
let mut v = *a;
v.pos = [mix(a.pos[0], b.pos[0]), mix(a.pos[1], b.pos[1])];
v.uv = [mix(a.uv[0], b.uv[0]), mix(a.uv[1], b.uv[1])];
v.depth = mix(a.depth, b.depth);
v.glint_uv = [
mix(a.glint_uv[0], b.glint_uv[0]),
mix(a.glint_uv[1], b.glint_uv[1]),
];
v.pos[axis] = limit;
v
};
let mut out = Vec::with_capacity(poly.len() + 2);
for i in 0..poly.len() {
let (a, b) = (&poly[i], &poly[(i + 1) % poly.len()]);
let (ia, ib) = (inside(a), inside(b));
if ia {
out.push(*a);
}
if ia != ib {
out.push(lerp(
a,
b,
(limit - a.pos[axis]) / (b.pos[axis] - a.pos[axis]),
));
}
}
out
}
/// The GUI Scale option (0 = auto).
pub static GUI_SCALE: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
/// One frame of overlay geometry in GUI pixels.
@@ -913,8 +973,8 @@ impl HudBatch {
}
}
/// Clip the quads drawn since `start` to `[x0, x1) x [y0, y1)` (the
/// GUI scissor): axis-aligned quads are cut with their UVs, others are
/// kept or dropped by their centre.
/// GUI scissor): axis-aligned quads are cut with their UVs, others (item
/// triangles) are clipped as polygons.
/// Scale the vertices pushed since `start` about `(cx, cy)` (a GUI pose
/// matrix of translate, scale, translate back).
pub fn scale_since(&mut self, start: usize, cx: f32, cy: f32, sx: f32, sy: f32) {
@@ -926,7 +986,11 @@ impl HudBatch {
pub fn clip_since(&mut self, start: usize, x0: f32, y0: f32, x1: f32, y1: f32) {
let quads: Vec<[HudVertex; 6]> = self.vertices[start..].as_chunks::<6>().0.to_vec();
self.vertices.truncate(start);
// Where each source quad now begins, to move the blend ranges (foil
// items are an additive run) along with their vertices.
let mut moved = Vec::with_capacity(quads.len() + 1);
for q in quads {
moved.push(self.vertices.len() as u32);
let corners = [q[0], q[1], q[2], q[5]];
let (mut x_lo, mut x_hi) = (f32::INFINITY, f32::NEG_INFINITY);
let (mut y_lo, mut y_hi) = (f32::INFINITY, f32::NEG_INFINITY);
@@ -945,10 +1009,26 @@ impl HudBatch {
find(x_lo, y_hi),
);
let (Some(tl), Some(tr), Some(br), Some(bl)) = rect else {
let cx = corners.iter().map(|v| v.pos[0]).sum::<f32>() / 4.0;
let cy = corners.iter().map(|v| v.pos[1]).sum::<f32>() / 4.0;
if cx >= x0 && cx < x1 && cy >= y0 && cy < y1 {
self.vertices.extend_from_slice(&q);
// Item geometry arrives as triangles: clip each against the
// rectangle and re-emit the fan, so a triangle straddling the
// edge keeps its visible part instead of vanishing whole.
for tri in [[q[0], q[1], q[2]], [q[3], q[4], q[5]]] {
let area = (tri[1].pos[0] - tri[0].pos[0]) * (tri[2].pos[1] - tri[0].pos[1])
- (tri[2].pos[0] - tri[0].pos[0]) * (tri[1].pos[1] - tri[0].pos[1]);
if area == 0.0 {
continue;
}
let mut poly = tri.to_vec();
for (axis, limit, keep_below) in
[(0, x0, false), (0, x1, true), (1, y0, false), (1, y1, true)]
{
poly = clip_polygon(&poly, axis, limit, keep_below);
}
for i in 1..poly.len().saturating_sub(1) {
// A triangle as the quad `a, b, c, c`.
let (a, b, c) = (poly[0], poly[i], poly[i + 1]);
self.vertices.extend_from_slice(&[a, b, c, a, c, c]);
}
}
continue;
};
@@ -972,6 +1052,14 @@ impl HudBatch {
tl.color,
);
}
moved.push(self.vertices.len() as u32);
let at = |v: u32| moved[((v as usize).saturating_sub(start) / 6).min(moved.len() - 1)];
for (range, _) in &mut self.blends {
if range.start as usize >= start {
*range = at(range.start)..at(range.end);
}
}
self.blends.retain(|(range, _)| !range.is_empty());
}
/// `blit` of the top-left `w x h` texels of a `tw x th` texture.
pub fn sprite_region(
@@ -1846,6 +1934,57 @@ impl HudRenderer {
mod tests {
use super::*;
#[test]
fn clip_since_keeps_the_visible_part_of_a_straddling_triangle() {
let mut batch = HudBatch::new([320, 240]);
let start = batch.vertices.len();
// A right triangle (a quad `a, b, c, c`) whose centre lies outside the rect.
batch.quad(
[[0.0, 0.0], [16.0, 0.0], [0.0, 16.0], [0.0, 16.0]],
[[0.0, 0.0], [1.0, 0.0], [0.0, 1.0], [0.0, 1.0]],
u32::MAX,
);
batch.clip_since(start, 0.0, 0.0, 16.0, 4.0);
assert!(!batch.vertices.is_empty());
assert!(batch.vertices.iter().all(|v| v.pos[1] <= 4.0));
let area: f32 = batch
.vertices
.as_chunks::<3>()
.0
.iter()
.map(|t| {
((t[1].pos[0] - t[0].pos[0]) * (t[2].pos[1] - t[0].pos[1])
- (t[2].pos[0] - t[0].pos[0]) * (t[1].pos[1] - t[0].pos[1]))
.abs()
/ 2.0
})
.sum();
// Rows 0..4 of the triangle: 16 * 16 / 2 - 12 * 12 / 2.
assert!((area - 56.0).abs() < 1e-3, "{area}");
}
#[test]
fn clip_since_moves_blend_ranges_with_their_vertices() {
let mut batch = HudBatch::new([320, 240]);
let start = batch.vertices.len();
let tri = |batch: &mut HudBatch, y: f32| {
batch.quad(
[[0.0, y], [8.0, y], [0.0, y + 8.0], [0.0, y + 8.0]],
[[0.0; 2]; 4],
u32::MAX,
);
};
tri(&mut batch, 100.0); // outside, dropped
let foil = batch.vertices.len();
tri(&mut batch, 0.0); // straddles the bottom edge
batch.blend_since(foil, Blend::Additive);
batch.clip_since(start, 0.0, 0.0, 16.0, 4.0);
assert_eq!(
batch.blends,
[(0..batch.vertices.len() as u32, Blend::Additive)]
);
}
/// Write a PNG `width x height` whose `frames` stacked rows are solid
/// colours, then its mcmeta beside it.
fn write_strip(path: &Path, width: u32, height: u32, frames: &[[u8; 4]], mcmeta: Option<&str>) {
@@ -1888,6 +2027,34 @@ mod tests {
atlas.pixels[at..at + 4].try_into().unwrap()
}
#[test]
fn content_mod_equipment_textures_pack_under_their_namespace() {
let root = std::env::temp_dir().join(format!("horizon-atlas-equip-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let equipment = root.join("assets/gems/textures/entity/equipment/humanoid");
fs::create_dir_all(&equipment).unwrap();
write_strip(
&equipment.join("ruby.png"),
64,
32,
&[[200, 0, 0, 255]],
None,
);
let other = root.join("assets/gems/textures/item");
fs::create_dir_all(&other).unwrap();
write_strip(&other.join("ruby.png"), 16, 16, &[[0, 200, 0, 255]], None);
fs::write(
root.join(horizon_items::content_namespaces::MARKER),
br#"["gems"]"#,
)
.unwrap();
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["gems:entity/equipment/humanoid/ruby"];
assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]);
// Only equipment: the mods' other textures reach the atlas through atlas definitions.
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn animated_block_sprites_register_and_pack_their_first_mcmeta_frame() {
let root = animated_root();
let atlas = HudAtlas::build(&root).unwrap();
@@ -2009,6 +2176,33 @@ mod tests {
assert_eq!(((r.w, r.h), r.texels(size)), (gui, texels), "{name}");
}
}
/// A content mod's GUI textures join the atlas under its namespace;
/// its other textures and vanilla's names stay as they were.
#[test]
fn a_content_mods_gui_textures_are_sprites_under_its_namespace() {
let root = std::env::temp_dir().join(format!("horizon-mod-gui-{}", std::process::id()));
write_png(&root, "gui/container/furnace", 256, 256);
let png = |rel: &str, w: u32, h: u32| {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let pixels = vec![255; (w * h * 4) as usize];
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
};
png("assets/mymod/textures/gui/container/machine.png", 176, 166);
png("assets/mymod/textures/block/machine.png", 16, 16);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
assert_eq!(atlas.sprites["mymod:gui/container/machine"].w, 176);
assert!(atlas.sprites.contains_key("gui/container/furnace"));
assert!(!atlas.sprites.contains_key("mymod:block/machine"));
}
#[test]
fn the_default_textures_keep_a_4096_wide_atlas() {
let images = sized(400, 64, 64);
@@ -0,0 +1,153 @@
//! `JigsawBlockEntity`: the jigsaw block's name, target, pool, joint, final
//! state and priorities, with its save format (`saveAdditional` /
//! `loadAdditional`).
use crate::structure_block::parse_identifier;
use horizon_client_core::nbt_value::{number, string, text};
use horizon_world::nbt::Tag;
/// `JigsawBlockEntity.EMPTY_ID` and `Pools.EMPTY`.
pub const EMPTY_ID: &str = "minecraft:empty";
/// `JigsawBlockEntity.JointType`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum JointType {
Rollable,
Aligned,
}
impl JointType {
pub const ALL: [Self; 2] = [Self::Rollable, Self::Aligned];
pub fn serialized_name(self) -> &'static str {
match self {
Self::Rollable => "rollable",
Self::Aligned => "aligned",
}
}
pub fn of_serialized_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|j| j.serialized_name() == name)
}
/// `JointType.getTranslatedName`'s lang key.
pub fn label_key(self) -> String {
format!("jigsaw_block.joint.{}", self.serialized_name())
}
/// `StructureTemplate.getDefaultJointType`: the joint of a block whose
/// front faces `front` (the first word of its `orientation` value).
pub fn default_for_front(front: &str) -> Self {
if is_vertical_front(front) {
Self::Rollable
} else {
Self::Aligned
}
}
}
/// `JigsawBlock.getFrontFacing(state)` from the `orientation` value
/// (`down_east`, `north_up`...): its front is the part before the `_`.
pub fn front_of_orientation(orientation: &str) -> &str {
orientation.split('_').next().unwrap_or(orientation)
}
pub fn is_vertical_front(front: &str) -> bool {
matches!(front, "up" | "down")
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct JigsawBlock {
pub name: String,
pub target: String,
pub pool: String,
pub joint: JointType,
pub final_state: String,
pub placement_priority: i32,
pub selection_priority: i32,
}
impl Default for JigsawBlock {
fn default() -> Self {
Self {
name: EMPTY_ID.into(),
target: EMPTY_ID.into(),
pool: EMPTY_ID.into(),
joint: JointType::Rollable,
final_state: "minecraft:air".into(),
placement_priority: 0,
selection_priority: 0,
}
}
}
impl JigsawBlock {
/// `saveAdditional` (also `getUpdateTag`, which is `saveCustomOnly`).
pub fn save(&self) -> Vec<(&'static str, Tag)> {
vec![
("name", string(&self.name)),
("target", string(&self.target)),
("pool", string(&self.pool)),
("final_state", string(&self.final_state)),
("joint", string(self.joint.serialized_name())),
("placement_priority", Tag::Int(self.placement_priority)),
("selection_priority", Tag::Int(self.selection_priority)),
]
}
/// `loadAdditional`. A missing joint takes `default_joint`
/// (`StructureTemplate.getDefaultJointType` of the block's state).
pub fn load(tag: &Tag, default_joint: JointType) -> Self {
let id = |key: &str| {
text(tag.get(key))
.and_then(|s| parse_identifier(&s))
.unwrap_or_else(|| EMPTY_ID.into())
};
Self {
name: id("name"),
target: id("target"),
pool: id("pool"),
final_state: text(tag.get("final_state")).unwrap_or_else(|| "minecraft:air".into()),
joint: text(tag.get("joint"))
.and_then(|j| JointType::of_serialized_name(&j))
.unwrap_or(default_joint),
placement_priority: number(tag.get("placement_priority")).unwrap_or(0) as i32,
selection_priority: number(tag.get("selection_priority")).unwrap_or(0) as i32,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn saves_and_loads_like_jigsaw_block_entity() {
let block = JigsawBlock {
name: "minecraft:bottom".into(),
target: "test:top".into(),
pool: "minecraft:village/plains/houses".into(),
joint: JointType::Aligned,
final_state: "minecraft:stone[x=1]".into(),
placement_priority: 4,
selection_priority: -1,
};
let tag = Tag::compound(block.save());
assert_eq!(text(tag.get("joint")).as_deref(), Some("aligned"));
assert_eq!(JigsawBlock::load(&tag, JointType::Rollable), block);
}
#[test]
fn load_defaults_like_vanilla() {
let tag = Tag::compound(vec![
("name", string("Bad Name")),
("target", string("door")),
]);
let block = JigsawBlock::load(&tag, JointType::Aligned);
assert_eq!(block.name, EMPTY_ID);
assert_eq!(block.target, "minecraft:door");
assert_eq!(block.pool, EMPTY_ID);
assert_eq!(block.final_state, "minecraft:air");
assert_eq!(block.joint, JointType::Aligned);
assert_eq!(JointType::default_for_front("up"), JointType::Rollable);
assert_eq!(
JointType::default_for_front(front_of_orientation("north_up")),
JointType::Aligned
);
}
}
@@ -53,6 +53,261 @@ pub enum Reason {
Other,
}
/// `LevelLoadTracker.CLIENT_WAIT_TIMEOUT_MS`.
pub const CLIENT_WAIT_TIMEOUT_MS: u64 = 30_000;
/// `LevelLoadTracker.LEVEL_LOAD_CLOSE_DELAY_MS`: how long a new world's loading
/// screen stays once the level is ready.
pub const LEVEL_LOAD_CLOSE_DELAY_MS: u64 = 500;
/// `ClientPacketListener.determineLevelLoadingReason`.
pub fn reason_for_respawn(player_died: bool, dimension: &str, old_dimension: &str) -> Reason {
const NETHER: &str = "minecraft:the_nether";
const END: &str = "minecraft:the_end";
if player_died {
Reason::Other
} else if dimension == NETHER || old_dimension == NETHER {
Reason::NetherPortal
} else if dimension == END || old_dimension == END {
Reason::EndPortal
} else {
Reason::Other
}
}
/// `LevelLoadTracker.ClientState`, with the times in milliseconds.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ClientState {
/// `WaitingForServer`: until the loading start game event.
WaitingForServer { timeout_after: u64 },
/// `WaitingForPlayerChunk`: until the section around the player is shown.
WaitingForPlayerChunk {
player_section_ready: bool,
timeout_after: u64,
},
/// `ClientLevelReady`.
Ready { ready_at: u64 },
}
/// What `WaitingForPlayerChunk.isReady` asks of the player.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PlayerCheck {
/// `ClientLevel.isOutsideBuildHeight(camera y)`.
pub outside_build_height: bool,
pub spectator: bool,
pub alive: bool,
}
/// The client half of `LevelLoadTracker`: what the loading screen waits for
/// after a login or a respawn, and the screen it is shown on.
#[derive(Clone, Copy, Debug)]
pub struct LevelLoadTracker {
state: Option<ClientState>,
close_delay_ms: u64,
/// `LevelLoadingScreen.reason`.
pub reason: Reason,
}
impl LevelLoadTracker {
pub fn new(close_delay_ms: u64) -> Self {
Self {
state: None,
close_delay_ms,
reason: Reason::Other,
}
}
/// `startClientLoad`, for the screen `reason` (`startWaitingForNewLevel`).
pub fn start_client_load(&mut self, now: u64, reason: Reason) {
self.state = Some(ClientState::WaitingForServer {
timeout_after: now + CLIENT_WAIT_TIMEOUT_MS,
});
self.reason = reason;
}
/// The tracker's work is over and the screen has closed
/// (`ClientPacketListener.tick` drops it once the level is ready).
pub fn finish(&mut self) {
self.state = None;
}
/// A load is under way, so its screen is up.
pub fn is_active(&self) -> bool {
self.state.is_some()
}
/// `loadingPacketsReceived`: `LEVEL_CHUNKS_LOAD_START` came.
pub fn loading_packets_received(&mut self) {
if let Some(ClientState::WaitingForServer { timeout_after }) = self.state {
self.state = Some(ClientState::WaitingForPlayerChunk {
player_section_ready: false,
timeout_after,
});
}
}
/// `getPlayerCompiledSectionCallback` is not null: the renderer is to say
/// when the player's section is shown.
pub fn wants_player_section(&self) -> bool {
matches!(self.state, Some(ClientState::WaitingForPlayerChunk { .. }))
}
/// The callback: the player's section is compiled and visible.
pub fn player_section_ready(&mut self) {
if let Some(ClientState::WaitingForPlayerChunk {
player_section_ready,
..
}) = &mut self.state
{
*player_section_ready = true;
}
}
/// `tickClientLoad`.
pub fn tick_client_load(&mut self, now: u64, player: PlayerCheck) {
if let Some(ClientState::WaitingForPlayerChunk {
player_section_ready,
timeout_after,
}) = self.state
{
let ready = if now > timeout_after {
log::warn!(
"Timed out while waiting for the client to load chunks, letting the player into the world anyway"
);
true
} else if !player.outside_build_height && !player.spectator && player.alive {
player_section_ready
} else {
true
};
if ready {
self.state = Some(ClientState::Ready { ready_at: now });
}
}
}
/// `isLevelReady`.
pub fn is_level_ready(&self, now: u64) -> bool {
matches!(self.state, Some(ClientState::Ready { ready_at }) if now >= ready_at + self.close_delay_ms)
}
}
/// `LevelLoadListener.Stage`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stage {
StartServer,
PrepareGlobalSpawn,
LoadInitialChunks,
LoadPlayerChunks,
}
/// `LevelLoadProgressTracker` (with `includePlayerChunks`).
#[derive(Debug, Default)]
struct ProgressTracker {
total_weight: usize,
finalized_weight: usize,
segment_weight: usize,
segment_fraction: f32,
progress: f32,
}
impl ProgressTracker {
fn tracks(stage: Stage) -> bool {
matches!(stage, Stage::LoadInitialChunks | Stage::LoadPlayerChunks)
}
fn begin_segment(&mut self, weight: usize) {
self.segment_weight = weight;
self.segment_fraction = 0.0;
self.update_progress();
}
fn finish_segment(&mut self) {
self.finalized_weight += self.segment_weight;
self.segment_weight = 0;
self.update_progress();
}
fn update_progress(&mut self) {
self.progress = if self.total_weight == 0 {
0.0
} else {
(self.finalized_weight as f32 + self.segment_fraction * self.segment_weight as f32)
/ self.total_weight as f32
};
}
fn start(&mut self, stage: Stage, total_chunks: usize) {
match stage {
Stage::LoadInitialChunks => {
self.total_weight = PREPARE_WEIGHT + total_chunks + PLAYER_CHUNKS;
self.begin_segment(PREPARE_WEIGHT);
self.finish_segment();
self.begin_segment(total_chunks);
}
Stage::LoadPlayerChunks => self.begin_segment(PLAYER_CHUNKS),
_ => {}
}
}
fn update(&mut self, stage: Stage, current: usize, total: usize) {
if Self::tracks(stage) {
self.segment_fraction = if total == 0 {
0.0
} else {
current as f32 / total as f32
};
self.update_progress();
}
}
fn finish(&mut self, stage: Stage) {
if Self::tracks(stage) {
self.finish_segment();
}
}
}
/// The integrated server's side of `LevelLoadTracker`: what its
/// `LevelLoadListener` reported (`serverProgress`, `hasProgress`) and the
/// chunk status map around the load focus (`ChunkLoadStatusView`), which the
/// server thread writes and the window's loading screen reads.
#[derive(Debug, Default)]
pub struct ServerLoadView {
tracker: std::sync::Mutex<ProgressTracker>,
/// `serverStage != null`.
has_progress: std::sync::atomic::AtomicBool,
/// `statusView.get(x, z)` for x and z in `0..=2 * STATUS_RADIUS`.
cells: std::sync::Mutex<Vec<Option<Status>>>,
}
impl ServerLoadView {
/// `LevelLoadListener.start`.
pub fn start(&self, stage: Stage, total_chunks: usize) {
Self::lock(&self.tracker).start(stage, total_chunks);
// `serverStage = stage`, whatever the stage.
self.has_progress
.store(true, std::sync::atomic::Ordering::Relaxed);
}
/// `LevelLoadListener.update`.
pub fn update(&self, stage: Stage, current: usize, total: usize) {
Self::lock(&self.tracker).update(stage, current, total);
}
/// `LevelLoadListener.finish`.
pub fn finish(&self, stage: Stage) {
Self::lock(&self.tracker).finish(stage);
}
/// `LevelLoadTracker.hasProgress`.
pub fn has_progress(&self) -> bool {
self.has_progress.load(std::sync::atomic::Ordering::Relaxed)
}
/// `LevelLoadTracker.serverProgress`.
pub fn server_progress(&self) -> f32 {
Self::lock(&self.tracker).progress
}
/// The map `ChunkLoadStatusView.get` reads, replaced as a whole.
pub fn set_status(&self, cells: Vec<Option<Status>>) {
*Self::lock(&self.cells) = cells;
}
/// `ChunkLoadStatusView.get`.
pub fn status(&self, x: i32, z: i32) -> Option<Status> {
let diameter = (STATUS_RADIUS * 2 + 1) as usize;
let (x, z) = (usize::try_from(x).ok()?, usize::try_from(z).ok()?);
if x >= diameter || z >= diameter {
return None;
}
Self::lock(&self.cells)
.get(x * diameter + z)
.copied()
.flatten()
}
fn lock<T>(m: &std::sync::Mutex<T>) -> std::sync::MutexGuard<'_, T> {
m.lock().unwrap_or_else(|e| e.into_inner())
}
}
/// `LevelLoadProgressTracker.get` once the server is prepared, with `ready`
/// of the player's 49 chunks loaded (no forced chunks to load first).
pub fn progress(ready: usize) -> f32 {
@@ -165,6 +420,118 @@ pub fn draw_connecting(
}
}
/// `FileFixerProgressScreen`'s Cancel button corner on a GUI `w` x `h`:
/// ((width - 200) / 2, height / 2 + 100), 200 x 20.
pub fn file_fixer_cancel(w: f32, h: f32) -> (f32, f32) {
(((w as i32 - 200) / 2) as f32, (h as i32 / 2 + 100) as f32)
}
/// `GuiGraphicsExtractor.centeredText` with the screen's grey (`-6250336`) or white.
fn centered_line(
batch: &mut HudBatch,
atlas: &HudAtlas,
text: &Text,
x_center: i32,
y: i32,
color: u32,
) {
let tw = atlas.font.width_text(text) as i32;
batch.text_styled(
atlas,
text,
(x_center - tw / 2) as f32,
y as f32,
color,
true,
);
}
/// `FileFixerProgressScreen.extractProgressBar`.
fn file_fixer_bar(batch: &mut HudBatch, atlas: &HudAtlas, x_center: i32, y: i32, progress: f32) {
let left = x_center - 100;
batch.fill(atlas, left as f32, y as f32, 200.0, 2.0, opaque(0));
batch.fill(
atlas,
left as f32,
y as f32,
(progress * 200.0).round(),
2.0,
opaque(0x00FF00),
);
}
/// `FileFixerProgressScreen.extractRenderState` (with `extractProgress`,
/// `extractProgressBar`, `extractFileStats`, `extractFileFixerCount`,
/// `extractTypeText`, `extractTypeProgress` and `extractScanning`) over the menu background: the
/// title, then either the scanning line or the running fixer's bar, the
/// totals, the stage, the type's label and its bar and percentage; and
/// Cancel, inactive once pressed.
pub fn draw_file_fixer(
batch: &mut HudBatch,
atlas: &HudAtlas,
progress: &horizon_world::datafix::progress::UpgradeProgress,
cursor: (f32, f32),
) {
const GREY: u32 = 0xFFA0_A0A0;
let (w, h) = (batch.width, batch.height);
crate::ui::menu_background(batch, atlas, 0.0, 0.0, w, h, false);
let (x_center, y_center) = ((w as i32) / 2, (h as i32) / 2);
let text_top = y_center - 50;
centered_line(
batch,
atlas,
&Text::translate("upgradeWorld.title", &[]),
x_center,
text_top,
u32::MAX,
);
let total = progress.total_file_fix_stats();
if total.total_operations() > 0 {
let of_type = progress.type_file_fix_stats();
let running = progress.running_file_fixer_stats();
let mut y = text_top + 9 + 3;
file_fixer_bar(batch, atlas, x_center, y, running.progress());
y += 7;
// extractFileStats
let converted = Text::tr(
"upgradeWorld.info.converted",
&[&total.finished_operations()],
);
centered_line(batch, atlas, &converted, x_center, y, GREY);
let sum = Text::tr("upgradeWorld.info.total", &[&total.total_operations()]);
centered_line(batch, atlas, &sum, x_center, y + 9 + 3, GREY);
y += 9 * 2 + 6;
// extractFileFixerCount
let stage = Text::tr(
"upgradeWorld.info.file_fix_stage",
&[&running.finished_operations(), &running.total_operations()],
);
centered_line(batch, atlas, &stage, x_center, y, GREY);
y += 9 + 30 - 5;
// extractTypeText
if let Some(ty) = progress.upgrade_type() {
let label = Text::translate(&ty.label_key(), &[]);
centered_line(batch, atlas, &label, x_center, y, GREY);
}
y += 9 + 3;
file_fixer_bar(batch, atlas, x_center, y, of_type.progress());
y += 7;
// extractTypeProgress: Mth.floor(progress * 100)
let percent = (of_type.progress() * 100.0).floor() as i32;
let text = Text::tr("upgradeWorld.progress.percentage", &[&percent]);
centered_line(batch, atlas, &text, x_center, y, GREY);
} else {
// extractScanning
let scanning = Text::translate("upgradeWorld.info.scanning", &[]);
centered_line(batch, atlas, &scanning, x_center, text_top + 9 + 3, GREY);
}
let (x, y) = file_fixer_cancel(w, h);
let active = !progress.is_canceled();
let hover = crate::ui::hovered(cursor, x, y, 200.0, 20.0);
let label = Text::translate("gui.cancel", &[]);
crate::ui::button(batch, atlas, x, y, 200.0, 20.0, &label, active, hover);
}
#[cfg(test)]
mod tests {
use super::*;
@@ -179,6 +546,12 @@ mod tests {
assert!((smooth(eased, 1.0) - 0.36).abs() < 1e-6);
}
#[test]
fn file_fixer_cancel_sits_below_the_centre() {
assert_eq!(file_fixer_cancel(427.0, 240.0), (113.0, 220.0));
assert_eq!(file_fixer_cancel(426.0, 241.0), (113.0, 220.0));
}
#[test]
fn colours_are_opaque_rgba() {
assert_eq!(opaque(Status::Full.color()), u32::MAX);
@@ -190,3 +563,145 @@ mod tests {
);
}
}
#[cfg(test)]
mod tracker_tests {
use super::*;
const ALIVE: PlayerCheck = PlayerCheck {
outside_build_height: false,
spectator: false,
alive: true,
};
#[test]
fn server_progress_follows_the_listener_like_the_progress_tracker() {
let view = ServerLoadView::default();
assert!(!view.has_progress() && view.server_progress() == 0.0);
// A stage the tracker ignores still counts as progress being made.
view.start(Stage::StartServer, 0);
assert!(view.has_progress() && view.server_progress() == 0.0);
// prepareLevels with no forced chunks: the 10 of preparing, of 59.
view.start(Stage::LoadInitialChunks, 0);
view.finish(Stage::LoadInitialChunks);
assert_eq!(view.server_progress(), 10.0 / 59.0);
// PrepareSpawnTask: the 49 player chunks.
view.start(Stage::LoadPlayerChunks, 49);
view.update(Stage::LoadPlayerChunks, 20, 49);
assert_eq!(view.server_progress(), (10.0 + 20.0) / 59.0);
assert_eq!(view.server_progress(), progress(20));
view.update(Stage::LoadPlayerChunks, 49, 49);
view.finish(Stage::LoadPlayerChunks);
assert_eq!(view.server_progress(), 1.0);
}
#[test]
fn the_status_map_is_read_from_the_north_west_corner() {
let view = ServerLoadView::default();
assert_eq!(view.status(0, 0), None);
let diameter = (STATUS_RADIUS * 2 + 1) as usize;
let mut cells = vec![None; diameter * diameter];
cells[3 * diameter + 5] = Some(Status::Full);
view.set_status(cells);
assert_eq!(view.status(3, 5), Some(Status::Full));
assert_eq!(view.status(5, 3), None);
assert_eq!(view.status(-1, 0), None);
assert_eq!(view.status(diameter as i32, 0), None);
}
#[test]
fn the_load_waits_for_the_server_then_the_players_section() {
let mut tracker = LevelLoadTracker::new(0);
assert!(!tracker.is_active());
tracker.start_client_load(1_000, Reason::NetherPortal);
assert!(tracker.is_active() && tracker.reason == Reason::NetherPortal);
// Nothing is asked of the renderer before the start event, and a
// section shown early counts for nothing.
assert!(!tracker.wants_player_section());
tracker.player_section_ready();
tracker.tick_client_load(1_050, ALIVE);
assert!(!tracker.is_level_ready(1_050));
tracker.loading_packets_received();
assert!(tracker.wants_player_section());
tracker.tick_client_load(1_100, ALIVE);
assert!(!tracker.is_level_ready(1_100));
tracker.player_section_ready();
tracker.tick_client_load(1_150, ALIVE);
assert!(tracker.is_level_ready(1_150));
tracker.finish();
assert!(!tracker.is_active() && !tracker.is_level_ready(2_000));
}
#[test]
fn a_player_who_cannot_see_the_section_does_not_wait_for_it() {
for player in [
PlayerCheck {
outside_build_height: true,
..ALIVE
},
PlayerCheck {
spectator: true,
..ALIVE
},
PlayerCheck {
alive: false,
..ALIVE
},
] {
let mut tracker = LevelLoadTracker::new(0);
tracker.start_client_load(0, Reason::Other);
tracker.loading_packets_received();
tracker.tick_client_load(10, player);
assert!(tracker.is_level_ready(10));
}
}
#[test]
fn the_wait_times_out_after_thirty_seconds_but_only_once_the_server_has_started() {
let mut tracker = LevelLoadTracker::new(0);
tracker.start_client_load(0, Reason::Other);
tracker.tick_client_load(CLIENT_WAIT_TIMEOUT_MS + 1, ALIVE);
assert!(!tracker.is_level_ready(CLIENT_WAIT_TIMEOUT_MS + 1));
tracker.loading_packets_received();
tracker.tick_client_load(CLIENT_WAIT_TIMEOUT_MS, ALIVE);
assert!(!tracker.is_level_ready(CLIENT_WAIT_TIMEOUT_MS));
tracker.tick_client_load(CLIENT_WAIT_TIMEOUT_MS + 1, ALIVE);
assert!(tracker.is_level_ready(CLIENT_WAIT_TIMEOUT_MS + 1));
}
#[test]
fn a_new_worlds_screen_stays_for_the_close_delay() {
let mut tracker = LevelLoadTracker::new(LEVEL_LOAD_CLOSE_DELAY_MS);
tracker.start_client_load(0, Reason::Other);
tracker.loading_packets_received();
tracker.player_section_ready();
tracker.tick_client_load(100, ALIVE);
assert!(!tracker.is_level_ready(599));
assert!(tracker.is_level_ready(600));
}
#[test]
fn a_respawn_names_its_screen_by_the_dimensions() {
let (overworld, nether, end) = (
"minecraft:overworld",
"minecraft:the_nether",
"minecraft:the_end",
);
assert_eq!(
reason_for_respawn(false, nether, overworld),
Reason::NetherPortal
);
assert_eq!(
reason_for_respawn(false, overworld, nether),
Reason::NetherPortal
);
assert_eq!(reason_for_respawn(false, end, overworld), Reason::EndPortal);
assert_eq!(reason_for_respawn(false, end, nether), Reason::NetherPortal);
assert_eq!(
reason_for_respawn(false, overworld, overworld),
Reason::Other
);
// A death never shows a portal.
assert_eq!(reason_for_respawn(true, nether, overworld), Reason::Other);
}
}
+6
View File
@@ -39,6 +39,7 @@ pub mod hud_layout;
pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
pub mod jigsaw_block;
pub mod level_loading;
pub mod light;
pub mod lod;
@@ -50,6 +51,7 @@ pub mod mipmap;
pub mod mod_list;
pub mod model_library;
pub mod models;
pub mod multiline_edit;
pub mod options_file;
pub mod options_fixes;
pub mod options_ui;
@@ -59,6 +61,7 @@ pub mod packs;
pub mod par;
pub mod particles;
pub mod poi_sections;
pub mod post_chain;
pub mod potent_sulfur;
pub mod providers;
pub mod screen_draw;
@@ -77,8 +80,11 @@ pub mod split_layout;
mod sprite_cache;
pub mod sprites;
pub mod streaming;
pub mod structure_block;
pub mod tab_list;
pub mod telemetry;
pub mod test_block;
pub mod test_instance_block;
pub mod testgen;
pub mod title_edition;
pub mod trims;
+125 -160
View File
@@ -7,6 +7,7 @@ use crate::{
mesher::SectionMesh,
streaming::{LodSource, WorkerContext},
};
pub mod cache;
mod mesh;
use horizon_world::registry::BlockStateId;
use std::{
@@ -40,7 +41,14 @@ fn sampling_threads(cores: usize) -> usize {
/// 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.
const MAX_LEVEL: u8 = 10;
pub(crate) const MAX_LEVEL: u8 = 10;
/// 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.
const DETAIL_LEVEL: u8 = 2;
/// The most chunks one detail request asks for, one request each 250 ms:
/// generated ones arrive at the decoration threads' pace, cached ones at once.
const DETAIL_REQUEST: usize = 512;
/// Top solid face and optional fluid face, in absolute block coordinates.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Surface {
@@ -358,7 +366,7 @@ impl TileKey {
pub fn origin(self) -> [i32; 3] {
[self.x * self.size(), 0, self.z * self.size()]
}
fn parent(self) -> Self {
pub(crate) fn parent(self) -> Self {
Self {
x: self.x.div_euclid(2),
z: self.z.div_euclid(2),
@@ -372,6 +380,12 @@ impl TileKey {
level: self.level - 1,
})
}
/// The squared horizontal distance from `camera` to the tile's nearest point.
fn reach(self, camera: [i32; 2]) -> i64 {
let [x, _, z] = self.origin();
let gap = |c: i32, lo: i32| i64::from((lo - c).max(c - lo - self.size()).max(0));
gap(camera[0], x).pow(2) + gap(camera[1], z).pow(2)
}
fn distance(self, camera: [i32; 2]) -> i32 {
let [x, _, z] = self.origin();
(x - camera[0])
@@ -380,27 +394,6 @@ impl TileKey {
.max(camera[1] - z - self.size())
.max(0)
}
fn in_view(self, camera: [i32; 2], yaw: f32) -> bool {
let [x, _, z] = self.origin();
let (sin, cos) = yaw.sin_cos();
let mut forward = f32::NEG_INFINITY;
let mut left = f32::NEG_INFINITY;
let mut right = f32::NEG_INFINITY;
for (dx, dz) in [
(0, 0),
(self.size(), 0),
(0, self.size()),
(self.size(), self.size()),
] {
let (x, z) = ((x + dx - camera[0]) as f32, (z + dz - camera[1]) as f32);
let f = -x * sin + z * cos;
let side = x * cos + z * sin;
forward = forward.max(f);
left = left.max(f * 0.866 + side * 0.5);
right = right.max(f * 0.866 - side * 0.5);
}
forward > 0. && left > 0. && right > 0.
}
}
#[derive(Default)]
struct Plan {
@@ -452,7 +445,9 @@ impl Plan {
previous: &Self,
reduced: bool,
) {
if key.distance(camera) > range {
// The range is a circle, as the renderer's: a tile leaves it, and
// fades out (gfx/lod.rs), as the player moves.
if key.reach(camera) > i64::from(range) * i64::from(range) {
return;
}
self.nodes.insert(key);
@@ -645,7 +640,6 @@ pub struct Lod {
/// Reduced far detail (`set_reduced_detail`).
reduced_detail: bool,
pending: FxHashSet<TileKey>,
sample_nearby: bool,
detail_masks: FxHashMap<TileKey, [u64; 4]>,
detail_request_pending: bool,
detail_request_at: std::time::Instant,
@@ -750,7 +744,9 @@ impl Lod {
.expect("far sampling worker"),
);
}
let mut mesher = mesh::Mesher::default();
// One mesher per thread: each keeps its material cache.
let mut meshers: Vec<_> =
(0..count.max(1)).map(|_| mesh::Mesher::default()).collect();
'work: loop {
for (key, columns) in sampler.drain_details() {
if tx
@@ -810,24 +806,48 @@ impl Lod {
covered,
bounds,
} => {
let mut meshes = Vec::with_capacity(keys.len());
for key in keys {
if worker.stop.load(Ordering::Relaxed) {
break 'work;
}
let finer = finer.remove(&key).unwrap_or_default();
let input = snapshot.mesh_input(key, finer);
let mut mesh = mesher.mesh(
input.key,
&input.columns(),
&covered,
bounds,
&mesh_context,
&input.neighbors,
&input.finer,
);
mesh.sort_solid();
meshes.push((input.key, mesh));
let work: Vec<_> = keys
.into_iter()
.map(|key| (key, finer.remove(&key).unwrap_or_default()))
.collect();
let per_thread = work.len().div_ceil(meshers.len()).max(1);
let (inputs, cut) = (&snapshot, &covered);
let (stop, context) = (&worker.stop, &mesh_context);
let meshes: Vec<_> = thread::scope(|scope| {
let threads: Vec<_> = work
.chunks(per_thread)
.zip(meshers.iter_mut())
.map(|(work, mesher)| {
scope.spawn(move || {
let mut meshes = Vec::with_capacity(work.len());
for (key, finer) in work {
if stop.load(Ordering::Relaxed) {
break;
}
let input = inputs.mesh_input(*key, finer.clone());
let mut mesh = mesher.mesh(
input.key,
&input.columns(),
cut,
bounds,
context,
&input.neighbors,
&input.finer,
);
mesh.sort_solid();
meshes.push((input.key, mesh));
}
meshes
})
})
.collect();
threads
.into_iter()
.flat_map(|thread| thread.join().unwrap_or_default())
.collect()
});
if worker.stop.load(Ordering::Relaxed) {
break 'work;
}
Done::Mesh {
active: snapshot.active,
@@ -840,7 +860,11 @@ impl Lod {
break;
}
}
// Under the lock the samplers wait on: one between its check
// and its wait would miss the wake and never be joined.
let jobs = worker.jobs.lock();
worker.stop.store(true, Ordering::Relaxed);
drop(jobs);
worker.wake.notify_all();
for handle in sampling {
let _ = handle.join();
@@ -861,7 +885,6 @@ impl Lod {
reduced_detail: false,
plan: Plan::default(),
pending: FxHashSet::default(),
sample_nearby: true,
detail_masks: FxHashMap::default(),
detail_request_pending: false,
detail_request_at: std::time::Instant::now(),
@@ -1119,7 +1142,7 @@ impl Lod {
.active
.iter()
.filter(|key| {
key.level <= 1
key.level <= DETAIL_LEVEL
&& !self.plan.split.contains(key)
&& !(key.level == 0 && self.covered.contains(&(key.x, key.z)))
&& self.detail_masks.get(key) != Some(&[u64::MAX; 4])
@@ -1156,7 +1179,7 @@ impl Lod {
}
}
}
if chunks.len() >= 32 {
if chunks.len() >= DETAIL_REQUEST {
break;
}
}
@@ -1166,7 +1189,7 @@ impl Lod {
let dz = i64::from(z * 16 + 8 - camera[1]);
dx * dx + dz * dz
});
chunks.truncate(32);
chunks.truncate(DETAIL_REQUEST);
if !chunks.is_empty() {
self.detail_request_pending = true;
self.shared
@@ -1183,7 +1206,6 @@ impl Lod {
pub fn update(
&mut self,
camera: [f64; 3],
yaw: f32,
detail: i32,
distance: u32,
covered: Option<FxHashSet<(i32, i32)>>,
@@ -1192,14 +1214,6 @@ impl Lod {
(((camera[1] - f64::from(MIN_Y + crate::chunk::HEIGHT)).clamp(0., 16384.)) as i32 / 32)
* 32;
let camera = [camera[0].floor() as i32, camera[2].floor() as i32];
// Fill the view ahead first; a nearest-only ring spends most work out
// of sight while visible forests remain undecorated noise terrain.
let ahead = (detail * 16 + 128).max(elevation * 2).min(4096) as f32;
let yaw = yaw.to_radians();
let focus = [
camera[0] - (yaw.sin() * ahead) as i32,
camera[1] + (yaw.cos() * ahead) as i32,
];
// Replan at chunk boundaries, not on every camera movement.
let area = (
[camera[0].div_euclid(16), camera[1].div_euclid(16)],
@@ -1232,10 +1246,8 @@ impl Lod {
if !self.meshing {
crate::spans::time("lod_select", || self.select());
}
crate::spans::time("lod_request", || self.request_detail(focus));
crate::spans::time("lod_wanted", || {
self.request_samples(camera, focus, yaw, detail, elevation)
});
crate::spans::time("lod_request", || self.request_detail(camera));
crate::spans::time("lod_wanted", || self.request_samples(camera));
meshes
}
fn receive(&mut self, meshes: &mut Vec<(TileKey, SectionMesh)>) {
@@ -1389,14 +1401,7 @@ impl Lod {
}
}
}
fn request_samples(
&mut self,
camera: [i32; 2],
focus: [i32; 2],
yaw: f32,
detail: i32,
elevation: i32,
) {
fn request_samples(&mut self, camera: [i32; 2]) {
// Every sample slot busy: no tile can be requested this frame.
let free = if self.held() {
usize::from(
@@ -1417,80 +1422,36 @@ impl Lod {
if self.held() {
self.held_sample_at = std::time::Instant::now();
}
// Load desired nearby leaves directly, alongside broad fallback coverage.
// Waiting for every ancestor/sibling made full detail take minutes to appear.
let mut wanted = Vec::new();
for &root in &self.plan.roots {
if self.plan.nodes.contains(&root) && !self.ready.contains_key(&root) {
wanted.push((0, 0, root));
}
}
for &key in &self.plan.nodes {
if !self.plan.split.contains(&key) && !self.ready.contains_key(&key) {
wanted.push((
if key.in_view(camera, yaw) { 1 } else { 3 },
i64::from(key.distance(focus)) * 4 + i64::from(key.distance(camera)),
key,
));
}
}
let mut fallback = Vec::new();
for &parent in &self.active {
if self.plan.split.contains(&parent) {
for child in parent.children() {
if self.plan.nodes.contains(&child) && !self.ready.contains_key(&child) {
// Covered near terrain bounds projected error. Using
// zero distance for a parent touching the camera kept
// refining it while huge visible walls waited elsewhere.
let priority =
i64::from(child.distance(camera).max((detail * 16).max(elevation)))
* 1024
/ i64::from(child.size());
fallback.push((
if child.in_view(camera, yaw) { 2 } else { 4 },
priority,
child,
));
}
}
}
}
// Only the first `free` keys not already pending are taken. A key is
// listed at most twice (as a root and as a node), so the best
// `2 * (sample_jobs + pending)` entries hold them, in order.
let best = 2 * (self.sample_jobs + self.pending.len());
// Only the tiles the plan keeps, each at the level its distance
// calls for, nearest first, whichever way the player looks: far
// terrain grows outward from them in rings. Coarse tiles first under
// fine ones near the player cost a far tile's work for terrain that
// fine tiles replace moments later.
let mut wanted: Vec<_> = self
.plan
.nodes
.iter()
.filter(|key| !self.plan.split.contains(key) && !self.ready.contains_key(key))
.map(|&key| (i64::from(key.distance(camera)), key))
.collect();
// Only the first `free` keys not already pending are taken: the best
// `sample_jobs + pending` entries hold them, in order.
let best = self.sample_jobs + self.pending.len();
if wanted.len() > best {
wanted.select_nth_unstable(best);
wanted.truncate(best);
}
wanted.sort_unstable();
// Alternate one nearby final tile with one broad refinement tile.
if let Some(fallback) = fallback
.into_iter()
.filter(|(_, _, k)| !self.pending.contains(k))
.min()
{
let index = if self.sample_nearby {
wanted
.iter()
.position(|(_, _, k)| !self.pending.contains(k))
.map_or(0, |i| i + 1)
} else {
0
};
wanted.insert(index, fallback);
}
let mut unique = FxHashSet::default();
let wanted: Vec<_> = wanted
.into_iter()
.map(|(_, _, key)| key)
.map(|(_, key)| key)
.filter(|key| !self.pending.contains(key) && unique.insert(*key))
.collect();
if !wanted.is_empty() {
let mut jobs = self.shared.jobs.lock().unwrap();
for key in wanted.into_iter().take(free) {
self.pending.insert(key);
self.sample_nearby = !self.sample_nearby;
jobs.push_back(Job::Sample(key));
}
drop(jobs);
@@ -1633,7 +1594,10 @@ impl Snapshot {
}
impl Drop for Lod {
fn drop(&mut self) {
// Under the jobs lock, as the waiters check it: see the samplers' stop.
let jobs = self.shared.jobs.lock();
self.shared.stop.store(true, Ordering::Relaxed);
drop(jobs);
self.shared.wake.notify_all();
if let Some(thread) = self.thread.take() {
let _ = thread.join();
@@ -1739,8 +1703,11 @@ mod tests {
}
}
let (full, reduced) = (leaves(&full), leaves(&reduced));
// At 64 chunks most tiles are the near area's finest, the
// same in both: about half there, well under from 128.
let bound = if distance == 64 { full * 21 / 20 } else { full };
assert!(
reduced * 2 < full,
reduced * 2 < bound,
"{reduced} of {full} tiles at {distance}"
);
}
@@ -1875,8 +1842,9 @@ mod tests {
let before = Plan::new([0, 0], 2, 256);
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false);
let mut retained = 0;
let range = i64::from(256 * 16);
for key in &before.split {
if key.distance([16, 0]) < key.size() * 10 {
if key.distance([16, 0]) < key.size() * 10 && key.reach([16, 0]) <= range * range {
assert!(after.split.contains(key));
retained += 1;
}
@@ -2303,7 +2271,7 @@ mod tests {
let await_meshes = |lod: &mut Lod| {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
let meshes = lod.update(camera, 0.0, 2, 64, None);
let meshes = lod.update(camera, 2, 64, None);
if !meshes.is_empty() {
return meshes;
}
@@ -2321,7 +2289,7 @@ mod tests {
lod.ready.insert(child, lod.ready[&key].clone());
}
lod.reselect = true;
assert!(lod.update(camera, 0.0, 2, 64, None).is_empty());
assert!(lod.update(camera, 2, 64, None).is_empty());
assert!(
lod.active.contains(&key),
"parent retired before child meshes"
@@ -2351,7 +2319,7 @@ mod tests {
lod.replanned = true;
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
loop {
lod.update(camera, 0., 2, 64, None);
lod.update(camera, 2, 64, None);
if !lod.meshing && lod.active.is_empty() {
break;
}
@@ -2389,33 +2357,26 @@ mod tests {
level: 0,
})
.collect();
lod.sample_nearby = true;
lod.detail_request_pending = true;
lod.meshing = true;
lod.update(camera, 0.0, 2, 64, Some(FxHashSet::default()));
lod.update(camera, 2, 64, Some(FxHashSet::default()));
let Some(Job::Sample(nearby)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("nearby detail was not scheduled");
};
assert_eq!(nearby.level, 0);
assert!(
nearby.origin()[2] > -256 + 64,
"detail should load ahead of the view"
assert_eq!(
nearby.distance([-256, -256]),
0,
"the nearest tiles load first"
);
lod.finish_sample(nearby, vec![Column::default(); 256]);
lod.update(camera, 0.0, 2, 64, Some(FxHashSet::default()));
let Some(Job::Sample(fallback)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("fallback refinement was starved");
};
assert!(fallback.level > 0);
lod.pending.remove(&fallback);
lod.sample_nearby = true;
lod.update(camera, 90.0, 2, 64, Some(FxHashSet::default()));
let Some(Job::Sample(turned)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("turned view was not scheduled");
lod.update(camera, 2, 64, Some(FxHashSet::default()));
let Some(Job::Sample(next)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("the next ring was not scheduled");
};
assert!(
turned.origin()[0] < -256 - 64,
"loading priority should follow a turn"
!lod.plan.split.contains(&next) && next.distance([-256, -256]) <= 64,
"the next nearest final tile, never a coarse one under it: {next:?}"
);
lod.shared.jobs.lock().unwrap().clear();
let near = TileKey {
@@ -2448,17 +2409,19 @@ mod tests {
level: 0,
})
.collect();
lod.sample_nearby = false;
lod.update(camera, 0., 2, 64, Some(FxHashSet::default()));
let Some(Job::Sample(broad)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("projected-error refinement missing")
lod.update(camera, 2, 64, Some(FxHashSet::default()));
let Some(Job::Sample(first)) = lod.shared.jobs.lock().unwrap().pop_front() else {
panic!("no final tile scheduled")
};
assert_eq!(
broad.level, 5,
"a near tile touching the camera must not starve a huge visible distant tile"
(first.level, first.parent()),
(1, near),
"the nearest parent's final tiles come first"
);
lod.shared.jobs.lock().unwrap().clear();
lod.active = (0..40).map(|x| TileKey { x, z: 0, level: 0 }).collect();
lod.active = (0..DETAIL_REQUEST as i32 + 8)
.map(|x| TileKey { x, z: 0, level: 0 })
.collect();
lod.plan.split.clear();
lod.detail_masks.clear();
lod.covered = [(0, 0)].into_iter().collect();
@@ -2471,7 +2434,9 @@ mod tests {
};
assert_eq!(
chunks,
(1..=32).map(|x| (x, 0)).collect::<Vec<_>>(),
(1..=DETAIL_REQUEST as i32)
.map(|x| (x, 0))
.collect::<Vec<_>>(),
"one unfinished fine tile must not starve neighbouring requests"
);
@@ -2488,7 +2453,7 @@ mod tests {
lod.set_near_loading(near_loading);
lod.shared.jobs.lock().unwrap().clear();
lod.pending.clear();
lod.update([-256., 64., -256.], 0., 2, 64, Some(FxHashSet::default()));
lod.update([-256., 64., -256.], 2, 64, Some(FxHashSet::default()));
let jobs = lod.shared.jobs.lock().unwrap();
jobs.iter()
.filter(|job| matches!(job, Job::Sample(_)))
@@ -0,0 +1,356 @@
//! Decorated far-terrain chunks kept on disk, beside a dimension's save, so a
//! world opened again shows them without generating them again. One append-only
//! file per 32x32 chunk region; a file written by another build, block registry
//! or format is cleared on first use.
use super::{Column, Span, Surface};
use crate::fast_hash::FxHashMap;
use horizon_world::registry::BlockStateId;
use std::{
fs::{File, OpenOptions},
io::{Read, Seek, SeekFrom, Write},
path::PathBuf,
sync::{Arc, Mutex},
};
const MAGIC: &[u8; 4] = b"HLOD";
const FORMAT: u32 = 1;
const HEADER: u64 = 16;
/// A record: chunk x and z, then the deflated columns' length.
const RECORD: u64 = 12;
/// Columns of a chunk are a few kilobytes deflated; anything larger is damage.
const MAX_RECORD: u32 = 1 << 22;
type Columns = Arc<[Column; 256]>;
struct Region {
file: File,
/// Each chunk's latest record: the offset of its deflated columns, and
/// their length.
index: FxHashMap<(i32, i32), (u64, u32)>,
}
pub struct DiskCache {
dir: PathBuf,
key: u64,
// ponytail: one lock for every region, with file reads under it; per-region
// locks if the sampling threads ever wait on it.
regions: Mutex<FxHashMap<(i32, i32), Option<Region>>>,
}
impl DiskCache {
/// `key`: what the cached columns depend on (`build_key`); files with
/// another are cleared.
pub fn new(dir: PathBuf, key: u64) -> Self {
Self {
dir,
key,
regions: Mutex::default(),
}
}
pub fn get(&self, x: i32, z: i32) -> Option<Columns> {
let mut regions = self.regions.lock().ok()?;
let region = self.region(&mut regions, x, z, false)?;
let &(offset, len) = region.index.get(&(x, z))?;
let mut bytes = vec![0; len as usize];
region.file.seek(SeekFrom::Start(offset)).ok()?;
region.file.read_exact(&mut bytes).ok()?;
drop(regions);
decode(&bytes).map(Arc::new)
}
fn contains(&self, x: i32, z: i32) -> bool {
let Ok(mut regions) = self.regions.lock() else {
return false;
};
self.region(&mut regions, x, z, false)
.is_some_and(|region| region.index.contains_key(&(x, z)))
}
/// Writes a chunk not cached yet. One already cached stays as it is: the
/// build key clears stale ones, and a generation batch hands back
/// neighbours cached before, which appended again grew the files each
/// session.
pub fn put(&self, x: i32, z: i32, columns: &[Column; 256]) {
if self.contains(x, z) {
return;
}
let bytes = encode(columns);
let Ok(mut regions) = self.regions.lock() else {
return;
};
let Some(region) = self.region(&mut regions, x, z, true) else {
return;
};
// Another sampler's thread may have written it while this one encoded.
if region.index.contains_key(&(x, z)) {
return;
}
let mut record = Vec::with_capacity(RECORD as usize + bytes.len());
record.extend_from_slice(&x.to_le_bytes());
record.extend_from_slice(&z.to_le_bytes());
record.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
record.extend_from_slice(&bytes);
let Ok(end) = region.file.seek(SeekFrom::End(0)) else {
return;
};
// One write, so a crash leaves at most a torn last record, which
// the next open cuts off.
if region.file.write_all(&record).is_ok() {
region
.index
.insert((x, z), (end + RECORD, bytes.len() as u32));
}
}
fn region<'a>(
&self,
regions: &'a mut FxHashMap<(i32, i32), Option<Region>>,
x: i32,
z: i32,
create: bool,
) -> Option<&'a mut Region> {
let at = (x >> 5, z >> 5);
if create && matches!(regions.get(&at), Some(None)) {
regions.remove(&at);
}
regions
.entry(at)
.or_insert_with(|| self.open(at, create))
.as_mut()
}
fn open(&self, (rx, rz): (i32, i32), create: bool) -> Option<Region> {
let path = self.dir.join(format!("r.{rx}.{rz}.hlod"));
if !create && !path.exists() {
return None;
}
if create {
std::fs::create_dir_all(&self.dir).ok()?;
}
let mut file = OpenOptions::new()
.read(true)
.write(true)
.create(create)
.truncate(false)
.open(&path)
.ok()?;
let mut header = [0; HEADER as usize];
let current = file.read_exact(&mut header).is_ok()
&& header[..4] == *MAGIC
&& header[4..8] == FORMAT.to_le_bytes()
&& header[8..] == self.key.to_le_bytes();
let mut index = FxHashMap::default();
if current {
let size = file.metadata().ok()?.len();
let mut at = HEADER;
let mut record = [0; RECORD as usize];
while at + RECORD <= size {
file.seek(SeekFrom::Start(at)).ok()?;
file.read_exact(&mut record).ok()?;
let field = |i: usize| u32::from_le_bytes(record[i..i + 4].try_into().unwrap());
let len = field(8);
if len > MAX_RECORD || at + RECORD + u64::from(len) > size {
break;
}
index.insert((field(0) as i32, field(4) as i32), (at + RECORD, len));
at += RECORD + u64::from(len);
}
if at < size {
file.set_len(at).ok()?;
}
} else {
file.set_len(0).ok()?;
file.seek(SeekFrom::Start(0)).ok()?;
let mut header = Vec::with_capacity(HEADER as usize);
header.extend_from_slice(MAGIC);
header.extend_from_slice(&FORMAT.to_le_bytes());
header.extend_from_slice(&self.key.to_le_bytes());
file.write_all(&header).ok()?;
}
Some(Region { file, index })
}
}
/// What cached columns depend on: this build (its commit, or for a local
/// build the executable's own time, so a rebuilt generator starts afresh),
/// the block states and the biomes.
pub fn build_key(states: usize, biomes: usize) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::hash::DefaultHasher::new();
FORMAT.hash(&mut hasher);
env!("CARGO_PKG_VERSION").hash(&mut hasher);
match option_env!("HORIZON_COMMIT") {
Some(commit) => commit.hash(&mut hasher),
None => std::env::current_exe()
.and_then(|exe| exe.metadata()?.modified())
.ok()
.hash(&mut hasher),
}
states.hash(&mut hasher);
biomes.hash(&mut hasher);
hasher.finish()
}
fn encode(columns: &[Column; 256]) -> Vec<u8> {
let mut out = Vec::new();
for column in columns {
match column.surface {
None => out.push(0),
Some(s) => {
out.push(1 + u8::from(s.water.is_some()));
out.extend_from_slice(&s.height.to_le_bytes());
out.extend_from_slice(&s.state.0.to_le_bytes());
out.extend_from_slice(&s.biome.to_le_bytes());
if let Some((height, state)) = s.water {
out.extend_from_slice(&height.to_le_bytes());
out.extend_from_slice(&state.0.to_le_bytes());
}
}
}
out.extend_from_slice(&(column.spans.len() as u16).to_le_bytes());
for span in &column.spans {
out.extend_from_slice(&span.min.to_le_bytes());
out.extend_from_slice(&span.max.to_le_bytes());
out.extend_from_slice(&span.state.0.to_le_bytes());
out.extend_from_slice(&span.biome.to_le_bytes());
out.extend_from_slice(&span.light.to_le_bytes());
}
}
let mut deflate =
flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default());
// Writing to a Vec cannot fail.
deflate.write_all(&out).unwrap();
deflate.finish().unwrap()
}
fn decode(bytes: &[u8]) -> Option<[Column; 256]> {
let mut raw = Vec::new();
flate2::read::DeflateDecoder::new(bytes)
.take(u64::from(MAX_RECORD) * 8)
.read_to_end(&mut raw)
.ok()?;
let mut at = 0;
let mut take = |n: usize| {
let field = raw.get(at..at + n)?;
at += n;
Some(field)
};
let mut columns: [Column; 256] = std::array::from_fn(|_| Column::default());
for column in &mut columns {
let i32_ = |b: &[u8]| i32::from_le_bytes(b.try_into().unwrap());
let u32_ = |b: &[u8]| u32::from_le_bytes(b.try_into().unwrap());
let u16_ = |b: &[u8]| u16::from_le_bytes(b.try_into().unwrap());
let i16_ = |b: &[u8]| i16::from_le_bytes(b.try_into().unwrap());
let tag = take(1)?[0];
if tag > 2 {
return None;
}
if tag > 0 {
let height = i32_(take(4)?);
let state = BlockStateId(u32_(take(4)?));
let biome = u16_(take(2)?);
let water = if tag == 2 {
Some((i32_(take(4)?), BlockStateId(u32_(take(4)?))))
} else {
None
};
column.surface = Some(Surface {
height,
state,
water,
biome,
});
}
let count = u16_(take(2)?);
column.spans.reserve(count.into());
for _ in 0..count {
let span = Span {
min: i16_(take(2)?),
max: i16_(take(2)?),
state: BlockStateId(u32_(take(4)?)),
biome: u16_(take(2)?),
light: u16_(take(2)?),
};
if span.min >= span.max {
return None;
}
column.spans.push(span);
}
}
(at == raw.len()).then_some(columns)
}
#[cfg(test)]
mod tests {
use super::*;
fn columns(seed: u32) -> [Column; 256] {
std::array::from_fn(|i| {
let i = i as u32 + seed;
Column {
surface: (!i.is_multiple_of(3)).then(|| Surface {
height: 60 + (i % 7) as i32,
state: BlockStateId(i),
water: (i.is_multiple_of(2)).then_some((63, BlockStateId(9))),
biome: (i % 5) as u16,
}),
spans: (0..i % 4)
.map(|n| Span {
min: (n * 10) as i16 - 64,
max: (n * 10 + 4) as i16 - 64,
state: BlockStateId(n + i),
biome: 2,
light: 240,
})
.collect(),
}
})
}
#[test]
fn chunks_survive_reopening_and_a_torn_last_record() {
let dir = std::env::temp_dir().join(format!("horizon-lod-cache-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let cache = DiskCache::new(dir.clone(), 7);
assert!(cache.get(3, -40).is_none(), "nothing cached yet");
cache.put(3, -40, &columns(0));
cache.put(4, -40, &columns(1));
let path = dir.join("r.0.-2.hlod");
let size = std::fs::metadata(&path).unwrap().len();
// A chunk cached already is not appended again.
cache.put(3, -40, &columns(2));
assert_eq!(*cache.get(3, -40).unwrap(), columns(0));
assert_eq!(std::fs::metadata(&path).unwrap().len(), size);
drop(cache);
let file = OpenOptions::new().write(true).open(&path).unwrap();
file.set_len(size + 7).unwrap();
drop(file);
let cache = DiskCache::new(dir.clone(), 7);
assert_eq!(*cache.get(3, -40).unwrap(), columns(0));
assert_eq!(*cache.get(4, -40).unwrap(), columns(1));
assert_eq!(
std::fs::metadata(&path).unwrap().len(),
size,
"torn tail cut"
);
drop(cache);
// Another build's files are cleared, not read.
let cache = DiskCache::new(dir.clone(), 8);
assert!(cache.get(3, -40).is_none());
cache.put(5, -40, &columns(3));
assert!(cache.get(4, -40).is_none());
assert_eq!(*cache.get(5, -40).unwrap(), columns(3));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn damaged_records_read_as_missing() {
let bytes = encode(&columns(0));
assert_eq!(decode(&bytes).unwrap(), columns(0));
assert!(decode(&bytes[..bytes.len() / 2]).is_none());
assert!(decode(b"not deflate").is_none());
}
}
+198 -65
View File
@@ -74,10 +74,18 @@ impl Mesher {
covered_bounds,
neighbors,
finer,
|span, direction| self.material(span, direction, context),
|span, direction, at| self.material(span, direction, at, context),
)
}
fn material(&mut self, span: &Span, direction: Direction, context: &WorkerContext) -> Material {
/// `at`: the block column the face covers, for the grass colours that
/// change across a biome (swamps).
fn material(
&mut self,
span: &Span,
direction: Direction,
at: [i32; 2],
context: &WorkerContext,
) -> Material {
let styles = *self
.styles
.entry((span.state, direction as u8))
@@ -179,7 +187,11 @@ impl Mesher {
face.map(|face| {
let rgb = match face.tint {
Tint::None => 0xffffff,
Tint::Grass => biome.grass,
Tint::Grass => {
context
.biomes
.grass(span.biome, f64::from(at[0]), f64::from(at[1]))
}
Tint::Foliage => biome.foliage,
Tint::Dry => biome.dry_foliage,
Tint::Water => biome.water,
@@ -256,7 +268,7 @@ fn mesh_columns(
columns: &[Column],
covered: &FxHashSet<(i32, i32)>,
covered_bounds: Option<[i32; 4]>,
material: impl FnMut(&Span, Direction) -> Material,
material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
) -> SectionMesh {
mesh_columns_with_neighbors(key, columns, covered, covered_bounds, &[], material)
}
@@ -267,7 +279,7 @@ fn mesh_columns_with_neighbors(
covered: &FxHashSet<(i32, i32)>,
covered_bounds: Option<[i32; 4]>,
neighbors: &[Column],
material: impl FnMut(&Span, Direction) -> Material,
material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
) -> SectionMesh {
assert!(neighbors.is_empty() || neighbors.len() == 4 * CELLS);
let stride = key.size() / CELLS as i32;
@@ -293,7 +305,7 @@ fn mesh_columns_with_edges(
covered_bounds: Option<[i32; 4]>,
neighbors: &[Vec<EdgeColumn>; 4],
finer: &[TileKey],
mut material: impl FnMut(&Span, Direction) -> Material,
mut material: impl FnMut(&Span, Direction, [i32; 2]) -> Material,
) -> SectionMesh {
assert_eq!(columns.len(), CELLS * CELLS);
let mut count = CELLS * CELLS;
@@ -325,17 +337,31 @@ fn mesh_columns_with_edges(
neighbors,
stride,
|x, z| cell_step(x, z) == stride,
|span| material(span, Direction::Up).fluid != 0,
|span| material(span, Direction::Up, [ox, oz]).fluid != 0,
);
// A cell's faces take the colours at its centre. The tile's neighbours'
// columns only hide faces, so their colours never show.
let centre = |i: usize| {
if i < CELLS * CELLS {
[
ox + (i % CELLS) as i32 * stride + stride / 2,
oz + (i / CELLS) as i32 * stride + stride / 2,
]
} else {
[ox, oz]
}
};
let prepared: Vec<Vec<Segment<'_>>> = filled
.iter()
.map(|column| &**column)
.chain(neighbors.iter().flatten().map(|edge| &edge.column))
.map(|column| {
.enumerate()
.map(|(index, column)| {
let at = centre(index);
let materials: Vec<_> = column
.spans
.iter()
.map(|s| material(s, Direction::Up))
.map(|s| material(s, Direction::Up, at))
.collect();
column
.spans
@@ -425,7 +451,7 @@ fn mesh_columns_with_edges(
u1: x1,
v1: z1,
},
material(segment.span, Direction::Down),
material(segment.span, Direction::Down, centre(z * CELLS + x)),
);
}
for (direction, nx, nz, edge, nw, plane, u0, u1) in [
@@ -470,21 +496,25 @@ fn mesh_columns_with_edges(
x1,
),
] {
let side = material(segment.span, direction);
let side = material(segment.span, direction, centre(z * CELLS + x));
// Near meshes may have culled against the real neighbor.
// Keep a finite seal where their full-detail area begins.
// Keep a finite seal where their full-detail area begins:
// opaque only, as the near water carries on into the far
// water and a seal shows through it as a wall.
if is_covered(nw[0], nw[1]) {
add_side(
&mut faces,
direction,
plane,
u0,
u1,
segment,
&[],
side,
own_sky,
);
if segment.material.opaque {
add_side(
&mut faces,
direction,
plane,
u0,
u1,
segment,
&[],
side,
own_sky,
);
}
} else if !edge {
add_side(
&mut faces, direction, plane, u0, u1, segment, column, side,
@@ -597,7 +627,7 @@ mod refinement_tests {
None,
&std::array::from_fn(|_| Vec::new()),
&[fine],
|_, _| Material {
|_, _, _| Material {
faces: [
Some(Paint {
layer: 1,
@@ -646,13 +676,15 @@ mod refinement_tests {
}
}
/// The tile's columns with the gaps no neighbour's open air reaches filled
/// with the material below them, or the fluid above them (kelp and seagrass
/// leave gaps in water): a cave seen from nowhere adds no faces. A
/// gap is open when a neighbouring column's top lies below the gap's top, so
/// that column's open air looks into it. Unknown neighbours, and cells that
/// `whole` rejects (they meet full-detail or finer terrain), count as open;
/// those cells keep their gaps.
/// The tile's columns with the gaps no open air reaches filled with the
/// material below them, or the fluid above them (kelp and seagrass leave gaps
/// in water): a cave seen from nowhere adds no faces. A gap is open when a
/// neighbouring column's top lies below the gap's top, so that column's open
/// air looks into it, when a gap of the tile's neighbour meets it, or when it
/// meets an open gap of a neighbouring column: the air under a canopy reaches
/// the sky through any clearing, where filling it raised pillars of the floor
/// block. Unknown neighbours, and cells that `whole` rejects (they meet
/// full-detail or finer terrain), count as open; those cells keep their gaps.
fn fill_buried_gaps<'a>(
columns: &'a [Column],
neighbors: &[Vec<EdgeColumn>; 4],
@@ -669,6 +701,18 @@ fn fill_buried_gaps<'a>(
.find(|span| span.min < span.max)
.map_or(i16::MIN, |span| span.max)
};
// Each column's gaps: the index of the span above, the gap's bottom and top.
let gaps_of = |column: &Column| -> Vec<(usize, i16, i16)> {
column
.spans
.windows(2)
.enumerate()
.filter(|(_, pair)| pair[0].max < pair[1].min)
.map(|(k, pair)| (k + 1, pair[0].max, pair[1].min))
.collect()
};
let meets =
|(lo, hi): (i16, i16), (other_lo, other_hi): (i16, i16)| lo < other_hi && other_lo < hi;
let tops: Vec<_> = (0..CELLS * CELLS)
.map(|i| {
if whole(i % CELLS, i / CELLS) {
@@ -678,57 +722,110 @@ fn fill_buried_gaps<'a>(
}
})
.collect();
let edge = |side: usize, cell: usize| {
let gaps: Vec<_> = (0..CELLS * CELLS)
.map(|i| {
if whole(i % CELLS, i / CELLS) {
gaps_of(&columns[i])
} else {
Vec::new()
}
})
.collect();
let touching = |side: usize, cell: usize| {
let (start, end) = (cell as i32 * stride, (cell as i32 + 1) * stride);
neighbors[side]
.iter()
.filter(|edge| edge.start < end && start < edge.end)
.filter(move |edge| edge.start < end && start < edge.end)
};
let edge = |side: usize, cell: usize| {
touching(side, cell)
.map(|edge| top(&edge.column))
.min()
.unwrap_or(i16::MIN)
};
// A gap of the tile's neighbour may be open through tiles this one can't
// see: it counts as open.
let edge_gap = |side: usize, cell: usize, gap: (i16, i16)| {
touching(side, cell).any(|edge| {
gaps_of(&edge.column)
.iter()
.any(|&(_, lo, hi)| meets((lo, hi), gap))
})
};
let around = |i: usize| {
let (x, z) = (i % CELLS, i / CELLS);
[
(x > 0).then(|| i - 1),
(x + 1 < CELLS).then(|| i + 1),
(z > 0).then(|| i - CELLS),
(z + 1 < CELLS).then(|| i + CELLS),
]
.into_iter()
.flatten()
};
let mut open: Vec<Vec<bool>> = gaps.iter().map(|g| vec![false; g.len()]).collect();
let mut reached = Vec::new();
for i in 0..CELLS * CELLS {
let (x, z) = (i % CELLS, i / CELLS);
let lowest = [
if x > 0 { tops[i - 1] } else { edge(0, z) },
if x + 1 < CELLS {
tops[i + 1]
} else {
edge(1, z)
},
if z > 0 { tops[i - CELLS] } else { edge(2, x) },
if z + 1 < CELLS {
tops[i + CELLS]
} else {
edge(3, x)
},
]
.into_iter()
.min()
.unwrap_or(i16::MIN);
for (g, &(_, lo, hi)) in gaps[i].iter().enumerate() {
let gap = (lo, hi);
if hi > lowest
|| (x == 0 && edge_gap(0, z, gap))
|| (x + 1 == CELLS && edge_gap(1, z, gap))
|| (z == 0 && edge_gap(2, x, gap))
|| (z + 1 == CELLS && edge_gap(3, x, gap))
{
open[i][g] = true;
reached.push((i, g));
}
}
}
while let Some((i, g)) = reached.pop() {
let (_, lo, hi) = gaps[i][g];
for j in around(i) {
for (h, &(_, other_lo, other_hi)) in gaps[j].iter().enumerate() {
if !open[j][h] && meets((lo, hi), (other_lo, other_hi)) {
open[j][h] = true;
reached.push((j, h));
}
}
}
}
columns
.iter()
.enumerate()
.map(|(i, column)| {
let (x, z) = (i % CELLS, i / CELLS);
if column.spans.len() < 2 || !whole(x, z) {
return Cow::Borrowed(column);
}
let open = [
if x > 0 { tops[i - 1] } else { edge(0, z) },
if x + 1 < CELLS {
tops[i + 1]
} else {
edge(1, z)
},
if z > 0 { tops[i - CELLS] } else { edge(2, x) },
if z + 1 < CELLS {
tops[i + CELLS]
} else {
edge(3, x)
},
]
.into_iter()
.min()
.unwrap_or(i16::MIN);
let buried = |below: &Span, above: &Span| below.max < above.min && above.min <= open;
if !column
.spans
.windows(2)
.any(|pair| buried(&pair[0], &pair[1]))
{
if open[i].iter().all(|&open| open) {
return Cow::Borrowed(column);
}
let mut filled = Column {
surface: column.surface,
spans: Vec::with_capacity(column.spans.len()),
};
for &span in &column.spans {
for (k, &span) in column.spans.iter().enumerate() {
let mut span = span;
if let Some(below) = filled.spans.last_mut()
&& buried(below, &span)
{
let buried = gaps[i]
.iter()
.zip(&open[i])
.any(|(&(above, _, _), &open)| above == k && !open);
if buried && let Some(below) = filled.spans.last_mut() {
if fluid(&span) {
span.min = below.max;
} else {
@@ -919,7 +1016,7 @@ mod tests {
})
.collect()
}
fn material(span: &Span, _: Direction) -> Material {
fn material(span: &Span, _: Direction, _: [i32; 2]) -> Material {
let water = span.state.0 == 2;
Material {
faces: [
@@ -1117,6 +1214,42 @@ mod tests {
assert!(cave[0].iter().all(|v| v.light == 0));
}
#[test]
fn air_under_a_canopy_stays_open_through_a_clearing() {
let column = |spans: Vec<Span>| Column {
surface: None,
spans,
};
// The tile's neighbours are solid hills: no open air from the sides.
let hill = || EdgeColumn {
start: 0,
end: CELLS as i32,
column: column(vec![span(0, 20, 1)]),
};
let neighbors = [vec![hill()], vec![hill()], vec![hill()], vec![hill()]];
let fill = |columns: &[Column]| -> Vec<Vec<Span>> {
fill_buried_gaps(columns, &neighbors, 1, |_, _| true, |_| false)
.iter()
.map(|filled| filled.spans.clone())
.collect()
};
let mut forest = vec![column(vec![span(0, 5, 1), span(10, 14, 3)]); CELLS * CELLS];
// A closed canopy hides the floor: filling it under there shows nowhere.
assert!(fill(&forest).iter().all(|spans| spans[0].max == 10));
// One clearing lets the sky in: the air under every tree stays air,
// not a pillar of the floor block up to the leaves.
forest[5 * CELLS + 5] = column(vec![span(0, 5, 1)]);
let filled = fill(&forest);
for (filled, column) in filled.iter().zip(&forest) {
assert_eq!(filled, &column.spans);
}
// A cave two columns wide sealed in rock is still filled.
let mut rock = vec![column(vec![span(0, 20, 1)]); CELLS * CELLS];
for cave in [5 * CELLS + 5, 5 * CELLS + 6] {
rock[cave] = column(vec![span(0, 8, 1), span(12, 20, 1)]);
}
assert!(fill(&rock).iter().all(|spans| spans == &[span(0, 20, 1)]));
}
#[test]
fn caves_no_neighbor_sees_into_add_no_faces() {
let mut columns = columns();
for column in &mut columns {
+120 -2
View File
@@ -8,11 +8,12 @@
//! Messages go through the `log` facade. Horizon's own still print their plain text on stderr,
//! as they did before the log existed; other crates' errors only reach the file (their
//! warnings are mostly the Vulkan loader's startup chatter).
use std::collections::VecDeque;
use std::collections::{HashMap, VecDeque};
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::{Mutex, mpsc};
use std::time::{Duration, Instant};
use chrono::{DateTime, Local, NaiveDate};
@@ -20,6 +21,11 @@ use chrono::{DateTime, Local, NaiveDate};
pub const MAX_ARCHIVES_PER_DAY: u32 = 7;
/// Formatted lines kept in memory for crash reports.
const RECENT: usize = 50;
/// Warnings and errors one line of code may log in a [`FLOOD_WINDOW`]; past that they are counted,
/// not written, so a warning repeated every packet can't fill the disk. Vanilla has no such limit:
/// below it the log is vanilla's, line for line.
const FLOOD_LINES: u32 = 100;
const FLOOD_WINDOW: Duration = Duration::from_secs(60);
/// The platform data folder (what the `dirs` crate's data_dir gives).
pub fn data_dir() -> PathBuf {
@@ -283,6 +289,59 @@ struct Logger {
/// What the main thread is called in the log: "Render thread", or "Server thread" for the
/// dedicated server, as in vanilla.
main_thread: &'static str,
floods: Mutex<Floods>,
}
/// What the flood limit does with one warning or error.
#[derive(Debug, PartialEq, Eq)]
enum Gate {
/// Write it, after saying how many lines like it were left out in the window before.
Write {
left_out: u32,
},
/// Write it, then say the next ones from this line of code wait for the next window.
Last,
Skip,
}
/// Warnings and errors per line of code, in windows of [`FLOOD_WINDOW`].
#[derive(Default)]
struct Floods {
sites: HashMap<(String, u32), Flood>,
}
struct Flood {
since: Instant,
written: u32,
left_out: u32,
}
impl Floods {
fn gate(&mut self, file: &str, line: u32, now: Instant) -> Gate {
let flood = self.sites.entry((file.to_owned(), line)).or_insert(Flood {
since: now,
written: 0,
left_out: 0,
});
let mut left_out = 0;
if now.duration_since(flood.since) >= FLOOD_WINDOW {
left_out = flood.left_out;
*flood = Flood {
since: now,
written: 0,
left_out: 0,
};
}
flood.written += 1;
match flood.written.cmp(&FLOOD_LINES) {
std::cmp::Ordering::Less => Gate::Write { left_out },
std::cmp::Ordering::Equal => Gate::Last,
std::cmp::Ordering::Greater => {
flood.left_out += 1;
Gate::Skip
}
}
}
}
static LOGGER: std::sync::OnceLock<Logger> = std::sync::OnceLock::new();
@@ -308,8 +367,37 @@ impl log::Log for Logger {
Some(name) => name,
None => "unnamed",
};
let gate = if record.level() <= log::Level::Warn {
let mut floods = self.floods.lock().unwrap_or_else(|e| e.into_inner());
floods.gate(
record.file().unwrap_or(record.target()),
record.line().unwrap_or(0),
Instant::now(),
)
} else {
Gate::Write { left_out: 0 }
};
let level = record.level();
match gate {
Gate::Skip => return,
Gate::Write { left_out } if left_out > 0 => self.write(
thread,
level,
&format!(
"{left_out} more lines like the next one were not logged in the last minute"
),
),
_ => {}
}
let stderr = ours(record.target()).then(|| message.clone());
self.send(thread, record.level(), &message, stderr);
self.send(thread, level, &message, stderr);
if gate == Gate::Last {
self.write(
thread,
level,
&format!("{FLOOD_LINES} lines like this one in a minute: the next ones are counted, not logged"),
);
}
}
/// Waits (a second at most) until every line logged before is in the file.
@@ -403,6 +491,7 @@ pub fn init(home: &Path, main_thread: &'static str) {
out,
recent: Mutex::new(VecDeque::new()),
main_thread,
floods: Mutex::new(Floods::default()),
}
});
if log::set_logger(logger).is_ok() {
@@ -478,6 +567,35 @@ pub fn newest_crash_report(home: &Path) -> Option<PathBuf> {
mod tests {
use super::*;
#[test]
fn a_warning_repeated_every_packet_is_counted_past_a_hundred_a_minute() {
let (mut floods, start) = (Floods::default(), Instant::now());
let gates: Vec<Gate> = (0..140)
.map(|i| floods.gate("lan_vehicles.rs", 345, start + Duration::from_millis(i * 7)))
.collect();
assert!(
gates[..99]
.iter()
.all(|g| *g == Gate::Write { left_out: 0 })
);
assert_eq!(gates[99], Gate::Last);
assert!(gates[100..].iter().all(|g| *g == Gate::Skip));
// Another line of code has its own count.
assert_eq!(
floods.gate("lan_vehicles.rs", 330, start),
Gate::Write { left_out: 0 }
);
// The next minute writes again, and says how many were left out.
assert_eq!(
floods.gate("lan_vehicles.rs", 345, start + FLOOD_WINDOW),
Gate::Write { left_out: 40 }
);
assert_eq!(
floods.gate("lan_vehicles.rs", 345, start + FLOOD_WINDOW),
Gate::Write { left_out: 0 }
);
}
fn day(d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(2026, 10, d).unwrap()
}
@@ -662,6 +662,15 @@ impl Padded {
c.get(x & 15, wy, z & 15)
};
light[i] = c.light(x & 15, wy, z & 15);
} else if let Some(c) = region[1][1] {
// A neighbour not loaded continues this chunk's edge
// column. As lit air, the outermost chunks' water drew
// a wall toward it (fluids show both sides), a light
// sheet in the water where far terrain carries on.
// Vanilla draws no chunk without its neighbours.
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
blocks[i] = c.get(ex, wy, ez);
light[i] = c.light(ex, wy, ez);
} else {
light[i] = 0xF0;
}
@@ -0,0 +1,780 @@
//! `MultiLineEditBox` over `MultilineTextField` (and `AbstractTextAreaWidget`'s
//! frame and scroll bar): a text area that wraps its text to the box, with a
//! caret and selection that move by line, a character limit, a line limit and
//! a scroll bar. Indices count characters, as `EditBox`'s do.
use crate::{
clipboard::{Clipboard, EditShortcut, filter_text},
font::Font,
hud::{HudAtlas, HudBatch, rgba},
text::Style,
ui::{self, Scroll, java_whitespace},
};
use std::time::Instant;
use winit::keyboard::KeyCode;
/// `AbstractTextAreaWidget.INNER_PADDING`.
const PADDING: f32 = 4.0;
/// `Font.lineHeight`.
const LINE: f32 = 9.0;
/// `AbstractScrollArea.defaultSettings((int) (9.0 / 2.0))`'s scroll rate.
const SCROLL_RATE: f32 = 4.0;
/// `StringSplitter.splitLines(value, width, Style.EMPTY, false, ..)`: each
/// line's `[begin, end)`. A line ends at a `\n`, or once a character overflows
/// the width (never the first visible one) at the last space so far, or before
/// that character; the `\n` or space at the end is skipped.
pub fn split_ranges(font: &Font, chars: &[char], width: f32) -> Vec<(usize, usize)> {
let max_width = width.max(1.0);
let style = Style::default();
let mut out = Vec::new();
let mut start = 0;
while start < chars.len() {
let (mut line_width, mut had_visible, mut last_space) = (0.0f32, false, None);
let mut line_break = None;
for (i, &c) in chars.iter().enumerate().skip(start) {
if c == '\n' {
line_break = Some(i);
break;
}
if c == ' ' {
last_space = Some(i);
}
let advance = font.advance(c, &style);
line_width += advance;
if !had_visible || line_width <= max_width {
had_visible |= advance != 0.0;
} else {
line_break = Some(last_space.unwrap_or(i));
break;
}
}
let Some(at) = line_break else {
out.push((start, chars.len()));
break;
};
out.push((start, at));
start = if matches!(chars[at], '\n' | ' ') {
at + 1
} else {
at
};
}
out
}
/// `MultiLineEditBox` with its `MultilineTextField`.
pub struct MultiLineEdit {
chars: Vec<char>,
cursor: usize,
select_cursor: usize,
selecting: bool,
/// `characterLimit` (`MultilineTextField.NO_LIMIT` is `None`).
character_limit: Option<usize>,
line_limit: Option<usize>,
/// `displayLines`.
lines: Vec<(usize, usize)>,
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
pub focused: bool,
focused_at: Instant,
pub scroll: Scroll,
}
impl MultiLineEdit {
pub fn new(font: &Font, x: f32, y: f32, width: f32, height: f32) -> Self {
let mut edit = Self {
chars: Vec::new(),
cursor: 0,
select_cursor: 0,
selecting: false,
character_limit: None,
line_limit: None,
lines: Vec::new(),
x,
y,
width,
height,
focused: false,
focused_at: Instant::now(),
scroll: Scroll::default(),
};
edit.reflow(font);
edit
}
/// `AbstractTextAreaWidget.totalInnerPadding`.
fn text_width(&self) -> f32 {
self.width - 2.0 * PADDING
}
pub fn set_character_limit(&mut self, limit: usize) {
self.character_limit = Some(limit);
}
pub fn set_line_limit(&mut self, limit: usize) {
self.line_limit = Some(limit);
}
pub fn value(&self) -> String {
self.chars.iter().collect()
}
pub fn cursor(&self) -> usize {
self.cursor
}
pub fn line_count(&self) -> usize {
self.lines.len()
}
pub fn lines(&self) -> &[(usize, usize)] {
&self.lines
}
pub fn has_selection(&self) -> bool {
self.select_cursor != self.cursor
}
/// `getSelected`.
fn selected(&self) -> (usize, usize) {
(
self.select_cursor.min(self.cursor),
self.select_cursor.max(self.cursor),
)
}
pub fn selected_text(&self) -> String {
let (begin, end) = self.selected();
self.chars[begin..end].iter().collect()
}
/// `setValue(value, false)`: kept as it was when the text has more lines
/// than the limit allows.
pub fn set_value(&mut self, font: &Font, value: &str) {
let mut chars: Vec<char> = value.chars().collect();
if let Some(limit) = self.character_limit {
chars.truncate(limit);
}
if !self.overflows_line_limit(font, &chars) {
self.chars = chars;
self.cursor = self.chars.len();
self.select_cursor = self.cursor;
self.on_value_change(font);
}
}
/// `overflowsLineLimit`.
fn overflows_line_limit(&self, font: &Font, value: &[char]) -> bool {
let Some(limit) = self.line_limit else {
return false;
};
let lines = split_ranges(font, value, self.text_width()).len();
// `StringUtil.endsWithNewLine`.
lines + usize::from(value.last().is_some_and(|c| matches!(c, '\n' | '\r'))) > limit
}
/// `insertText`.
pub fn insert_text(&mut self, font: &Font, input: &str) {
if input.is_empty() && !self.has_selection() {
return;
}
let mut text: Vec<char> = filter_text(input, true).chars().collect();
if let Some(limit) = self.character_limit {
text.truncate(limit.saturating_sub(self.chars.len()));
}
let (begin, end) = self.selected();
let mut value = self.chars[..begin].to_vec();
value.extend_from_slice(&text);
value.extend_from_slice(&self.chars[end..]);
if !self.overflows_line_limit(font, &value) {
self.chars = value;
self.cursor = begin + text.len();
self.select_cursor = self.cursor;
self.on_value_change(font);
}
}
/// `deleteText`.
pub fn delete_text(&mut self, font: &Font, dir: isize) {
if !self.has_selection() {
self.select_cursor = self.cursor.saturating_add_signed(dir).min(self.chars.len());
}
self.insert_text(font, "");
}
/// `onValueChange`: the lines again, and the cursor in view.
fn on_value_change(&mut self, font: &Font) {
self.reflow(font);
self.scroll_to_cursor();
}
/// `reflowDisplayLines`.
fn reflow(&mut self, font: &Font) {
self.lines = if self.chars.is_empty() {
vec![(0, 0)]
} else {
let mut lines = split_ranges(font, &self.chars, self.text_width());
if self.chars.last() == Some(&'\n') {
lines.push((self.chars.len(), self.chars.len()));
}
lines
};
}
/// `getLineAtCursor`: -1 when no line holds the cursor.
fn line_at_cursor(&self) -> isize {
self.lines
.iter()
.position(|&(begin, end)| self.cursor >= begin && self.cursor <= end)
.map_or(-1, |i| i as isize)
}
/// `getLineView`: the index clamped to the lines.
fn line_view(&self, index: isize) -> (usize, usize) {
self.lines[index.clamp(0, self.lines.len() as isize - 1) as usize]
}
/// `getCursorLineView(offset)`.
fn cursor_line_view(&self, offset: isize) -> (usize, usize) {
match self.line_at_cursor() {
-1 => *self.lines.last().expect("a text field has a line"),
line => self.line_view(line + offset),
}
}
/// `AbstractScrollArea.contentHeight` of the text area.
fn content_height(&self) -> f32 {
LINE * self.lines.len() as f32 + 2.0 * PADDING
}
/// `setScrollAmount`.
fn set_scroll(&mut self, amount: f32) {
self.scroll.amount = amount;
self.scroll.clamp(0.0, self.height, self.content_height());
}
/// `MultiLineEditBox.scrollToCursor`, the text field's cursor listener.
fn scroll_to_cursor(&mut self) {
let mut amount = self.scroll.amount;
let first = self.line_view((amount / LINE) as isize);
let line = self.line_at_cursor() as f32;
if self.cursor <= first.0 {
amount = line * LINE;
} else {
let last = self.line_view(((amount + self.height) / LINE) as isize - 1);
if self.cursor > last.1 {
amount = line * LINE - self.height + LINE + 2.0 * PADDING;
}
}
self.set_scroll(amount);
}
/// `seekCursor` to `cursor`, then the cursor listener.
fn seek_cursor(&mut self, cursor: usize) {
self.cursor = cursor.min(self.chars.len());
self.scroll_to_cursor();
if !self.selecting {
self.select_cursor = self.cursor;
}
}
/// `seekCursorLine`.
fn seek_cursor_line(&mut self, font: &Font, offset: isize) {
if offset == 0 {
return;
}
let (begin, _) = self.cursor_line_view(0);
let before: String = self.chars[begin..self.cursor].iter().collect();
let old_left = font.width(&before) + 2.0;
let (begin, end) = self.cursor_line_view(offset);
let line: String = self.chars[begin..end].iter().collect();
self.seek_cursor(begin + font.fit(&line, old_left));
}
/// `seekCursorToPoint`: `x` and `y` from the top left of the text.
fn seek_cursor_to_point(&mut self, font: &Font, x: f32, y: f32) {
let left = x.floor();
let top = (y / LINE).floor() as isize;
let (begin, end) = self.line_view(top);
let line: String = self.chars[begin..end].iter().collect();
self.seek_cursor(begin + font.fit(&line, left));
}
/// `getPreviousWord`: where the word starts and ends.
fn previous_word(&self) -> (usize, usize) {
if self.chars.is_empty() {
return (0, 0);
}
let mut start = self.cursor.min(self.chars.len() - 1);
while start > 0 && java_whitespace(self.chars[start - 1]) {
start -= 1;
}
while start > 0 && !java_whitespace(self.chars[start - 1]) {
start -= 1;
}
(start, self.word_end(start))
}
/// `getNextWord`.
fn next_word(&self) -> (usize, usize) {
if self.chars.is_empty() {
return (0, 0);
}
let mut start = self.cursor.min(self.chars.len() - 1);
while start < self.chars.len() && !java_whitespace(self.chars[start]) {
start += 1;
}
while start < self.chars.len() && java_whitespace(self.chars[start]) {
start += 1;
}
(start, self.word_end(start))
}
fn word_end(&self, from: usize) -> usize {
let mut end = from;
while end < self.chars.len() && !java_whitespace(self.chars[end]) {
end += 1;
}
end
}
/// `selectWordAtCursor`.
fn select_word_at_cursor(&mut self) {
let (begin, end) = self.previous_word();
self.seek_cursor(begin);
self.selecting = true;
self.seek_cursor(end);
}
/// `MultilineTextField.keyPressed` for the keys that are not shortcuts.
/// True when the key was used.
pub fn key(&mut self, font: &Font, key: KeyCode, ctrl: bool, shift: bool) -> bool {
self.selecting = shift;
match key {
KeyCode::Backspace if ctrl => {
let (begin, _) = self.previous_word();
self.delete_text(font, begin as isize - self.cursor as isize);
}
KeyCode::Backspace => self.delete_text(font, -1),
KeyCode::Enter | KeyCode::NumpadEnter => self.insert_text(font, "\n"),
KeyCode::Delete if ctrl => {
let (begin, _) = self.next_word();
self.delete_text(font, begin as isize - self.cursor as isize);
}
KeyCode::Delete => self.delete_text(font, 1),
KeyCode::Home if ctrl => self.seek_cursor(0),
KeyCode::Home => self.seek_cursor(self.cursor_line_view(0).0),
KeyCode::PageUp => self.seek_cursor(0),
KeyCode::End if ctrl => self.seek_cursor(self.chars.len()),
KeyCode::End => self.seek_cursor(self.cursor_line_view(0).1),
KeyCode::PageDown => self.seek_cursor(self.chars.len()),
KeyCode::ArrowRight if ctrl => self.seek_cursor(self.next_word().0),
KeyCode::ArrowRight => self.seek_cursor(self.cursor + 1),
KeyCode::ArrowLeft if ctrl => self.seek_cursor(self.previous_word().0),
KeyCode::ArrowLeft => self.seek_cursor(self.cursor.saturating_sub(1)),
KeyCode::ArrowDown => {
if !ctrl {
self.seek_cursor_line(font, 1);
}
}
KeyCode::ArrowUp => {
if !ctrl {
self.seek_cursor_line(font, -1);
}
}
_ => return false,
}
true
}
/// Ctrl+A/C/V/X. True when the value changed.
pub fn shortcut(
&mut self,
font: &Font,
shortcut: EditShortcut,
clipboard: &mut dyn Clipboard,
) -> bool {
let before = self.chars.len();
match shortcut {
EditShortcut::SelectAll => {
self.cursor = self.chars.len();
self.select_cursor = 0;
}
EditShortcut::Copy => clipboard.set(&self.selected_text()),
EditShortcut::Paste => self.insert_text(font, &clipboard.get()),
EditShortcut::Cut => {
clipboard.set(&self.selected_text());
self.insert_text(font, "");
}
}
self.focused_at = Instant::now();
self.chars.len() != before
}
/// `AbstractWidget.isMouseOver`: the box and its scroll bar.
pub fn hovered(&self, (x, y): (f32, f32)) -> bool {
x >= self.x && y >= self.y && x < self.x + self.width + 6.0 && y < self.y + self.height
}
/// `AbstractTextAreaWidget.mouseClicked`: a press on the scroll bar
/// holds it, else `onClick` places the cursor (`double`: selects the word).
pub fn click(&mut self, font: &Font, cursor: (f32, f32), shift: bool, double: bool) {
let bar = self.x + self.width;
if self.scroll.press(
cursor,
bar,
self.y,
self.y + self.height,
self.content_height(),
) {
return;
}
if double {
self.select_word_at_cursor();
} else {
self.selecting = shift;
self.seek_screen(font, cursor);
}
}
/// `onDrag`: the selection follows the pointer, or the held scroll bar does.
pub fn drag(&mut self, font: &Font, cursor: (f32, f32), shift: bool) {
if self.scroll.held() {
self.scroll.drag(
cursor.1,
self.y,
self.y + self.height,
self.content_height(),
);
return;
}
self.selecting = true;
self.seek_screen(font, cursor);
self.selecting = shift;
}
/// `onRelease`.
pub fn release(&mut self) {
self.scroll.release();
}
/// `mouseScrolled`.
pub fn wheel(&mut self, delta: f32) {
let amount = self.scroll.amount - delta * SCROLL_RATE;
self.set_scroll(amount);
}
/// `seekCursorScreen`.
fn seek_screen(&mut self, font: &Font, (x, y): (f32, f32)) {
let mouse_x = x - self.x - PADDING;
let mouse_y = y - self.y - PADDING + self.scroll.amount;
self.seek_cursor_to_point(font, mouse_x, mouse_y);
}
/// `setFocused`: the caret starts to blink.
pub fn set_focused(&mut self, focused: bool) {
if focused && !self.focused {
self.focused_at = Instant::now();
}
self.focused = focused;
}
/// `AbstractTextAreaWidget.withinContentAreaTopBottom`.
fn within(&self, top: f32, bottom: f32) -> bool {
bottom - self.scroll.amount >= self.y && top - self.scroll.amount <= self.y + self.height
}
/// `extractWidgetRenderState`: the frame, the text under a scissor, the
/// scroll bar and the character count.
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas) {
crate::widgets::target_as(
[self.x, self.y, self.width, self.height],
crate::widgets::Kind::Field,
);
let sprite = if self.focused {
"gui/sprites/widget/text_field_highlighted"
} else {
"gui/sprites/widget/text_field"
};
ui::nine_slice(
batch,
atlas,
sprite,
self.x,
self.y,
self.width,
self.height,
u32::MAX,
);
let start = batch.vertices.len();
self.draw_contents(batch, atlas);
batch.clip_since(
start,
self.x + 1.0,
self.y + 1.0,
self.x + self.width - 1.0,
self.y + self.height - 1.0,
);
ui::scrollbar(
batch,
atlas,
self.x + self.width,
self.y,
self.y + self.height,
self.content_height(),
self.scroll.amount,
);
if let Some(limit) = self.character_limit {
let count = crate::lang::tr(
"gui.multiLineEditBox.character_limit",
&[&self.chars.len(), &limit],
);
let width = atlas.font.width(&count);
batch.text(
atlas,
&count,
self.x + self.width - width,
self.y + self.height + 4.0,
rgba(160, 160, 160, 255),
true,
);
}
}
/// `MultiLineEditBox.extractContents` (the placeholder is empty).
fn draw_contents(&self, batch: &mut HudBatch, atlas: &HudAtlas) {
if self.chars.is_empty() && !self.focused {
return;
}
let font = &atlas.font;
let text_color = rgba(224, 224, 224, 255);
let cursor_color = rgba(208, 208, 208, 255);
let slice =
|begin: usize, end: usize| -> String { self.chars[begin..end].iter().collect() };
let show_cursor =
self.focused && (self.focused_at.elapsed().as_millis() / 300).is_multiple_of(2);
let needs_valid_cursor_pos = self.focused;
let insert_cursor = self.cursor < self.chars.len();
let (inner_left, mut draw_top) = (self.x + PADDING, self.y + PADDING - self.scroll.amount);
let (mut cursor_x, mut cursor_y) = (0.0f32, 0.0f32);
let mut has_drawn_cursor = false;
// The content is translated by the scroll amount; `within` compares
// untranslated tops, so it is given the line's top before the shift.
let shift = self.scroll.amount;
for &(begin, end) in &self.lines {
let within = self.within(draw_top + shift, draw_top + shift + LINE);
if !has_drawn_cursor
&& (needs_valid_cursor_pos || show_cursor)
&& insert_cursor
&& self.cursor >= begin
&& self.cursor <= end
{
let before = slice(begin, self.cursor);
let right = inner_left + font.width(&before);
(cursor_x, cursor_y) = (right, draw_top);
if within {
let after = slice(self.cursor, end);
batch.text(atlas, &before, inner_left, draw_top, text_color, true);
batch.text(atlas, &after, right, draw_top, text_color, true);
if show_cursor {
batch.fill(atlas, cursor_x, cursor_y - 1.0, 1.0, 11.0, cursor_color);
}
}
has_drawn_cursor = true;
} else {
let line = slice(begin, end);
if within {
batch.text(atlas, &line, inner_left, draw_top, text_color, true);
}
if (needs_valid_cursor_pos || show_cursor) && !insert_cursor {
cursor_x = inner_left + font.width(&line);
cursor_y = draw_top;
}
}
draw_top += LINE;
}
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.has_selection() {
let (sel_begin, sel_end) = self.selected();
let draw_x = inner_left;
let mut draw_top = self.y + PADDING - self.scroll.amount;
for &(begin, end) in &self.lines {
if sel_begin > end {
draw_top += LINE;
continue;
}
if begin > sel_end {
break;
}
if self.within(draw_top + shift, draw_top + shift + LINE) {
let draw_begin = font.width(&slice(begin, sel_begin.max(begin)));
let draw_end = if sel_end > end {
self.width - PADDING
} else {
font.width(&slice(begin, sel_end))
};
batch.text_highlight(
atlas,
(draw_x + draw_begin, draw_top, draw_end - draw_begin, LINE),
true,
);
}
draw_top += LINE;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::hud::Rect;
use std::{collections::HashMap, path::Path};
fn font() -> Font {
// The missing-glyph metrics let these tests run without game assets.
Font::load(
Path::new("/nonexistent-horizon-multiline-test-resources"),
&HashMap::new(),
&[],
(1, 1),
(0, 0),
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 1,
h: 1,
},
)
}
/// A box whose text is `chars` characters wide and `lines` lines tall.
fn edit(font: &Font, chars: usize, lines: usize) -> MultiLineEdit {
let advance = font.advance('a', &Style::default());
MultiLineEdit::new(
font,
0.0,
0.0,
chars as f32 * advance + 8.0,
LINE * lines as f32 + 8.0,
)
}
#[test]
fn lines_break_at_newlines_and_at_the_last_space_that_fits() {
let font = font();
let split = |text: &str, chars: usize| {
let advance = font.advance('a', &Style::default());
let text: Vec<char> = text.chars().collect();
split_ranges(&font, &text, chars as f32 * advance)
};
assert_eq!(split("abcd efgh", 4), [(0, 4), (5, 9)]);
// No space to break at: before the character that overflows.
assert_eq!(split("abcdefgh", 4), [(0, 4), (4, 8)]);
assert_eq!(split("ab\ncd", 4), [(0, 2), (3, 5)]);
// A trailing newline starts no line here; the text field adds it.
assert_eq!(split("ab\n", 4), [(0, 2)]);
assert_eq!(split("", 4), []);
}
#[test]
fn a_trailing_newline_gets_an_empty_last_line() {
let font = font();
let mut edit = edit(&font, 8, 4);
assert_eq!(edit.lines(), [(0, 0)]);
edit.insert_text(&font, "ab\n");
assert_eq!(edit.lines(), [(0, 2), (3, 3)]);
assert_eq!(edit.cursor(), 3);
}
#[test]
fn the_character_limit_cuts_what_is_typed_and_set() {
let font = font();
let mut edit = edit(&font, 8, 4);
edit.set_character_limit(5);
edit.set_value(&font, "abcdefgh");
assert_eq!(edit.value(), "abcde");
edit.key(&font, KeyCode::Backspace, false, false);
edit.insert_text(&font, "wxyz");
assert_eq!(edit.value(), "abcdw");
}
#[test]
fn the_line_limit_refuses_an_edit_that_adds_a_line() {
let font = font();
let mut edit = edit(&font, 8, 4);
edit.set_line_limit(2);
edit.set_value(&font, "a\nb");
assert_eq!(edit.value(), "a\nb");
// A third line, by a newline or by wrapping, changes nothing.
edit.key(&font, KeyCode::Enter, false, false);
edit.insert_text(&font, "\nc");
assert_eq!(edit.value(), "a\nb");
edit.insert_text(&font, " abcdefghijkl");
assert_eq!(edit.value(), "a\nb");
// A value over the limit is not set at all.
edit.set_value(&font, "1\n2\n3");
assert_eq!(edit.value(), "a\nb");
// A trailing newline counts as a line.
edit.set_value(&font, "a\n");
edit.insert_text(&font, "\n");
assert_eq!(edit.value(), "a\n");
}
#[test]
fn up_and_down_keep_the_column_and_home_and_end_stay_on_the_line() {
let font = font();
let mut edit = edit(&font, 8, 4);
edit.set_value(&font, "abcd\nef\nghij");
// From the end of `ghij` up to `ef`: the end of the shorter line.
edit.key(&font, KeyCode::ArrowUp, false, false);
assert_eq!(edit.cursor(), 7);
edit.key(&font, KeyCode::ArrowUp, false, false);
assert_eq!(edit.cursor(), 2);
edit.key(&font, KeyCode::Home, false, false);
assert_eq!(edit.cursor(), 0);
edit.key(&font, KeyCode::End, false, false);
assert_eq!(edit.cursor(), 4);
edit.key(&font, KeyCode::End, true, false);
assert_eq!(edit.cursor(), 12);
edit.key(&font, KeyCode::PageUp, false, false);
assert_eq!(edit.cursor(), 0);
}
#[test]
fn shift_selects_and_typing_replaces_the_selection() {
let font = font();
let mut edit = edit(&font, 8, 4);
edit.set_value(&font, "one two");
edit.key(&font, KeyCode::ArrowLeft, true, true);
assert_eq!(edit.selected_text(), "two");
edit.insert_text(&font, "2");
assert_eq!(edit.value(), "one 2");
edit.set_value(&font, "one two");
edit.key(&font, KeyCode::Backspace, true, false);
assert_eq!(edit.value(), "one ");
}
#[test]
fn the_box_scrolls_to_keep_the_cursor_in_view() {
let font = font();
let mut edit = edit(&font, 8, 2);
edit.set_value(&font, "1\n2\n3\n4\n5");
// Five lines in two lines' height: the end shows.
assert_eq!(edit.scroll.amount, 9.0 * 5.0 + 8.0 - edit.height);
edit.key(&font, KeyCode::PageUp, false, false);
assert_eq!(edit.scroll.amount, 0.0);
edit.wheel(-1.0);
assert_eq!(edit.scroll.amount, SCROLL_RATE);
}
#[test]
fn a_click_puts_the_cursor_on_the_line_and_character_under_it() {
let font = font();
let mut edit = edit(&font, 8, 4);
edit.set_value(&font, "abcd\nefgh");
let advance = font.advance('a', &Style::default());
// The second line, after two characters.
edit.click(
&font,
(4.0 + 2.0 * advance + 1.0, 4.0 + 9.0 + 1.0),
false,
false,
);
assert_eq!(edit.cursor(), 7);
// Shift extends the selection.
edit.click(&font, (4.0 + 1.0, 4.0 + 1.0), true, false);
assert_eq!(edit.selected_text(), "abcd\nef");
// A double click, after the plain click that starts it, selects the word.
edit.click(&font, (4.0 + 1.0, 4.0 + 1.0), false, false);
edit.click(&font, (4.0 + 1.0, 4.0 + 1.0), false, true);
assert_eq!(edit.selected_text(), "abcd");
}
}
+364 -4
View File
@@ -141,8 +141,10 @@ impl Opt {
| Opt::ChunkFade
| Opt::ImprovedTransparency
| Opt::ChunkBuilder
// Saved, but the quit keys and the mouse don't read them yet.
| Opt::QuitShortcuts
// 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
)
}
@@ -202,6 +204,11 @@ impl Opt {
Opt::MaxFps => "options.framerateLimit",
Opt::Vsync => "options.vsync",
Opt::Fullscreen => "options.fullscreen",
Opt::ExclusiveFullscreen => "options.exclusiveFullscreen",
Opt::ExclusiveMode => "options.fullscreen.exclusive.mode",
Opt::GraphicsBackend => "options.graphicsApi",
Opt::TextureFiltering => "options.textureFiltering",
Opt::MaxAnisotropy => "options.maxAnisotropy",
Opt::SmoothLighting => "options.ao",
Opt::Clouds => "options.renderClouds",
Opt::CloudRange => "options.renderCloudsDistance",
@@ -333,6 +340,15 @@ impl Opt {
Opt::BiomeBlend => unit(s.biome_blend as f32, 0.0, 7.0),
Opt::Preset => unit(preset_index(&s.graphics_preset) as f32, 0.0, 3.0),
Opt::Mipmaps => unit(s.mipmap_levels as f32, 0.0, 4.0),
Opt::MaxAnisotropy => unit(s.file.int("maxAnisotropyBit") as f32, 1.0, 3.0),
Opt::ExclusiveMode => {
let last = s.video_modes.len() as f32 - 1.0;
if last < 0.0 {
Kind::Slider(0.0)
} else {
unit(s.video_mode_index() as f32, -1.0, last)
}
}
Opt::WeatherRadius => unit(s.weather_radius as f32, 3.0, 10.0),
Opt::ChunkFade => unit(s.chunk_fade, 0.0, 2.0),
Opt::MenuBlur => unit(s.menu_blur as f32, 0.0, 10.0),
@@ -366,6 +382,19 @@ impl Opt {
"options.prioritizeChunkUpdates.byPlayer",
"options.prioritizeChunkUpdates.nearby",
][usize::from(s.prioritize_chunk_updates.min(2))],
// Options.exclusiveFullscreen: a tooltip per value.
Opt::ExclusiveFullscreen => {
if s.file.bool("exclusiveFullscreen") {
"options.exclusiveFullscreen.on"
} else {
"options.exclusiveFullscreen.off"
}
}
Opt::TextureFiltering => [
"options.textureFiltering.none",
"options.textureFiltering.rgss",
"options.textureFiltering.anisotropic",
][s.file.int("textureFiltering").clamp(0, 2) as usize],
_ => self.caption(),
};
format!("{key}.tooltip")
@@ -470,6 +499,26 @@ impl Opt {
)),
),
Opt::Preset => generic(c, tr(&format!("options.graphics.{}", s.graphics_preset))),
Opt::MaxAnisotropy => match s.file.int("maxAnisotropyBit") {
0 => generic(c, tr("options.off")),
bit => generic(c, Text::tr("options.multiplier", &[&(1 << bit)])),
},
Opt::ExclusiveMode => match s.video_modes.get(s.video_mode_index().max(0) as usize) {
None if s.video_modes.is_empty() => tr("options.fullscreen.unavailable"),
Some(mode) if s.video_mode_index() >= 0 => generic(
c,
Text::tr(
"options.fullscreen.entry",
&[
&mode.width,
&mode.height,
&mode.refresh_rate_label(),
&mode.bits(),
],
),
),
_ => generic(c, tr("options.fullscreen.current")),
},
Opt::Mipmaps => {
if s.mipmap_levels == 0 {
generic(c, tr("options.off"))
@@ -580,6 +629,17 @@ impl Opt {
Opt::ViewBobbing => on_off(s.view_bobbing),
Opt::Vsync => on_off(s.vsync),
Opt::Fullscreen => on_off(s.fullscreen),
Opt::ExclusiveFullscreen => on_off(s.file.bool("exclusiveFullscreen")),
Opt::TextureFiltering => tr([
"options.textureFiltering.none",
"options.textureFiltering.rgss",
"options.textureFiltering.anisotropic",
][s.file.int("textureFiltering").clamp(0, 2) as usize]),
Opt::GraphicsBackend => tr(match s.horizon.api {
crate::graphics::GraphicsApi::OpenGl => "options.graphicsApi.opengl",
crate::graphics::GraphicsApi::Vulkan => "options.graphicsApi.vulkan",
_ => "options.graphicsApi.default",
}),
Opt::SmoothLighting => on_off(s.smooth_lighting),
Opt::Clouds => tr(match s.clouds.as_str() {
"false" => "options.off",
@@ -731,6 +791,20 @@ impl Opt {
Opt::BiomeBlend => s.biome_blend = step(0.0, 7.0) as i32,
Opt::Preset => s.graphics_preset = PRESETS[step(0.0, 3.0) as usize].into(),
Opt::Mipmaps => s.mipmap_levels = step(0.0, 4.0) as i32,
Opt::MaxAnisotropy => s.file.set(
"maxAnisotropyBit",
crate::options_file::V::Int(step(1.0, 3.0) as i32),
),
Opt::ExclusiveMode => {
// Window.setPreferredFullscreenVideoMode: -1 is the desktop's own mode.
let Some(last) = s.video_modes.len().checked_sub(1) else {
return;
};
let index = step(-1.0, last as f32) as i32;
s.file.fullscreen_resolution = usize::try_from(index)
.ok()
.map(|i| s.video_modes[i].write());
}
Opt::WeatherRadius => s.weather_radius = step(3.0, 10.0) as i32,
Opt::ChunkFade => s.chunk_fade = step(0.0, 40.0) / 20.0,
Opt::MenuBlur => s.menu_blur = step(0.0, 10.0) as i32,
@@ -770,6 +844,8 @@ impl Opt {
| Opt::ImprovedTransparency
| Opt::MenuBlur
| Opt::ChunkBuilder
| Opt::TextureFiltering
| Opt::MaxAnisotropy
)
}
/// Advance a cycle button (CycleButton.cycleValue).
@@ -819,6 +895,45 @@ impl Opt {
Opt::ViewBobbing => s.view_bobbing = !s.view_bobbing,
Opt::Vsync => s.vsync = !s.vsync,
Opt::Fullscreen => s.fullscreen = !s.fullscreen,
Opt::ExclusiveFullscreen => {
let on = s.file.bool("exclusiveFullscreen");
s.file
.set("exclusiveFullscreen", crate::options_file::V::Bool(!on));
}
Opt::TextureFiltering => {
let n = next(s.file.int("textureFiltering").clamp(0, 2) as u8, 3);
s.file.set(
"textureFiltering",
crate::options_file::V::Int(i32::from(n)),
);
}
Opt::GraphicsBackend => {
// PreferredGraphicsApi: Default, OpenGL, Vulkan; a platform lacking one skips it.
use crate::graphics::GraphicsApi::{Auto, OpenGl, Vulkan};
let offered: Vec<_> = [Auto, OpenGl, Vulkan]
.into_iter()
.filter(|a| crate::graphics::GraphicsApi::choices().contains(a))
.collect();
let at = offered
.iter()
.position(|a| *a == s.horizon.api)
.unwrap_or(0);
let count = offered.len();
let api = offered[if back {
(at + count - 1) % count
} else {
(at + 1) % count
}];
s.horizon.api = api;
s.file.set_name(
"preferredGraphicsBackend",
match api {
OpenGl => "opengl",
Vulkan => "vulkan",
_ => "default",
},
);
}
Opt::SmoothLighting => s.smooth_lighting = !s.smooth_lighting,
Opt::Clouds => {
let order = ["false", "fast", "true"];
@@ -968,8 +1083,7 @@ fn drag_slider(opt: Opt, s: &mut Settings, v: f32, events: &mut Vec<Event>) {
events.push(Event::Volume(source, after));
}
}
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set (texture
/// filtering and anisotropy have no option here yet).
/// GraphicsPreset.apply: the options Fast, Fancy and Fabulous set.
pub fn apply_preset(s: &mut Settings) {
let fabulous = s.graphics_preset == "fabulous";
match s.graphics_preset.as_str() {
@@ -989,6 +1103,7 @@ pub fn apply_preset(s: &mut Settings) {
s.cutout_leaves = false;
s.improved_transparency = false;
s.weather_radius = 5;
set_filtering(s, 1, 0);
}
"fancy" | "fabulous" => {
s.biome_blend = 2;
@@ -1006,10 +1121,40 @@ pub fn apply_preset(s: &mut Settings) {
s.cutout_leaves = true;
s.improved_transparency = fabulous;
s.weather_radius = 10;
// Fabulous takes anisotropic filtering (RGSS on a device with known issues, which
// wgpu does not report).
set_filtering(
s,
if fabulous { 2 } else { 1 },
if fabulous { 2 } else { 1 },
);
}
_ => {}
}
}
/// The preset's `maxAnisotropyBit` and `textureFiltering` (0 none, 1 RGSS, 2 anisotropic).
fn set_filtering(s: &mut Settings, anisotropy_bit: i32, filtering: i32) {
use crate::options_file::V;
s.file.set("maxAnisotropyBit", V::Int(anisotropy_bit));
s.file.set("textureFiltering", V::Int(filtering));
}
/// `Options.preferredGraphicsBackend`'s tooltip: the restart note once the choice differs from
/// the startup one, the description, and Vulkan's warning.
fn graphics_api_tooltip(s: &Settings) -> Text {
let line = |key: &str| lang::get_or(key, key);
let mut lines = Vec::new();
if s.horizon.api != s.startup_api {
lines.push(line("options.needsRestart"));
lines.push(String::new());
}
lines.push(line("options.graphicsApi.tooltip"));
if s.horizon.api == crate::graphics::GraphicsApi::Vulkan {
lines.push(String::new());
lines.push(line("options.graphicsApi.tooltip.vulkan"));
}
Text::literal(lines.join("\n"))
}
/// `selected_key` while a native mod's key waits (`selected_native_key`).
const NATIVE_KEY: &str = "horizon.nativeModKey";
@@ -1648,13 +1793,16 @@ impl OptionsUi {
)),
)];
rows.push(Row::Header("options.video.display.header"));
rows.push(Row::Big(Opt::ExclusiveMode));
rows.extend(pairs(&[
Opt::MaxFps,
Opt::Vsync,
Opt::InactivityFps,
Opt::GuiScale,
Opt::Fullscreen,
Opt::ExclusiveFullscreen,
Opt::Gamma,
Opt::GraphicsBackend,
]));
rows.push(Row::Header("options.video.quality.header"));
rows.push(Row::Big(Opt::Preset));
@@ -1673,6 +1821,8 @@ impl OptionsUi {
Opt::CloudRange,
Opt::CutoutLeaves,
Opt::ImprovedTransparency,
Opt::TextureFiltering,
Opt::MaxAnisotropy,
Opt::WeatherRadius,
]));
rows.push(Row::Header("options.video.preferences.header"));
@@ -2001,6 +2151,21 @@ impl OptionsUi {
});
let row_left = cx - 170.0;
let scrollbar_x = row_left + 340.0 + 6.0 + 2.0;
// VideoSettingsScreen.addTitle: a changed graphics API needs a restart.
if page == Page::Video && s.horizon.api != s.startup_api {
out.push(Widget {
x: 0.0,
y: 15.0,
w,
h: 9.0,
kind: WKind::Text {
text: tr("options.restartRequired"),
color: rgba(223, 80, 80, 255),
centered: true,
},
list: false,
});
}
let list_start = out.len();
let mut y = top + 2.0 - self.scroll;
let mut last_category = "";
@@ -2490,6 +2655,13 @@ impl OptionsUi {
if let Some(key) = key.filter(|key| lang::has(key)) {
tooltip = Some(atlas.font.split(&tr(&key), 170.0));
}
if let WKind::Button {
act: Act::Cycle(Opt::GraphicsBackend),
..
} = &wd.kind
{
tooltip = Some(atlas.font.split(&graphics_api_tooltip(s), 170.0));
}
if let WKind::Button {
act: Act::Cycle(opt),
..
@@ -3433,6 +3605,17 @@ mod tests {
assert!(loaded.toggle_attack && loaded.toggle_use);
}
#[test]
fn quit_shortcuts_option_can_be_switched() {
assert!(Opt::QuitShortcuts.available());
let s = Settings::default();
assert_eq!(Opt::QuitShortcuts.caption(), "options.quitShortcuts");
assert_eq!(
Opt::QuitShortcuts.tooltip_key(&s),
"options.quitShortcuts.tooltip"
);
}
#[test]
fn operator_items_tab_option_can_be_switched() {
// The creative screen reads it (CreativeModeInventoryScreen.hasPermissions).
@@ -3724,6 +3907,183 @@ mod tests {
assert_eq!(saved.keys_of("zoom"), vec![("zoom", "key.keyboard.r")]);
}
fn mode(width: i32, rate: f32) -> crate::window::VideoMode {
crate::window::VideoMode {
width,
height: width * 9 / 16,
red_bits: 8,
green_bits: 8,
blue_bits: 8,
refresh_rate: rate,
}
}
#[test]
fn video_page_lists_vanilla_display_and_quality_options_in_order() {
let mut ui = OptionsUi::default();
ui.show(Page::Video);
let rows = ui.list_rows(&Settings::default());
let opts: Vec<Opt> = rows
.iter()
.flat_map(|r| match r {
Row::Big(o) => vec![*o],
Row::Pair(a, b) => std::iter::once(*a).chain(*b).collect(),
_ => Vec::new(),
})
.collect();
// VideoSettingsScreen.addOptions: the exclusive mode row, displayOptions, the preset,
// qualityOptions, then the preferences.
assert_eq!(
opts[..9],
[
Opt::ExclusiveMode,
Opt::MaxFps,
Opt::Vsync,
Opt::InactivityFps,
Opt::GuiScale,
Opt::Fullscreen,
Opt::ExclusiveFullscreen,
Opt::Gamma,
Opt::GraphicsBackend,
]
);
let at = |o| opts.iter().position(|x| *x == o).unwrap();
assert_eq!(at(Opt::ImprovedTransparency) + 1, at(Opt::TextureFiltering));
assert_eq!(at(Opt::TextureFiltering) + 1, at(Opt::MaxAnisotropy));
assert_eq!(at(Opt::MaxAnisotropy) + 1, at(Opt::WeatherRadius));
}
#[test]
fn exclusive_fullscreen_mode_picks_a_monitor_mode_or_the_current_one() {
let mut s = Settings::default();
// No monitor mode: the slider changes nothing.
Opt::ExclusiveMode.set_slider(&mut s, 1.0);
assert_eq!(s.file.fullscreen_resolution, None);
s.video_modes = vec![mode(1280, 60.0), mode(1920, 144.0), mode(2560, 59.94)];
assert_eq!(s.video_mode_index(), -1);
// IntRange(-1, 2): the slider's thirds are -1, 0, 1, 2.
Opt::ExclusiveMode.set_slider(&mut s, 2.0 / 3.0);
assert_eq!(s.video_mode_index(), 1);
assert_eq!(Opt::ExclusiveMode.slider_position(&s), Some(2.0 / 3.0));
Opt::ExclusiveMode.set_slider(&mut s, 1.0);
assert_eq!(s.video_mode_index(), 2);
Opt::ExclusiveMode.set_slider(&mut s, 0.0);
assert_eq!(s.file.fullscreen_resolution, None);
}
#[test]
fn exclusive_fullscreen_and_its_tooltips_follow_the_option() {
let mut s = Settings::default();
assert!(!s.file.bool("exclusiveFullscreen"));
assert_eq!(
Opt::ExclusiveFullscreen.tooltip_key(&s),
"options.exclusiveFullscreen.off.tooltip"
);
Opt::ExclusiveFullscreen.cycle(&mut s, false, [854, 480]);
assert!(s.file.bool("exclusiveFullscreen"));
assert_eq!(
Opt::ExclusiveFullscreen.tooltip_key(&s),
"options.exclusiveFullscreen.on.tooltip"
);
// The mode row's own tooltip says it only matters with Exclusive Fullscreen.
assert_eq!(
Opt::ExclusiveMode.tooltip_key(&s),
"options.fullscreen.exclusive.mode.tooltip"
);
}
#[test]
fn the_graphics_api_option_cycles_default_opengl_vulkan_and_asks_for_a_restart() {
use crate::graphics::GraphicsApi;
let mut s = Settings::default();
s.startup_api = s.horizon.api;
let offered = GraphicsApi::choices();
let mut seen = vec![s.horizon.api];
for _ in 1..offered.len() {
Opt::GraphicsBackend.cycle(&mut s, false, [854, 480]);
seen.push(s.horizon.api);
}
// Dx12 is Horizon's own Graphics page's; this option has the three of vanilla.
assert!(!seen.contains(&GraphicsApi::Dx12));
assert_eq!(seen[0], GraphicsApi::Auto);
if offered.len() == 3 {
assert_eq!(seen[1..], [GraphicsApi::OpenGl, GraphicsApi::Vulkan]);
assert_eq!(s.file.name("preferredGraphicsBackend"), "vulkan");
Opt::GraphicsBackend.cycle(&mut s, false, [854, 480]);
assert_eq!(s.horizon.api, GraphicsApi::Auto);
assert_eq!(s.file.name("preferredGraphicsBackend"), "default");
}
}
#[test]
fn texture_filtering_rows_exist_and_the_presets_set_them() {
let mut s = Settings::default();
for (preset, bit, filtering) in [("fast", 1, 0), ("fancy", 1, 1), ("fabulous", 2, 2)] {
s.graphics_preset = preset.into();
apply_preset(&mut s);
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());
}
#[test]
#[ignore = "requires prepared language data in HORIZON_TEST_RESOURCES; run alone"]
fn the_new_video_options_read_as_vanilla_words_them() {
use crate::graphics::GraphicsApi;
let root = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
lang::set(lang::Language::load(&[&root], "en_us"));
let mut s = Settings::default();
assert_eq!(Opt::ExclusiveMode.label(&s).plain(), "Setting unavailable");
s.video_modes = vec![mode(1280, 60.0), mode(1920, 144.0), mode(2560, 59.94)];
assert_eq!(
Opt::ExclusiveMode.label(&s).plain(),
"Exclusive Fullscreen Mode: Current"
);
Opt::ExclusiveMode.set_slider(&mut s, 2.0 / 3.0);
assert_eq!(
Opt::ExclusiveMode.label(&s).plain(),
"Exclusive Fullscreen Mode: 1920x1080@144 (24bit)"
);
Opt::ExclusiveMode.set_slider(&mut s, 1.0);
assert_eq!(
Opt::ExclusiveMode.label(&s).plain(),
"Exclusive Fullscreen Mode: [email protected] (24bit)"
);
s.horizon.api = GraphicsApi::Vulkan;
assert_eq!(
Opt::GraphicsBackend.label(&s).plain(),
"Graphics API: Prefer Vulkan (Experimental)"
);
assert!(
graphics_api_tooltip(&s)
.plain()
.starts_with("Requires a game restart to take effect.\n\n")
);
s.startup_api = GraphicsApi::Vulkan;
assert!(
graphics_api_tooltip(&s)
.plain()
.starts_with("Which graphics API")
);
s.file
.set("maxAnisotropyBit", crate::options_file::V::Int(2));
s.file
.set("textureFiltering", crate::options_file::V::Int(2));
assert_eq!(
Opt::MaxAnisotropy.label(&s).plain(),
"Anisotropic Filtering: 4x"
);
assert_eq!(
Opt::TextureFiltering.label(&s).plain(),
"Texture Filtering: Anisotropic"
);
}
#[test]
fn option_tooltip_keys_follow_value_dependent_descriptions() {
let mut s = Settings::default();
+343 -47
View File
@@ -156,60 +156,223 @@ fn format_of(v: &serde_json::Value) -> Option<(i64, i64)> {
_ => None,
}
}
/// PackCompatibility: inside [min_format, max_format] (or the legacy
/// `pack_format`/`supported_formats`).
/// `PackFormat`: major and minor.
type Format = (i64, i64);
/// `PackFormat.lastPreMinorVersion`: the last major without minor versions,
/// for the resource pack format (64) or the data pack one (81).
fn last_pre_minor(current: Format) -> i64 {
if current.0 == horizon_core::version::DATA_PACK_FORMAT.0 {
81
} else {
64
}
}
/// `PackFormat.fullCodec(default_minor)`: an int, or a list of 1 to 256
/// non-negative ints whose second is the minor. None: it does not parse.
fn format_codec(v: &serde_json::Value, default_minor: i64) -> Option<Format> {
let non_negative =
|v: &serde_json::Value| v.as_i64().filter(|n| *n >= 0 && *n <= i64::from(i32::MAX));
match v {
serde_json::Value::Array(a) if (1..=256).contains(&a.len()) => {
let list: Option<Vec<i64>> = a.iter().map(non_negative).collect();
let list = list?;
Some((list[0], list.get(1).copied().unwrap_or(default_minor)))
}
serde_json::Value::Array(_) => None,
v => Some((non_negative(v)?, default_minor)),
}
}
/// `InclusiveRange.codec(Codec.INT)`: `[min, max]` or
/// `{min_inclusive, max_inclusive}`, min not above max.
fn int_range_codec(v: &serde_json::Value) -> Option<(i64, i64)> {
let int = |v: &serde_json::Value| v.as_i64().filter(|n| i32::try_from(*n).is_ok());
let (min, max) = match v {
serde_json::Value::Array(a) if a.len() == 2 => (int(&a[0])?, int(&a[1])?),
serde_json::Value::Object(o) => {
(int(o.get("min_inclusive")?)?, int(o.get("max_inclusive")?)?)
}
_ => return None,
};
(min <= max).then_some((min, max))
}
/// An `optionalFieldOf`: absent is `Ok(None)`, one that does not parse an error.
fn optional<T>(
v: &serde_json::Value,
parse: impl Fn(&serde_json::Value) -> Option<T>,
) -> Result<Option<T>, ()> {
if v.is_null() {
Ok(None)
} else {
parse(v).map(Some).ok_or(())
}
}
/// `PackFormat.IntermediaryFormat.validatePackFormatForRange`.
fn validate_pack_format_for_range(main_format: i64, min: i64, max: i64) -> Option<String> {
if main_format < min || main_format > max {
Some(format!(
"Pack declared support for versions {min} to {max} but declared main format is {main_format}"
))
} else if main_format < 15 {
Some("Multi-version packs cannot support minimum version of less than 15, since this will leave versions in range unable to load pack.".into())
} else {
None
}
}
/// `PackFormat` as `toString` prints it.
fn format_text(f: Format) -> String {
if f.1 == i64::from(i32::MAX) {
format!("{}.*", f.0)
} else {
format!("{}.{}", f.0, f.1)
}
}
/// `PackFormat.IntermediaryFormat.validate` for a pack's `pack` section
/// (`hasPackFormatField`, no `requireOldField`): the supported range, or why
/// vanilla refuses the metadata (it then falls back to a pack of unknown
/// format).
pub fn validate_pack_range(
pack: &serde_json::Value,
last_pre_minor: i64,
) -> Result<(Format, Format), String> {
const CONTEXT: &str = "Pack";
const OLD: &str = "supported_formats";
let bad = |field: &str| format!("{CONTEXT} field {field} does not parse");
let min =
optional(&pack["min_format"], |v| format_codec(v, 0)).map_err(|()| bad("min_format"))?;
let max = optional(&pack["max_format"], |v| {
format_codec(v, i64::from(i32::MAX))
})
.map_err(|()| bad("max_format"))?;
let format = optional(&pack["pack_format"], |v| {
v.as_i64().filter(|n| i32::try_from(*n).is_ok())
})
.map_err(|()| bad("pack_format"))?;
let supported = optional(&pack[OLD], int_range_codec).map_err(|()| bad(OLD))?;
if min.is_some() != max.is_some() {
return Err(format!(
"{CONTEXT} missing field, must declare both min_format and max_format"
));
}
if let (Some(min), Some(max)) = (min, max) {
return validate_new_format(min, max, format, supported, last_pre_minor);
}
if let Some(supported) = supported {
return validate_old_format(supported, format, last_pre_minor);
}
match format {
Some(main) if main > last_pre_minor => Err(format!(
"{CONTEXT} declares support for version newer than {last_pre_minor}, but is missing mandatory fields min_format and max_format"
)),
Some(main) => Ok(((main, 0), (main, 0))),
None => Err(format!(
"{CONTEXT} could not be parsed, missing format version information"
)),
}
}
/// `PackFormat.IntermediaryFormat.validateNewFormat`.
fn validate_new_format(
min: Format,
max: Format,
format: Option<i64>,
supported: Option<(i64, i64)>,
last_pre_minor: i64,
) -> Result<(Format, Format), String> {
const OLD: &str = "supported_formats";
let (major_min, major_max) = (min.0, max.0);
if min > max {
return Err(format!(
"Pack min_format ({}) is greater than max_format ({})",
format_text(min),
format_text(max)
));
}
if major_min > last_pre_minor {
if supported.is_some() {
return Err(format!(
"Pack key {OLD} is deprecated starting from pack format {}. Remove {OLD} from your pack.mcmeta.",
last_pre_minor + 1
));
}
if let Some(main) = format
&& let Some(error) = validate_pack_format_for_range(main, major_min, major_max)
{
return Err(error);
}
} else {
let Some(old) = supported else {
return Err(format!(
"Pack declares support for format {major_min}, but game versions supporting formats 15 to {last_pre_minor} require a {OLD} field. Add \"{OLD}\": [{major_min}, {last_pre_minor}] or require a version greater or equal to {}.0.",
last_pre_minor + 1
));
};
if old.0 != major_min {
return Err(format!(
"Pack version declaration mismatch between {OLD} (from {}) and min_format ({})",
old.0,
format_text(min)
));
}
if old.1 != major_max && old.1 != last_pre_minor {
return Err(format!(
"Pack version declaration mismatch between {OLD} (up to {}) and max_format ({})",
old.1,
format_text(max)
));
}
let Some(main) = format else {
return Err(format!(
"Pack declares support for formats up to {last_pre_minor}, but game versions supporting formats 15 to {last_pre_minor} require a pack_format field. Add \"pack_format\": {major_min} or require a version greater or equal to {}.0.",
last_pre_minor + 1
));
};
if let Some(error) = validate_pack_format_for_range(main, major_min, major_max) {
return Err(error);
}
}
Ok((min, max))
}
/// `PackFormat.IntermediaryFormat.validateOldFormat`.
fn validate_old_format(
supported: (i64, i64),
format: Option<i64>,
last_pre_minor: i64,
) -> Result<(Format, Format), String> {
let (min, max) = supported;
if max > last_pre_minor {
return Err(format!(
"Pack declares support for version newer than {last_pre_minor}, but is missing mandatory fields min_format and max_format"
));
}
let Some(main) = format else {
return Err(format!(
"Pack declares support for formats up to {last_pre_minor}, but game versions supporting formats 15 to {last_pre_minor} require a pack_format field. Add \"pack_format\": {min} or require a version greater or equal to {}.0.",
last_pre_minor + 1
));
};
if let Some(error) = validate_pack_format_for_range(main, min, max) {
return Err(error);
}
Ok(((min, 0), (max, 0)))
}
/// PackCompatibility: inside the pack's declared range.
pub fn compatible(meta: &serde_json::Value) -> bool {
let pack = &meta["pack"];
let current = RESOURCE_FORMAT;
if let (Some(min), Some(max)) = (
format_of(&pack["min_format"]),
format_of(&pack["max_format"]),
) {
return min <= current
&& current <= (max.0, max.1.max(if max.1 == 0 { i64::MAX } else { max.1 }));
}
if let Some(range) = pack["supported_formats"].as_array()
&& let (Some(lo), Some(hi)) = (
range.first().and_then(|v| v.as_i64()),
range.get(1).and_then(|v| v.as_i64()),
)
{
return lo <= current.0 && current.0 <= hi;
}
format_of(&pack["pack_format"]).is_some_and(|f| f.0 == current.0)
compatibility(meta) == crate::pack_screen::Compat::Compatible
}
/// `PackCompatibility.forVersion`: too old, too new, compatible, or unknown
/// when the metadata names no format.
/// when the metadata names no (valid) format.
pub fn compatibility(meta: &serde_json::Value) -> crate::pack_screen::Compat {
compatibility_for(meta, RESOURCE_FORMAT)
}
/// `compatibility` against `current`, the resource or the data pack format.
pub fn compatibility_for(
meta: &serde_json::Value,
current: (i64, i64),
) -> crate::pack_screen::Compat {
/// Metadata that fails `PackFormat.packCodec` parses as the fallback type,
/// whose format is `Integer.MAX_VALUE`: unknown.
pub fn compatibility_for(meta: &serde_json::Value, current: Format) -> crate::pack_screen::Compat {
use crate::pack_screen::Compat;
let pack = &meta["pack"];
let range = if let (Some(min), Some(max)) = (
format_of(&pack["min_format"]),
format_of(&pack["max_format"]),
) {
Some((min, (max.0, if max.1 == 0 { i64::MAX } else { max.1 })))
} else if let Some(r) = pack["supported_formats"].as_array()
&& let (Some(lo), Some(hi)) = (
r.first().and_then(|v| v.as_i64()),
r.get(1).and_then(|v| v.as_i64()),
)
{
Some(((lo, 0), (hi, i64::MAX)))
} else {
format_of(&pack["pack_format"]).map(|f| ((f.0, 0), (f.0, i64::MAX)))
};
match range {
None => Compat::Unknown,
Some((_, max)) if max < current => Compat::TooOld,
Some((min, _)) if current < min => Compat::TooNew,
_ => Compat::Compatible,
match validate_pack_range(&meta["pack"], last_pre_minor(current)) {
Err(_) => Compat::Unknown,
Ok((_, max)) if max < current => Compat::TooOld,
Ok((min, _)) if current < min => Compat::TooNew,
Ok(_) => Compat::Compatible,
}
}
/// Whether an `overlays.entries[]` item applies to the current format
@@ -739,6 +902,12 @@ pub fn enabled(all: &[Pack], ids: &[String]) -> Vec<Pack> {
.filter_map(|id| all.iter().find(|p| &p.id == id).cloned())
.collect()
}
/// `PackRepository.isAbleToClearAnyPack`: clearing the selection
/// (`rebuildSelected([])`, which keeps only the required vanilla pack)
/// changes it, that is, some selected pack still exists.
pub fn able_to_clear_any_pack(all: &[Pack], selected: &[String]) -> bool {
!enabled(all, selected).is_empty()
}
/// Resource root for a pack selection: vanilla itself when no pack is on.
pub fn resource_root(vanilla: &Path, packs: &[Pack], cache: &Path) -> io::Result<PathBuf> {
if packs.is_empty() {
@@ -957,6 +1126,134 @@ mod tests {
assert!(read_meta(&Source::Dir(dir.clone())).is_none());
fs::remove_dir_all(&dir).ok();
}
/// `PackFormat.IntermediaryFormat.validate` and its three helpers, with
/// the resource pack's last pre-minor version, 64.
#[test]
fn pack_format_validation() {
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
let check = |s: &str| validate_pack_range(&v(s), 64);
// validate: both min_format and max_format, or neither.
assert!(
check(r#"{"min_format":97}"#)
.unwrap_err()
.contains("both min_format and max_format")
);
assert!(check(r#"{"max_format":97}"#).is_err());
assert!(check("{}").unwrap_err().contains("missing format version"));
// A pack_format alone: fine up to 64, not above.
assert_eq!(check(r#"{"pack_format":34}"#), Ok(((34, 0), (34, 0))));
assert!(
check(r#"{"pack_format":97}"#)
.unwrap_err()
.contains("newer than 64")
);
// validateNewFormat: min above max, the deprecated key above 64.
assert!(
check(r#"{"min_format":98,"max_format":97}"#)
.unwrap_err()
.contains("greater than max_format")
);
assert!(check(r#"{"min_format":[97,2],"max_format":[97,1]}"#).is_err());
assert!(
check(r#"{"min_format":97,"max_format":98,"supported_formats":[97,98]}"#)
.unwrap_err()
.contains("deprecated")
);
assert_eq!(
check(r#"{"min_format":97,"max_format":98}"#),
Ok(((97, 0), (98, i64::from(i32::MAX))))
);
assert_eq!(
check(r#"{"min_format":[97,0],"max_format":[97,1]}"#),
Ok(((97, 0), (97, 1)))
);
// validatePackFormatForRange: a pack_format outside the range.
assert!(
check(r#"{"min_format":97,"max_format":98,"pack_format":99}"#)
.unwrap_err()
.contains("declared main format is 99")
);
assert!(check(r#"{"min_format":97,"max_format":98,"pack_format":98}"#).is_ok());
// Below 65 the old fields are required and must agree.
assert!(
check(r#"{"min_format":34,"max_format":70}"#)
.unwrap_err()
.contains("require a supported_formats")
);
assert!(
check(
r#"{"min_format":34,"max_format":70,"supported_formats":[35,64],"pack_format":35}"#
)
.unwrap_err()
.contains("mismatch")
);
assert!(
check(
r#"{"min_format":34,"max_format":70,"supported_formats":[34,60],"pack_format":35}"#
)
.unwrap_err()
.contains("mismatch")
);
assert!(
check(r#"{"min_format":34,"max_format":70,"supported_formats":[34,64]}"#)
.unwrap_err()
.contains("require a pack_format")
);
assert!(
check(
r#"{"min_format":34,"max_format":70,"supported_formats":[34,64],"pack_format":33}"#
)
.is_err()
);
assert!(
check(
r#"{"min_format":34,"max_format":70,"supported_formats":[34,64],"pack_format":34}"#
)
.is_ok()
);
// Multi-version packs below 15 cannot work.
assert!(
check(
r#"{"min_format":10,"max_format":70,"supported_formats":[10,64],"pack_format":10}"#
)
.unwrap_err()
.contains("less than 15")
);
// validateOldFormat: a range above 64, or no pack_format.
assert!(
check(r#"{"supported_formats":[34,70],"pack_format":34}"#)
.unwrap_err()
.contains("newer than 64")
);
assert!(
check(r#"{"supported_formats":[34,60]}"#)
.unwrap_err()
.contains("require a pack_format")
);
assert_eq!(
check(r#"{"supported_formats":[34,60],"pack_format":40}"#),
Ok(((34, 0), (60, 0)))
);
assert!(
check(
r#"{"supported_formats":{"min_inclusive":34,"max_inclusive":60},"pack_format":40}"#
)
.is_ok()
);
assert!(check(r#"{"supported_formats":[60,34],"pack_format":40}"#).is_err());
// A refused pack is of unknown format.
use crate::pack_screen::Compat;
assert_eq!(
compatibility(&v(r#"{"pack":{"pack_format":97}}"#)),
Compat::Unknown
);
assert_eq!(
compatibility(&v(
r#"{"pack":{"min_format":97,"max_format":98,"pack_format":99}}"#
)),
Compat::Unknown
);
}
#[test]
fn compatibility_ranges() {
let v = |s: &str| serde_json::from_str::<serde_json::Value>(s).unwrap();
@@ -969,7 +1266,6 @@ mod tests {
assert!(!compatible(&v(
r#"{"pack":{"min_format":[97,2],"max_format":99}}"#
)));
assert!(compatible(&v(r#"{"pack":{"pack_format":97}}"#)));
assert!(!compatible(&v(r#"{"pack":{"pack_format":34}}"#)));
assert_eq!(
component(&v(r#"["a",{"text":"b","color":"red"}]"#)).plain(),
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,124 @@
// 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.
// OutSize, InSize | BlurSize, menu blur radius.
@group(0) @binding(0) var<uniform> info: array<vec4<f32>, 2>;
// The vertex and fragment stage's uniform block, packed as std140.
@group(0) @binding(1) var<uniform> vs_cfg: array<vec4<f32>, 8>;
@group(0) @binding(2) var<uniform> fs_cfg: array<vec4<f32>, 8>;
@group(0) @binding(3) var in_tex: texture_2d<f32>;
@group(0) @binding(4) var in_smp: sampler;
@group(0) @binding(5) var blur_tex: texture_2d<f32>;
@group(0) @binding(6) var blur_smp: sampler;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) scaled: vec2<f32>,
}
fn vcf(i: u32) -> f32 {
return vs_cfg[i / 4u][i % 4u];
}
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);
}
fn read_blur(uv: vec2<f32>) -> vec4<f32> {
return textureSampleLevel(blur_tex, blur_smp, vec2(uv.x, 1.0 - uv.y), 0.0);
}
fn corner(i: u32) -> vec2<f32> {
return vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
}
@vertex fn vs_quad(@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.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.uv = p;
let radians = vcf(4u) * 0.0174532925;
let c = cos(radians);
let s = sin(radians);
let rotated = vec2(p.x * c - p.y * s, p.y * c + p.x * s);
out.scaled = rotated * vec2(vcf(0u), vcf(1u)) + vec2(vcf(2u), vcf(3u));
return out;
}
@fragment fn fs_invert(in: Out) -> @location(0) vec4<f32> {
let d = read_in(in.uv);
let mixed = mix(d, 1.0 - d, fcf(0u));
return vec4(mixed.rgb, 1.0);
}
@fragment fn fs_blit(in: Out) -> @location(0) vec4<f32> {
return read_in(in.uv) * vec4(fcf(0u), fcf(1u), fcf(2u), fcf(3u));
}
@fragment fn fs_bits(in: Out) -> @location(0) vec4<f32> {
let resolution = fcf(0u);
let mosaic_in = info[0].zw / fcf(1u);
let fract_pix = fract(in.uv * mosaic_in) / mosaic_in;
var base = read_in(in.uv - fract_pix);
let quantised = base.rgb - fract(base.rgb * resolution) / resolution;
let luma = dot(quantised, vec3(0.3, 0.59, 0.11));
let chroma = (quantised - luma) * 1.5;
return vec4(luma + chroma, 1.0);
}
@fragment fn fs_color_convolve(in: Out) -> @location(0) vec4<f32> {
let t = read_in(in.uv).rgb;
let red = dot(t, vec3(fcf(0u), fcf(1u), fcf(2u)));
let green = dot(t, vec3(fcf(4u), fcf(5u), fcf(6u)));
let blue = dot(t, vec3(fcf(8u), fcf(9u), fcf(10u)));
let rgb = vec3(red, green, blue);
let luma = dot(rgb, vec3(0.3, 0.59, 0.11));
return vec4((rgb - luma) * 1.8 + luma, 1.0);
}
@fragment fn fs_box_blur(in: Out) -> @location(0) vec4<f32> {
let step = (1.0 / info[0].zw) * vec2(fcf(0u), fcf(1u));
let configured = fcf(2u);
var radius = info[1].z;
if configured >= 0.5 {
radius = round(configured);
}
var blurred = vec4(0.0);
for (var a = -radius + 0.5; a <= radius; a += 2.0) {
blurred += read_in(in.uv + step * a);
}
blurred += read_in(in.uv + step * radius) / 2.0;
return blurred / (radius + 0.5);
}
@fragment fn fs_spiderclip(in: Out) -> @location(0) vec4<f32> {
let scaled_texel = read_in(in.scaled);
let blur_texel = read_blur(in.uv);
let scissor = vec4(fcf(0u), fcf(1u), fcf(2u), fcf(3u));
let vignette = vec4(fcf(4u), fcf(5u), fcf(6u), fcf(7u));
let s = in.scaled;
var o = scaled_texel;
if s.x < scissor.x { o = blur_texel; }
if s.y < scissor.y { o = blur_texel; }
if s.x > scissor.z { o = blur_texel; }
if s.y > scissor.w { o = blur_texel; }
if s.x < vignette.x { o = mix(blur_texel, o, (scissor.x - s.x) / (scissor.x - vignette.x)); }
if s.y < vignette.y { o = mix(blur_texel, o, (scissor.y - s.y) / (scissor.y - vignette.y)); }
if s.x > vignette.z { o = mix(blur_texel, o, (scissor.z - s.x) / (scissor.z - vignette.z)); }
if s.y > vignette.w { o = mix(blur_texel, o, (scissor.w - s.y) / (scissor.w - vignette.w)); }
return vec4(o.rgb, 1.0);
}
+44 -1
View File
@@ -189,6 +189,10 @@ pub struct Settings {
pub ctrl_click_emulates_right_click: bool,
/// Horizon's own graphics settings (`horizon-graphics.txt`, not options.txt).
pub horizon: crate::graphics::Graphics,
/// The graphics API the game started with: `Options.preferredGraphicsBackendFromStartup`.
pub startup_api: crate::graphics::GraphicsApi,
/// `Monitor.videoModes` of the window's monitor, for the Video page (set by the game).
pub video_modes: Vec<crate::window::VideoMode>,
/// Controller settings (`horizon-controller.txt`, Horizon's own).
pub controller: crate::gamepad::PadSettings,
/// Horizon's optional extras (`horizon-extras.txt`), all off by default.
@@ -275,6 +279,8 @@ impl Default for Settings {
quit_shortcuts: true,
ctrl_click_emulates_right_click: false,
horizon: Default::default(),
startup_api: Default::default(),
video_modes: Vec::new(),
controller: Default::default(),
extras: Default::default(),
mods: Default::default(),
@@ -308,6 +314,16 @@ fn put_s(f: &mut OptionsFile, key: &str, v: &str) {
}
}
impl Settings {
/// The index in `video_modes` of the preferred fullscreen mode, -1 (the desktop's own mode)
/// when none is set or the monitor lacks it: `Monitor.indexOfMode`.
pub fn video_mode_index(&self) -> i32 {
self.file
.fullscreen_resolution
.as_deref()
.and_then(crate::window::VideoMode::read)
.and_then(|mode| self.video_modes.iter().position(|m| *m == mode))
.map_or(-1, |i| i as i32)
}
pub fn load(path: &Path) -> Self {
let horizon = path
.parent()
@@ -342,7 +358,11 @@ impl Settings {
},
};
settings.describe_for_crashes();
settings
// Options.load: the backend asked for at startup, what a restart is measured from.
Self {
startup_api: settings.horizon.api,
..settings
}
}
/// The render settings and resource packs a crash report lists.
fn describe_for_crashes(&self) {
@@ -441,6 +461,8 @@ impl Settings {
quit_shortcuts: f.bool("quitShortcuts"),
ctrl_click_emulates_right_click: f.bool("ctrlClickEmulatesRightClick"),
horizon: Default::default(),
startup_api: Default::default(),
video_modes: Vec::new(),
controller: Default::default(),
extras: Default::default(),
mods: Default::default(),
@@ -767,6 +789,16 @@ pub enum Opt {
SharePresence,
DirectionalAudio,
MusicToast,
/// `Options.exclusiveFullscreen`.
ExclusiveFullscreen,
/// VideoSettingsScreen's "Exclusive Fullscreen Mode": the monitor's mode to take.
ExclusiveMode,
/// `Options.preferredGraphicsBackend`.
GraphicsBackend,
/// `Options.textureFiltering`.
TextureFiltering,
/// `Options.maxAnisotropyBit`.
MaxAnisotropy,
}
#[cfg(test)]
@@ -835,6 +867,17 @@ mod tests {
assert_eq!(again.keys.get("key.jump"), "key.keyboard.j");
}
#[test]
fn client_information_maps_the_stored_main_hand() {
// The Skin Customization option is stored by name; ClientInformation
// sends HumanoidArm (LEFT = 0, RIGHT = 1).
for (stored, arm) in [("left", 0), ("right", 1)] {
let s = Settings::parse(&format!("mainHand:{stored}\n"));
assert_eq!(s.client_information(10).main_hand, arm);
assert!(s.serialize().contains(&format!("mainHand:\"{stored}\"")));
}
assert_eq!(Settings::default().client_information(10).main_hand, 1);
}
#[test]
fn no_options_txt_paces_frames_like_an_empty_one() {
let (fresh, empty) = (Settings::default(), Settings::parse(""));
assert_eq!(fresh.max_fps, 120);
+80 -89
View File
@@ -32,45 +32,14 @@ pub const TEXTURE_URIS: [&str; 2] = [
/// `java.util.Base64.getDecoder().decode`.
pub fn base64_decode(text: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(text.len() / 4 * 3);
let (mut bits, mut shift) = (0u32, 18i32);
let bytes: Vec<char> = text.chars().collect();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
i += 1;
let b = match c {
'A'..='Z' => c as u32 - 'A' as u32,
'a'..='z' => c as u32 - 'a' as u32 + 26,
'0'..='9' => c as u32 - '0' as u32 + 52,
'+' => 62,
'/' => 63,
'=' => {
if shift == 6 && (i == bytes.len() || bytes[i] != '=') || shift == 18 {
return None;
}
if shift == 6 {
i += 1;
}
break;
}
_ => return None,
};
bits |= b << shift;
shift -= 6;
if shift < 0 {
out.extend([(bits >> 16) as u8, (bits >> 8) as u8, bits as u8]);
shift = 18;
bits = 0;
}
}
match shift {
6 => out.push((bits >> 16) as u8),
0 => out.extend([(bits >> 16) as u8, (bits >> 8) as u8]),
12 => return None,
_ => {}
}
(i >= bytes.len()).then_some(out)
use base64::{
Engine,
engine::general_purpose::{STANDARD, STANDARD_NO_PAD},
};
STANDARD
.decode(text)
.or_else(|_| STANDARD_NO_PAD.decode(text))
.ok()
}
/// `unpackTextures(property)`: the textures of a `textures` property's
@@ -557,6 +526,39 @@ pub fn offline_cape() -> Option<&'static [u8]> {
.as_deref()
}
/// The skin the launcher gives an offline name (`HORIZON_OFFLINE_SKIN`, a
/// PNG's path, and `HORIZON_OFFLINE_SKIN_MODEL`, `classic` or `slim`;
/// launcher/src/skins.rs): its 64x64 RGBA as `process_legacy_skin` lays it
/// out, and whether it is slim. Read once.
pub fn offline_skin() -> Option<&'static (Vec<u8>, bool)> {
static SKIN: std::sync::OnceLock<Option<(Vec<u8>, bool)>> = std::sync::OnceLock::new();
SKIN.get_or_init(|| {
let path = std::env::var_os("HORIZON_OFFLINE_SKIN").filter(|p| !p.is_empty())?;
let model = std::env::var("HORIZON_OFFLINE_SKIN_MODEL").unwrap_or_default();
read_offline_skin(std::path::Path::new(&path), &model)
.inspect_err(|e| eprintln!("Horizon: no offline skin ({}): {e}", path.display()))
.ok()
})
.as_ref()
}
fn read_offline_skin(path: &std::path::Path, model: &str) -> Result<(Vec<u8>, bool), String> {
let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
let (w, h, rgba) = crate::hud::decode_bytes(&bytes).map_err(|e| e.to_string())?;
let skin = process_legacy_skin(w, h, &rgba).ok_or("not a 64x64 or 64x32 skin")?;
Ok((skin, model == "slim"))
}
/// The signed-in account's own profile properties (`Minecraft.profileFuture`:
/// `fetchProfile(id, true)`, so its `textures` property is signed), or empty
/// without an answer. Blocks on the network.
pub fn fetch_own_properties(id: u128) -> Vec<(String, String, Option<String>)> {
crate::auth::discovery()
.ok()
.and_then(|doc| resolve_profile(&NameOrId::Id(id), &doc))
.map(|p| p.properties)
.unwrap_or_default()
}
/// Where a texture download stands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Download {
@@ -902,60 +904,49 @@ fn http_get(url: &str) -> Option<Vec<u8>> {
/// SHA-1 (FIPS 180-4).
pub fn sha1(data: &[u8]) -> [u8; 20] {
let mut h: [u32; 5] = [
0x6745_2301,
0xEFCD_AB89,
0x98BA_DCFE,
0x1032_5476,
0xC3D2_E1F0,
];
let mut msg = data.to_vec();
msg.push(0x80);
while msg.len() % 64 != 56 {
msg.push(0);
}
msg.extend((data.len() as u64 * 8).to_be_bytes());
for block in msg.as_chunks::<64>().0 {
let mut w = [0u32; 80];
for (i, word) in block.as_chunks::<4>().0.iter().enumerate() {
w[i] = u32::from_be_bytes(*word);
}
for i in 16..80 {
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
}
let [mut a, mut b, mut c, mut d, mut e] = h;
for (i, wi) in w.iter().enumerate() {
let (f, k) = match i {
0..=19 => ((b & c) | (!b & d), 0x5A82_7999),
20..=39 => (b ^ c ^ d, 0x6ED9_EBA1),
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1B_BCDC),
_ => (b ^ c ^ d, 0xCA62_C1D6),
};
let t = a
.rotate_left(5)
.wrapping_add(f)
.wrapping_add(e)
.wrapping_add(k)
.wrapping_add(*wi);
e = d;
d = c;
c = b.rotate_left(30);
b = a;
a = t;
}
for (x, y) in h.iter_mut().zip([a, b, c, d, e]) {
*x = x.wrapping_add(y);
}
}
let mut out = [0u8; 20];
for (i, x) in h.iter().enumerate() {
out[i * 4..i * 4 + 4].copy_from_slice(&x.to_be_bytes());
}
out
use sha1::{Digest, Sha1};
Sha1::digest(data).into()
}
#[cfg(test)]
mod tests {
/// 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.
#[test]
fn offline_skins_read_like_downloaded_ones() {
let png = |w: u32, h: u32| {
let rgba: Vec<u8> = (0..w * h * 4).map(|i| (i % 251) as u8).collect();
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&rgba)
.unwrap();
(out, rgba)
};
let dir = std::env::temp_dir().join(format!("horizon-offline-skin-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let file = dir.join("steve.png");
for (h, model, slim) in [(64, "slim", true), (32, "classic", false), (64, "", false)] {
let (bytes, rgba) = png(64, h);
std::fs::write(&file, bytes).unwrap();
let (skin, is_slim) = super::read_offline_skin(&file, model).unwrap();
assert_eq!(skin, super::process_legacy_skin(64, h, &rgba).unwrap());
assert_eq!(skin.len(), 64 * 64 * 4);
assert_eq!(is_slim, slim);
}
std::fs::write(&file, png(32, 32).0).unwrap();
assert!(super::read_offline_skin(&file, "slim").is_err());
std::fs::write(&file, b"not a png").unwrap();
assert!(super::read_offline_skin(&file, "slim").is_err());
assert!(super::read_offline_skin(&dir.join("missing.png"), "").is_err());
let _ = std::fs::remove_dir_all(&dir);
}
/// `MinecraftProfileTexture.getMetadata("model")`: `slim` marks a slim
/// skin; no metadata is the wide (classic) model.
#[test]
@@ -58,8 +58,13 @@ pub enum Ticking {
Bee { bee: u32, aggressive: bool },
/// `GuardianAttackSoundInstance`.
GuardianAttack { guardian: u32 },
/// `SnifferSoundInstance`.
Sniffer { sniffer: u32 },
/// `ElytraOnPlayerSoundInstance`: ticks since it started.
Elytra { time: i32 },
/// `RidingEntitySoundInstance` for the player riding a happy ghast
/// (`underwater_sound` false) or a nautilus (true).
RidingEntity { entity: u32, underwater_sound: bool },
}
/// What a minecart's sound instances read about it.
#[derive(Clone, Copy, Debug)]
@@ -83,6 +88,19 @@ pub struct MobSound {
pub silent: bool,
/// `Guardian.getAttackAnimationScale(0)`.
pub attack_scale: f32,
/// `getTarget() == null && Sniffer.canPlayDiggingSound()`.
pub sniffer_digging: bool,
}
/// What a `RidingEntitySoundInstance` reads about the mob the player rides.
#[derive(Clone, Copy, Debug)]
pub struct RiddenSound {
pub id: u32,
/// `getDeltaMovement().length()`.
pub speed: f64,
/// `isSilent`.
pub silent: bool,
/// `Entity.isUnderWater`.
pub underwater: bool,
}
/// What tickable instances read about the player and the entities they follow.
#[derive(Clone, Debug, Default)]
@@ -103,6 +121,8 @@ pub struct TickContext {
pub player_speed_sqr: f64,
/// `isFallFlying()` of the player.
pub player_fall_flying: bool,
/// The mob the player rides, when it is not a minecart.
pub ridden: Option<RiddenSound>,
}
/// `AbstractSoundInstance` and its subclasses' fields.
#[derive(Clone, Debug)]
@@ -345,6 +365,43 @@ impl SoundInstance {
};
s
}
/// `new RidingEntitySoundInstance(player, happyGhast, false,
/// HAPPY_GHAST_RIDING, happyGhast.getSoundSource(), 0, 1, 5)`, from
/// `LocalPlayer.startRiding`.
pub fn riding_happy_ghast(ghast: &RiddenSound, random: LegacyRandom) -> Self {
Self::riding_entity("minecraft:entity.happy_ghast.riding", ghast, false, random)
}
/// `new RidingEntitySoundInstance(player, nautilus, true,
/// NAUTILUS_RIDING, nautilus.getSoundSource(), 0, 1, 5)`, from
/// `LocalPlayer.startRiding`.
pub fn riding_nautilus(nautilus: &RiddenSound, random: LegacyRandom) -> Self {
Self::riding_entity("minecraft:entity.nautilus.riding", nautilus, true, random)
}
fn riding_entity(
id: &str,
entity: &RiddenSound,
underwater_sound: bool,
random: LegacyRandom,
) -> Self {
let mut s = Self::new(
id,
NEUTRAL,
0.0,
1.0,
random,
true,
0,
Attenuation::None,
[0.0; 3],
false,
);
s.can_start_silent = true;
s.ticking = Ticking::RidingEntity {
entity: entity.id,
underwater_sound,
};
s
}
/// `new BeeFlyingSoundInstance(bee)` (or `BeeAggressiveSoundInstance`):
/// silent until the bee moves.
pub fn bee(bee: &MobSound, aggressive: bool, random: LegacyRandom) -> Self {
@@ -398,6 +455,25 @@ impl SoundInstance {
};
s
}
/// `new SnifferSoundInstance(sniffer)`: the digging sound, which sits at
/// the origin (`AbstractSoundInstance` leaves x, y, z at 0) until its
/// first tick moves it to the sniffer.
pub fn sniffer(sniffer: u32, random: LegacyRandom) -> Self {
let mut s = Self::new(
"minecraft:entity.sniffer.digging",
NEUTRAL,
1.0,
1.0,
random,
false,
0,
Attenuation::Linear,
[0.0; 3],
false,
);
s.ticking = Ticking::Sniffer { sniffer };
s
}
/// `new ElytraOnPlayerSoundInstance(player)`: looping at volume 0.1 until
/// its first tick.
pub fn elytra(position: [f64; 3], random: LegacyRandom) -> Self {
@@ -551,6 +627,27 @@ impl SoundInstance {
1.0
};
}
Ticking::Sniffer { sniffer } => {
// canPlaySound (`!isSilent`), then `tick`: follow the sniffer
// while it is alive, has no target and still digs or searches.
let id = *sniffer;
let Some(sniffer) = ctx
.mobs
.iter()
.find(|m| m.id == id && !m.silent && m.sniffer_digging)
else {
self.stop();
return;
};
let [x, y, z] = sniffer.position;
self.position = [
f64::from(x as f32),
f64::from(y as f32),
f64::from(z as f32),
];
self.volume = 1.0;
self.pitch = 1.0;
}
Ticking::GuardianAttack { guardian } => {
let id = *guardian;
// The client's guardian never has a target of its own, so
@@ -569,6 +666,26 @@ impl SoundInstance {
self.volume = 0.0 + 1.0 * scale * scale;
self.pitch = 0.7 + 0.5 * scale;
}
Ticking::RidingEntity {
entity,
underwater_sound,
} => {
// RidingEntitySoundInstance.tick: canPlaySound, then while the
// player still rides it, volumeMin (0) when
// `underwaterSound != entity.isUnderWater()` and else
// `volumeAmplifier * Mth.clampedLerp(speed, volumeMin, volumeMax)`.
let (id, underwater_sound) = (*entity, *underwater_sound);
let Some(entity) = ctx.ridden.filter(|r| r.id == id && !r.silent) else {
self.stop();
return;
};
let speed = entity.speed as f32;
self.volume = if underwater_sound != entity.underwater || speed < 0.01 {
0.0
} else {
5.0 * speed.clamp(0.0, 1.0)
};
}
Ticking::RidingMinecart { cart, underwater } => {
let (id, underwater) = (*cart, *underwater);
let found = ctx.carts.iter().find(|c| c.id == id);
@@ -1328,3 +1445,108 @@ pub mod test_backend {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn happy_ghast_riding_sound_follows_the_ghast_speed() {
let ghast = RiddenSound {
id: 7,
speed: 0.1,
silent: false,
underwater: false,
};
let mut s = SoundInstance::riding_happy_ghast(&ghast, SoundEngine::unseeded());
assert_eq!(s.volume, 0.0);
let mut ctx = TickContext {
ridden: Some(ghast),
..TickContext::default()
};
// 5 * clampedLerp(speed, 0, 1).
s.tick(&ctx);
assert!((s.volume - 0.5).abs() < 1e-6, "{}", s.volume);
ctx.ridden = Some(RiddenSound {
speed: 3.0,
..ghast
});
s.tick(&ctx);
assert_eq!(s.volume, 5.0);
ctx.ridden = Some(RiddenSound {
speed: 0.005,
..ghast
});
s.tick(&ctx);
assert_eq!(s.volume, 0.0);
// Under water the ghast's sound is not the underwater one.
ctx.ridden = Some(RiddenSound {
underwater: true,
..ghast
});
s.tick(&ctx);
assert_eq!(s.volume, 0.0);
assert!(!s.stopped);
// Off the ghast, it stops.
ctx.ridden = None;
s.tick(&ctx);
assert!(s.stopped);
}
#[test]
fn sniffer_digging_sound_follows_the_sniffer_and_stops() {
let sniffer = MobSound {
id: 3,
position: [10.0, 64.0, -5.0],
speed: 0.0,
baby: false,
angry: false,
silent: false,
attack_scale: 0.0,
sniffer_digging: true,
};
let mut s = SoundInstance::sniffer(3, SoundEngine::unseeded());
assert_eq!(s.position, [0.0; 3]);
assert!(!s.looping);
let mut ctx = TickContext {
mobs: vec![sniffer],
..TickContext::default()
};
s.tick(&ctx);
assert_eq!(s.position, [10.0, 64.0, -5.0]);
assert!(!s.stopped);
// A target, a state other than digging or searching: stop.
ctx.mobs[0].sniffer_digging = false;
s.tick(&ctx);
assert!(s.stopped);
// Removed (missing): stop.
let mut s = SoundInstance::sniffer(3, SoundEngine::unseeded());
ctx.mobs.clear();
s.tick(&ctx);
assert!(s.stopped);
}
#[test]
fn nautilus_riding_sound_plays_only_under_water() {
let nautilus = RiddenSound {
id: 9,
speed: 0.2,
silent: false,
underwater: true,
};
let mut s = SoundInstance::riding_nautilus(&nautilus, SoundEngine::unseeded());
let mut ctx = TickContext {
ridden: Some(nautilus),
..TickContext::default()
};
s.tick(&ctx);
assert!((s.volume - 1.0).abs() < 1e-6, "{}", s.volume);
ctx.ridden = Some(RiddenSound {
underwater: false,
..nautilus
});
s.tick(&ctx);
assert_eq!(s.volume, 0.0);
assert!(!s.stopped);
}
}
@@ -313,6 +313,9 @@ pub struct Streaming {
decorates: bool,
lighting: FxHashMap<(i32, i32), u64>,
meshing: FxHashMap<(i32, i32, i32), u64>,
/// The sequence each section's latest accepted mesh was dispatched at
/// (`CompiledSectionMesh.compileTaskStartTimeNs`).
compile_started: FxHashMap<(i32, i32, i32), u64>,
/// Chunks whose light is current.
lit: FxHashSet<(i32, i32)>,
/// Columns whose sections have been meshed at least once.
@@ -687,6 +690,7 @@ impl Streaming {
decorates: generator.decorates(),
lighting: Default::default(),
meshing: Default::default(),
compile_started: Default::default(),
lit: Default::default(),
meshed: Default::default(),
waiting: Default::default(),
@@ -730,6 +734,7 @@ impl Streaming {
pub fn reload_resources(&mut self, world: &mut World, context: Arc<WorkerContext>) {
*self.shared.context.write().unwrap() = context;
self.meshing.clear();
self.compile_started.clear();
for &(x, z) in world.chunks.keys() {
if self.lit.contains(&(x, z)) {
for sy in 0..world.layout().sections() as i32 {
@@ -793,6 +798,7 @@ impl Streaming {
// shrunk storage forgets thousands of columns in one frame.
for sy in 0..world.layout().sections() as i32 {
self.meshing.remove(&(x, sy, z));
self.compile_started.remove(&(x, sy, z));
self.waiting.remove(&(x, sy, z));
}
world.dirty_sections.take_column(x, z);
@@ -1002,6 +1008,17 @@ impl Streaming {
return None;
})
}
/// The sequence the next mesh job will be dispatched after: a clock for
/// `compile_start`, whose jobs all snapshot the world as they are
/// dispatched (`Util.getNanos()` at the start of a compile task).
pub fn sequence(&self) -> u64 {
self.sequence
}
/// When the section's latest accepted mesh started compiling, on the
/// `sequence` clock (`CompiledSectionMesh.getCompileTaskStartTime`).
pub fn compile_start(&self, section: (i32, i32, i32)) -> Option<u64> {
self.compile_started.get(&section).copied()
}
pub fn is_meshing(&self, section: (i32, i32, i32)) -> bool {
self.meshing.contains_key(&section)
}
@@ -1125,6 +1142,7 @@ impl Streaming {
continue;
}
self.meshing.remove(&(x, sy, z));
self.compile_started.insert((x, sy, z), seq);
self.meshed_total += 1;
uploads.push(((x, sy, z), mesh));
}
@@ -1156,6 +1174,8 @@ impl Streaming {
if !far.is_empty() {
let far: HashSet<(i32, i32)> = far.iter().copied().collect();
self.meshing.retain(|k, _| !far.contains(&(k.0, k.2)));
self.compile_started
.retain(|k, _| !far.contains(&(k.0, k.2)));
self.waiting.retain(|k| !far.contains(&(k.0, k.2)));
world
.dirty_sections
@@ -0,0 +1,496 @@
//! `StructureBlockEntity`: the structure block's name, offset, size, mode and
//! options, with its save format (`saveAdditional` / `loadAdditional`), the
//! corner scan (`getEnclosingBoundingBox`) and the box the editor shows
//! (`getRenderableBox`).
use horizon_client_core::nbt_value::{float, number, string, text};
use horizon_world::{
nbt::Tag,
worldgen::structure::{Mirror, Rotation},
};
pub const MAX_OFFSET_PER_AXIS: i32 = 48;
pub const MAX_SIZE_PER_AXIS: i32 = 48;
/// `SCAN_CORNER_BLOCKS_RANGE`.
pub const SCAN_CORNER_BLOCKS_RANGE: i32 = 80;
/// `StructureMode`, in declaration order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum StructureMode {
Save,
Load,
Corner,
Data,
}
impl StructureMode {
pub const ALL: [Self; 4] = [Self::Save, Self::Load, Self::Corner, Self::Data];
/// `StructureMode.getSerializedName`: the block state's `mode` value.
pub fn serialized_name(self) -> &'static str {
match self {
Self::Save => "save",
Self::Load => "load",
Self::Corner => "corner",
Self::Data => "data",
}
}
pub fn of_serialized_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.serialized_name() == name)
}
/// `StructureMode.LEGACY_CODEC`: `Enum.name()`.
pub fn legacy_name(self) -> &'static str {
match self {
Self::Save => "SAVE",
Self::Load => "LOAD",
Self::Corner => "CORNER",
Self::Data => "DATA",
}
}
pub fn from_legacy_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.legacy_name() == name)
}
pub fn ordinal(self) -> i32 {
self as i32
}
pub fn from_ordinal(ordinal: i32) -> Option<Self> {
usize::try_from(ordinal)
.ok()
.and_then(|i| Self::ALL.get(i).copied())
}
/// `StructureMode.getDisplayName`'s lang key.
pub fn info_key(self) -> String {
format!("structure_block.mode_info.{}", self.serialized_name())
}
/// The mode button's label (`structure_block.mode.<name>`).
pub fn label_key(self) -> String {
format!("structure_block.mode.{}", self.serialized_name())
}
}
/// `StructureBlockEntity.UpdateType`, in declaration order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UpdateType {
UpdateData,
SaveArea,
LoadArea,
ScanArea,
}
impl UpdateType {
pub fn ordinal(self) -> i32 {
self as i32
}
pub fn from_ordinal(ordinal: i32) -> Option<Self> {
Some(match ordinal {
0 => Self::UpdateData,
1 => Self::SaveArea,
2 => Self::LoadArea,
3 => Self::ScanArea,
_ => return None,
})
}
}
pub fn rotation_from_ordinal(ordinal: i32) -> Option<Rotation> {
usize::try_from(ordinal)
.ok()
.and_then(|i| Rotation::ALL.get(i).copied())
}
pub fn rotation_ordinal(rotation: Rotation) -> i32 {
rotation as i32
}
pub fn rotation_from_legacy_name(name: &str) -> Option<Rotation> {
Rotation::ALL.into_iter().find(|r| r.saved_name() == name)
}
pub fn mirror_from_ordinal(ordinal: i32) -> Option<Mirror> {
Some(match ordinal {
0 => Mirror::None,
1 => Mirror::LeftRight,
2 => Mirror::FrontBack,
_ => return None,
})
}
pub fn mirror_ordinal(mirror: Mirror) -> i32 {
mirror as i32
}
pub fn mirror_from_legacy_name(name: &str) -> Option<Mirror> {
[Mirror::None, Mirror::LeftRight, Mirror::FrontBack]
.into_iter()
.find(|m| m.saved_name() == name)
}
/// `BoundingBoxRenderable.Mode`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RenderMode {
None,
Box,
BoxAndInvisibleBlocks,
}
/// `BoundingBoxRenderable.RenderableBox`: an offset from the block and a
/// size, in blocks.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RenderableBox {
pub offset: [i32; 3],
pub size: [i32; 3],
}
impl RenderableBox {
/// `RenderableBox.fromCorners`.
pub fn from_corners(x0: i32, y0: i32, z0: i32, x1: i32, y1: i32, z1: i32) -> Self {
let (min_x, min_y, min_z) = (x0.min(x1), y0.min(y1), z0.min(z1));
Self {
offset: [min_x, min_y, min_z],
size: [x0.max(x1) - min_x, y0.max(y1) - min_y, z0.max(z1) - min_z],
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct StructureBlock {
/// The structure's id; an unparsable or empty name is none.
pub name: Option<String>,
pub author: String,
pub metadata: String,
pub pos: [i32; 3],
pub size: [i32; 3],
pub mirror: Mirror,
pub rotation: Rotation,
pub mode: StructureMode,
pub ignore_entities: bool,
pub strict: bool,
pub powered: bool,
pub show_air: bool,
pub show_bounding_box: bool,
pub integrity: f32,
pub seed: i64,
}
impl StructureBlock {
/// `new StructureBlockEntity(pos, state)`: the mode is the state's.
pub fn new(mode: StructureMode) -> Self {
Self {
name: None,
author: String::new(),
metadata: String::new(),
pos: [0, 1, 0],
size: [0; 3],
mirror: Mirror::None,
rotation: Rotation::None,
mode,
ignore_entities: true,
strict: false,
powered: false,
show_air: false,
show_bounding_box: true,
integrity: 1.0,
seed: 0,
}
}
/// `StructureBlockEntity.getStructureName`.
pub fn structure_name(&self) -> &str {
self.name.as_deref().unwrap_or("")
}
/// `setStructureName(String)`: empty or unparsable is none (`Identifier.tryParse`).
pub fn set_structure_name(&mut self, name: &str) {
self.name = if name.is_empty() {
None
} else {
parse_identifier(name)
};
}
/// `StructureBlockEntity.saveAdditional`.
pub fn save(&self) -> Vec<(&'static str, Tag)> {
let byte = |b: bool| Tag::Byte(i8::from(b));
vec![
("name", string(self.structure_name())),
("author", string(&self.author)),
("metadata", string(&self.metadata)),
("posX", Tag::Int(self.pos[0])),
("posY", Tag::Int(self.pos[1])),
("posZ", Tag::Int(self.pos[2])),
("sizeX", Tag::Int(self.size[0])),
("sizeY", Tag::Int(self.size[1])),
("sizeZ", Tag::Int(self.size[2])),
("rotation", string(self.rotation.saved_name())),
("mirror", string(self.mirror.saved_name())),
("mode", string(self.mode.legacy_name())),
("ignoreEntities", byte(self.ignore_entities)),
("strict", byte(self.strict)),
("powered", byte(self.powered)),
("showair", byte(self.show_air)),
("showboundingbox", byte(self.show_bounding_box)),
("integrity", Tag::Float(self.integrity.to_bits())),
("seed", Tag::Long(self.seed)),
]
}
/// `StructureBlockEntity.loadAdditional`.
pub fn load(tag: &Tag) -> Self {
let int = |key: &str, default: i32, lo: i32, hi: i32| {
(number(tag.get(key)).unwrap_or(i64::from(default)) as i32).clamp(lo, hi)
};
let flag = |key: &str, default: bool| number(tag.get(key)).map_or(default, |v| v != 0);
let o = MAX_OFFSET_PER_AXIS;
let s = MAX_SIZE_PER_AXIS;
let mut block = Self::new(
text(tag.get("mode"))
.and_then(|m| StructureMode::from_legacy_name(&m))
.unwrap_or(StructureMode::Data),
);
block.set_structure_name(&text(tag.get("name")).unwrap_or_default());
block.author = text(tag.get("author")).unwrap_or_default();
block.metadata = text(tag.get("metadata")).unwrap_or_default();
block.pos = [
int("posX", 0, -o, o),
int("posY", 1, -o, o),
int("posZ", 0, -o, o),
];
block.size = [
int("sizeX", 0, 0, s),
int("sizeY", 0, 0, s),
int("sizeZ", 0, 0, s),
];
block.rotation = text(tag.get("rotation"))
.and_then(|r| rotation_from_legacy_name(&r))
.unwrap_or(Rotation::None);
block.mirror = text(tag.get("mirror"))
.and_then(|m| mirror_from_legacy_name(&m))
.unwrap_or(Mirror::None);
block.ignore_entities = flag("ignoreEntities", true);
block.strict = flag("strict", false);
block.powered = flag("powered", false);
block.show_air = flag("showair", false);
block.show_bounding_box = flag("showboundingbox", true);
block.integrity = float(tag.get("integrity")).map_or(1.0, |f| f as f32);
block.seed = number(tag.get("seed")).unwrap_or(0);
block
}
/// `StructureBlockEntity.renderMode`.
pub fn render_mode(&self) -> RenderMode {
if self.mode != StructureMode::Save && self.mode != StructureMode::Load {
RenderMode::None
} else if self.mode == StructureMode::Save && self.show_air {
RenderMode::BoxAndInvisibleBlocks
} else if self.mode != StructureMode::Save && !self.show_bounding_box {
RenderMode::None
} else {
RenderMode::Box
}
}
/// `StructureBlockEntity.getRenderableBox`.
pub fn renderable_box(&self) -> RenderableBox {
let [x_origin, y0, z_origin] = self.pos;
let size = self.size;
let y1 = y0 + size[1];
let (x_diff, z_diff) = match self.mirror {
Mirror::LeftRight => (size[0], -size[2]),
Mirror::FrontBack => (-size[0], size[2]),
Mirror::None => (size[0], size[2]),
};
let (x0, z0, x1, z1);
match self.rotation {
Rotation::Clockwise90 => {
x0 = if z_diff < 0 { x_origin } else { x_origin + 1 };
z0 = if x_diff < 0 { z_origin + 1 } else { z_origin };
x1 = x0 - z_diff;
z1 = z0 + x_diff;
}
Rotation::Clockwise180 => {
x0 = if x_diff < 0 { x_origin } else { x_origin + 1 };
z0 = if z_diff < 0 { z_origin } else { z_origin + 1 };
x1 = x0 - x_diff;
z1 = z0 - z_diff;
}
Rotation::Counterclockwise90 => {
x0 = if z_diff < 0 { x_origin + 1 } else { x_origin };
z0 = if x_diff < 0 { z_origin } else { z_origin + 1 };
x1 = x0 + z_diff;
z1 = z0 - x_diff;
}
Rotation::None => {
x0 = if x_diff < 0 { x_origin + 1 } else { x_origin };
z0 = if z_diff < 0 { z_origin + 1 } else { z_origin };
x1 = x0 + x_diff;
z1 = z0 + z_diff;
}
}
RenderableBox::from_corners(x0, y0, z0, x1, y1, z1)
}
/// `StructureBlockEntity.getEnclosingBoundingBox` over the corner blocks
/// of this structure's name found in range (`corners`: every position of
/// a corner-mode structure block with the same name), with `at` the block
/// asking. Returns the inclusive min and max, none without a corner.
pub fn enclosing_box(corners: &[[i32; 3]], at: [i32; 3]) -> Option<([i32; 3], [i32; 3])> {
let mut found: Option<([i32; 3], [i32; 3])> = None;
for c in corners {
found = Some(match found {
None => (*c, *c),
Some((min, max)) => (
[min[0].min(c[0]), min[1].min(c[1]), min[2].min(c[2])],
[max[0].max(c[0]), max[1].max(c[1]), max[2].max(c[2])],
),
});
}
let (min, max) = found?;
if corners.len() == 1 {
return Some((
[min[0].min(at[0]), min[1].min(at[1]), min[2].min(at[2])],
[max[0].max(at[0]), max[1].max(at[1]), max[2].max(at[2])],
));
}
Some((min, max))
}
/// `detectSize`'s result for the box: the offset and size the block takes
/// when every side is wider than the corner blocks themselves.
pub fn detected(min: [i32; 3], max: [i32; 3], at: [i32; 3]) -> Option<([i32; 3], [i32; 3])> {
let delta = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
if delta.iter().all(|d| *d > 1) {
Some((
[min[0] - at[0] + 1, min[1] - at[1] + 1, min[2] - at[2] + 1],
[delta[0] - 1, delta[1] - 1, delta[2] - 1],
))
} else {
None
}
}
}
/// `Identifier.tryParse`: `namespace:path` (the namespace defaults to
/// `minecraft`) with only legal characters, as its string form.
pub fn parse_identifier(text: &str) -> Option<String> {
let (namespace, path) = text.split_once(':').unwrap_or(("minecraft", text));
let namespace = if namespace.is_empty() {
"minecraft"
} else {
namespace
};
let namespace_ok = namespace
.chars()
.all(|c| matches!(c, '_' | '-' | '.' | '0'..='9' | 'a'..='z'));
let path_ok = !path.is_empty()
&& path
.chars()
.all(|c| matches!(c, '_' | '-' | '.' | '/' | '0'..='9' | 'a'..='z'));
(namespace_ok && path_ok).then(|| format!("{namespace}:{path}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn saves_and_loads_like_structure_block_entity() {
let mut block = StructureBlock::new(StructureMode::Save);
block.set_structure_name("house");
block.author = "levzzz".into();
block.pos = [1, -2, 3];
block.size = [4, 5, 6];
block.rotation = Rotation::Clockwise90;
block.mirror = Mirror::FrontBack;
block.ignore_entities = false;
block.strict = true;
block.integrity = 0.25;
block.seed = -9;
let tag = Tag::compound(block.save());
assert_eq!(text(tag.get("name")).as_deref(), Some("minecraft:house"));
assert_eq!(text(tag.get("mode")).as_deref(), Some("SAVE"));
assert_eq!(text(tag.get("rotation")).as_deref(), Some("CLOCKWISE_90"));
assert_eq!(StructureBlock::load(&tag), block.clone());
}
#[test]
fn load_clamps_and_defaults() {
let tag = Tag::compound(vec![
("posX", Tag::Int(99)),
("posY", Tag::Int(-99)),
("sizeX", Tag::Int(-4)),
("sizeY", Tag::Int(100)),
("name", string("Bad Name")),
]);
let block = StructureBlock::load(&tag);
assert_eq!(block.pos, [48, -48, 0]);
assert_eq!(block.size, [0, 48, 0]);
assert_eq!(block.name, None);
assert_eq!(block.mode, StructureMode::Data);
assert!(block.ignore_entities && block.show_bounding_box && !block.strict);
assert_eq!(block.integrity, 1.0);
// Pos defaults to (0, 1, 0).
assert_eq!(
StructureBlock::load(&Tag::compound(Vec::<(&str, Tag)>::new())).pos,
[0, 1, 0]
);
}
#[test]
fn render_mode_and_box_follow_get_renderable_box() {
let mut block = StructureBlock::new(StructureMode::Save);
block.pos = [0, 1, 0];
block.size = [3, 2, 4];
assert_eq!(block.render_mode(), RenderMode::Box);
assert_eq!(
block.renderable_box(),
RenderableBox {
offset: [0, 1, 0],
size: [3, 2, 4]
}
);
block.rotation = Rotation::Clockwise90;
assert_eq!(
block.renderable_box(),
RenderableBox {
offset: [-3, 1, 0],
size: [4, 2, 3]
}
);
block.rotation = Rotation::None;
block.mirror = Mirror::LeftRight;
assert_eq!(
block.renderable_box(),
RenderableBox {
offset: [0, 1, -3],
size: [3, 2, 4]
}
);
block.show_air = true;
assert_eq!(block.render_mode(), RenderMode::BoxAndInvisibleBlocks);
block.mode = StructureMode::Load;
block.show_bounding_box = false;
assert_eq!(block.render_mode(), RenderMode::None);
block.mode = StructureMode::Corner;
assert_eq!(block.render_mode(), RenderMode::None);
}
#[test]
fn corner_scan_matches_detect_size() {
// Two corners: their box; one corner: the box with the asking block.
let (min, max) = StructureBlock::enclosing_box(&[[0, 0, 0], [5, 4, 6]], [2, 0, 2]).unwrap();
assert_eq!((min, max), ([0, 0, 0], [5, 4, 6]));
assert_eq!(
StructureBlock::detected(min, max, [2, 0, 2]),
Some(([-1, 1, -1], [4, 3, 5]))
);
let one = StructureBlock::enclosing_box(&[[0, 0, 0]], [3, 3, 3]).unwrap();
assert_eq!(one, ([0, 0, 0], [3, 3, 3]));
assert_eq!(StructureBlock::enclosing_box(&[], [0, 0, 0]), None);
// A box of one block thickness is no structure.
assert_eq!(
StructureBlock::detected([0, 0, 0], [1, 5, 5], [0, 0, 0]),
None
);
}
#[test]
fn identifiers_parse_like_try_parse() {
assert_eq!(parse_identifier("a/b").as_deref(), Some("minecraft:a/b"));
assert_eq!(parse_identifier("my:house").as_deref(), Some("my:house"));
assert_eq!(parse_identifier("Bad"), None);
assert_eq!(parse_identifier("ns:"), None);
}
}
+107 -9
View File
@@ -74,6 +74,68 @@ struct TerrainOut {
@location(4) cylindrical: f32,
// The light the face emits, 0 to 15 (mesher.rs's `EMISSION_BITS`).
@location(5) @interpolate(flat) emission: u32,
@location(6) @interpolate(flat) fade: u32,
// A distant tile's tile-relative position, for its fade (`faded`).
@location(7) local: vec3<f32>,
// terrain.vsh's chunkVisibility: the section's fade-in, from 16 blocks out.
@location(8) visibility: f32,
}
// Whether a fading tile or section leaves this pixel out (gfx/lod.rs's
// `fade_code`, `fade_out_code`). It fades in, or out, chunk by chunk: each
// column of its own grid (`local`, the tile-relative position: 16 blocks,
// 2^level for coarse tiles) starts in turn over the first half of the fade
// and fades over the second, as a 4x4 ordered pattern in the face's plane,
// its unit the power of two of a block nearest 1.5 pixels, so it stays on
// the terrain as the camera moves. `dx` and `dy`: `local`'s screen
// derivatives.
fn faded(code: u32, local: vec3<f32>, dx: vec3<f32>, dy: vec3<f32>) -> bool {
if code == 0u { return false; }
// Sixty-fourths of the fade: 1 to 63 fading in, 65 to 128 fading out.
let step = (code - 1u) % 129u;
let out = step >= 64u;
let progress = f32(select(step, step - 64u, out)) / 64.0;
let size = exp2(f32(max((code - 1u) / 129u, 4u)));
let cell = bitcast<vec2<u32>>(vec2<i32>(floor(local.xz / size)));
var h = cell.x * 73856093u ^ cell.y * 19349663u;
h = (h ^ (h >> 13u)) * 0x5bd1e995u;
let start = f32((h ^ (h >> 15u)) & 1023u) / 2048.0;
let ramp = clamp((progress - start) * 2.0, 0.0, 1.0);
let shown = select(ramp, 1.0 - ramp, out);
let n = abs(cross(dx, dy));
var plane = local.xy;
if n.x >= n.y && n.x >= n.z {
plane = local.yz;
} else if n.y >= n.z {
plane = local.xz;
}
let pixel = max(length(dx), length(dy));
let unit = exp2(ceil(log2(max(pixel * 1.5, 1.0 / 16.0))));
let p = vec2<u32>(bitcast<vec2<u32>>(vec2<i32>(floor(plane / unit)))) & vec2(3u);
let bayer = array<u32, 16>(0u, 8u, 2u, 10u, 12u, 4u, 14u, 6u, 3u, 11u, 1u, 9u, 15u, 7u, 13u, 5u);
return (f32(bayer[p.y * 4u + p.x]) + 0.5) / 16.0 >= shown;
}
// A fading tile or section is drawn a hair toward the camera (depth is
// reversed: nearer is larger), the newer the more, so it wins where it draws
// over the solid tiles it replaces. Distant tiles go back by 0.2% of their
// distance, so near chunks win over tiles still under them (gfx/lod.rs's
// `previous`, columns not complete yet): near water lies a little below a
// tile's (vanilla's fluid corner heights), and under a smaller step the
// tile's drew over it, two waters, opaque. A tile's UV step is 1/32, a
// section's 1/32768 (`terrain_uv`).
fn fade_depth(clip: vec4<f32>, w: f32) -> vec4<f32> {
let code = fade_code(w);
let tile = instance_step(w) > 0.001;
var scale = select(1.0, 1.0 - 2e-3, tile);
if code != 0u && (code - 1u) % 129u < 64u {
scale *= 1.0 + 5e-6 * f32(64u - (code - 1u) % 129u);
} else if code != 0u && !tile {
// A section leaving (gfx/lod.rs's `HELD`, `fade_out_code`) goes 2% of
// its distance toward the camera: far terrain's coarser blocks stand a
// little out of the real ones, and the tile coming in under it hid it
// at once, before its fade.
scale *= 1.02;
}
return vec4(clip.xy, clip.z * scale, clip.w);
}
// Horizon Extras' dynamic lights (dynamic_light.rs): the block light, in the vertex's units
// (16 per level), a vertex at camera-relative `pos` gets from the frame's light sources, one
@@ -95,15 +157,36 @@ fn sample_lightmap(sky: f32, block: f32) -> vec4<f32> {
// mesher.rs's TerrainVertex holds the UV in fixed point; the instance's
// origin.w is the step: 1/32768 per unit for sections (a power of two on an
// integer below 2^24, so it decodes exactly), 1/32 per block for distant
// terrain tiles (TerrainVertex::tiled).
fn terrain_uv(packed: vec2<u32>, step: f32) -> vec2<f32> {
return vec2<f32>(packed) * step;
// terrain tiles (TerrainVertex::tiled). A fading tile negates it past its
// dither code (gfx/lod.rs's `instance`): the step is the fraction either way.
fn terrain_uv(packed: vec2<u32>, w: f32) -> vec2<f32> {
return vec2<f32>(packed) * instance_step(w);
}
// A whole section's step (a power of two) carries its fade-in in the mantissa's 23 bits, as
// `1 - visibility` (gfx.rs's `section_instance`): masked off, the step; the rest is
// `RenderSection.getVisibility`. A fading one (negative) is fully visible.
fn instance_step(w: f32) -> f32 {
if w > 0.0 {
return bitcast<f32>(bitcast<u32>(w) & 0xff800000u);
}
return fract(abs(w));
}
fn instance_visibility(w: f32) -> f32 {
if w > 0.0 {
return 1.0 - f32(bitcast<u32>(w) & 0x7fffffu) / 8388608.0;
}
return 1.0;
}
// A fading distant tile's dither code (gfx/lod.rs's `fade_code`), 0 when whole.
fn fade_code(w: f32) -> u32 {
return u32(floor(max(-w, 0.0)));
}
@vertex fn vs_terrain(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> TerrainOut {
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz);
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
// Flat section draws (gfx/flat.rs): one draw per arena page over the frame's
// index list. A section's vertices fill whole 256-vertex blocks of its page:
@@ -116,13 +199,15 @@ fn terrain_uv(packed: vec2<u32>, step: f32) -> vec2<f32> {
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> TerrainOut {
let origin = slot_origins[block_slots[vertex >> 8u]];
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz);
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> TerrainOut {
light: vec2<u32>, origin: vec3<f32>, w: f32) -> TerrainOut {
let pos = position + origin;
var out: TerrainOut;
out.clip = globals.view_proj * vec4(pos, 1.0);
out.clip = fade_depth(globals.view_proj * vec4(pos, 1.0), w);
out.local = position;
out.uv = uv;
out.layer = layer;
let block = max(f32(light.y), dynamic_block_light(pos));
@@ -130,6 +215,10 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
out.emission = 15u - (u32(round(color.a * 255.0)) & 15u);
out.spherical = length(pos);
out.cylindrical = max(length(pos.xz), abs(pos.y));
out.fade = fade_code(w);
const CHUNK_FULLY_VISIBLE_RANGE: f32 = 16.0;
out.visibility = mix(1.0, instance_visibility(w),
clamp((length(pos) - CHUNK_FULLY_VISIBLE_RANGE) / CHUNK_FULLY_VISIBLE_RANGE, 0.0, 1.0));
return out;
}
fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
@@ -143,8 +232,13 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
return vec4(mix(color.rgb, globals.fog_color.rgb, f * globals.fog_color.a), color.a);
}
@fragment fn fs_solid(in: TerrainOut) -> @location(0) vec4<f32> {
let dx = dpdx(in.local);
let dy = dpdy(in.local);
if faded(in.fade, in.local, dx, dy) { discard; }
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
var color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
// terrain.fsh: the fade-in mixes toward the fog colour.
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
// Below 0.75, alpha tells HDR output (hdr.wgsl) a light source's bright
// pixels may shine, and at which tier (mesher::hdr_tier): 0.525 at the
@@ -152,8 +246,12 @@ fn apply_fog(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
let emissive = select(1.0, 0.7 * f32(4u - min(in.emission, 4u)) / 4.0, in.emission > 0u);
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
}
// Water, glass and other see-through faces draw whole as they fade in, the
// layer drawn first in front (gfx.rs's water tiers): dithered, the faded-out
// pixels showed the layer behind through the one in front, twice the water.
@fragment fn fs_translucent(in: TerrainOut) -> @location(0) vec4<f32> {
let color = sprite(in.uv, in.layer & 0x7fffu) * in.color;
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 < 0.01 { discard; }
return apply_fog(color, in.spherical, in.cylindrical);
}
@@ -0,0 +1,202 @@
//! `TestBlockEntity`: the test block's mode, message and powered flag, with
//! its save format (`saveAdditional` / `loadAdditional`).
use horizon_client_core::nbt_value::{number, string, text};
use horizon_world::nbt::Tag;
/// `TestBlockMode`, in id order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TestBlockMode {
Start,
Log,
Fail,
Accept,
}
impl TestBlockMode {
pub const ALL: [Self; 4] = [Self::Start, Self::Log, Self::Fail, Self::Accept];
/// `getSerializedName`: the block state's `mode` value.
pub fn serialized_name(self) -> &'static str {
match self {
Self::Start => "start",
Self::Log => "log",
Self::Fail => "fail",
Self::Accept => "accept",
}
}
pub fn of_serialized_name(name: &str) -> Option<Self> {
Self::ALL.into_iter().find(|m| m.serialized_name() == name)
}
pub fn id(self) -> i32 {
self as i32
}
/// `ByIdMap.continuous(.., OutOfBoundsStrategy.ZERO)`.
pub fn by_id(id: i32) -> Self {
usize::try_from(id)
.ok()
.and_then(|i| Self::ALL.get(i).copied())
.unwrap_or(Self::Start)
}
/// `getDisplayName`'s lang key.
pub fn label_key(self) -> String {
format!("test_block.mode.{}", self.serialized_name())
}
/// `getDetailedMessage`'s lang key.
pub fn info_key(self) -> String {
format!("test_block.mode_info.{}", self.serialized_name())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TestBlock {
pub mode: TestBlockMode,
pub message: String,
pub powered: bool,
/// `triggered`: not saved; the game test reads and clears it.
pub triggered: bool,
}
/// What a `TestBlockEntity` call asks of the level.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Effects {
/// `level.updateNeighborsAt(pos, block)`.
pub update_neighbors: bool,
/// `log()`.
pub log: bool,
}
impl TestBlock {
/// `new TestBlockEntity(pos, state)`: the mode is the state's.
pub fn new(mode: TestBlockMode) -> Self {
Self {
mode,
message: String::new(),
powered: false,
triggered: false,
}
}
/// `reset`.
pub fn reset(&mut self) -> Effects {
self.triggered = false;
let start = self.mode == TestBlockMode::Start;
if start {
self.powered = false;
}
Effects {
update_neighbors: start,
log: false,
}
}
/// `trigger`: a start block powers up and logs; the others log (LOG
/// only) and remember they were triggered.
pub fn trigger(&mut self) -> Effects {
if self.mode == TestBlockMode::Start {
self.powered = true;
return Effects {
update_neighbors: true,
log: true,
};
}
self.triggered = true;
Effects {
update_neighbors: false,
log: self.mode == TestBlockMode::Log,
}
}
/// `TestBlock.neighborChanged`: `signal` is `hasNeighborSignal`; a start
/// block ignores it, the others trigger on a rising signal.
pub fn neighbor_changed(&mut self, signal: bool) -> Option<Effects> {
if self.mode == TestBlockMode::Start {
return None;
}
if signal && !self.powered {
self.powered = true;
Some(self.trigger())
} else {
if !signal && self.powered {
self.powered = false;
}
None
}
}
/// `TestBlock.ownSignal`: a powered start block gives 15.
pub fn own_signal(&self) -> i32 {
if self.mode == TestBlockMode::Start && self.powered {
15
} else {
0
}
}
/// `saveAdditional` (also `getUpdateTag`, which is `saveCustomOnly`).
pub fn save(&self) -> Vec<(&'static str, Tag)> {
vec![
("mode", string(self.mode.serialized_name())),
("message", string(&self.message)),
("powered", Tag::Byte(i8::from(self.powered))),
]
}
/// `loadAdditional`: a missing or unknown mode is FAIL.
pub fn load(tag: &Tag) -> Self {
Self {
mode: text(tag.get("mode"))
.and_then(|m| TestBlockMode::of_serialized_name(&m))
.unwrap_or(TestBlockMode::Fail),
message: text(tag.get("message")).unwrap_or_default(),
powered: number(tag.get("powered")).is_some_and(|v| v != 0),
triggered: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn saves_and_loads_like_test_block_entity() {
let block = TestBlock {
mode: TestBlockMode::Accept,
message: "all done".into(),
powered: true,
triggered: false,
};
let tag = Tag::compound(block.save());
assert_eq!(text(tag.get("mode")).as_deref(), Some("accept"));
assert_eq!(TestBlock::load(&tag), block);
}
#[test]
fn start_blocks_power_up_and_the_others_trigger_on_a_rising_signal() {
let mut start = TestBlock::new(TestBlockMode::Start);
assert_eq!(start.neighbor_changed(true), None);
assert_eq!(start.own_signal(), 0);
let effects = start.trigger();
assert!(effects.update_neighbors && effects.log && start.own_signal() == 15);
assert!(start.reset().update_neighbors && start.own_signal() == 0);
let mut log = TestBlock::new(TestBlockMode::Log);
let effects = log.neighbor_changed(true).unwrap();
assert!(effects.log && !effects.update_neighbors && log.triggered && log.powered);
// Still powered: no second trigger; a fall clears the flag.
assert_eq!(log.neighbor_changed(true), None);
assert_eq!(log.neighbor_changed(false), None);
assert!(!log.powered && log.triggered);
assert!(!log.reset().update_neighbors && !log.triggered);
let mut accept = TestBlock::new(TestBlockMode::Accept);
assert!(!accept.neighbor_changed(true).unwrap().log);
}
#[test]
fn load_defaults_to_fail_and_ids_wrap_to_start() {
let block = TestBlock::load(&Tag::compound(Vec::<(&str, Tag)>::new()));
assert_eq!(block, TestBlock::new(TestBlockMode::Fail));
assert_eq!(TestBlockMode::by_id(3), TestBlockMode::Accept);
assert_eq!(TestBlockMode::by_id(9), TestBlockMode::Start);
assert_eq!(TestBlockMode::Log.id(), 1);
}
}
@@ -0,0 +1,265 @@
//! `TestInstanceBlockEntity`: the test instance block's test, size, rotation
//! and status, its error markers, and the boxes derived from them.
use horizon_client_core::nbt_value::{number, string, text};
use horizon_world::{
nbt::Tag,
worldgen::structure::{BoundingBox, Rotation},
};
/// `STRUCTURE_OFFSET`: where the structure starts from the block.
pub const STRUCTURE_OFFSET: [i32; 3] = [0, 1, 1];
/// `TestInstanceBlockEntity.Status`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Status {
Cleared,
Running,
Finished,
}
impl Status {
pub fn serialized_name(self) -> &'static str {
match self {
Self::Cleared => "cleared",
Self::Running => "running",
Self::Finished => "finished",
}
}
pub fn of_serialized_name(name: &str) -> Option<Self> {
[Self::Cleared, Self::Running, Self::Finished]
.into_iter()
.find(|s| s.serialized_name() == name)
}
pub fn index(self) -> i32 {
self as i32
}
/// `Status.byIndex`: out of range is CLEARED.
pub fn by_index(index: i32) -> Self {
match index {
1 => Self::Running,
2 => Self::Finished,
_ => Self::Cleared,
}
}
}
/// `TestInstanceBlockEntity.Data`. Components are kept as the NBT they are
/// sent and saved as.
#[derive(Clone, Debug, PartialEq)]
pub struct Data {
/// The test's registry id.
pub test: Option<String>,
pub size: [i32; 3],
pub rotation: Rotation,
pub ignore_entities: bool,
pub status: Status,
pub error_message: Option<Tag>,
}
impl Default for Data {
fn default() -> Self {
Self {
test: None,
size: [0; 3],
rotation: Rotation::None,
ignore_entities: false,
status: Status::Cleared,
error_message: None,
}
}
}
impl Data {
/// `Data.CODEC` encoded.
pub fn save(&self) -> Tag {
let mut entries = Vec::new();
if let Some(test) = &self.test {
entries.push(("test", string(test)));
}
entries.push(("size", Tag::IntArray(self.size.to_vec())));
entries.push(("rotation", string(self.rotation.serialized_name())));
entries.push(("ignore_entities", Tag::Byte(i8::from(self.ignore_entities))));
entries.push(("status", string(self.status.serialized_name())));
if let Some(error) = &self.error_message {
entries.push(("error_message", error.clone()));
}
Tag::compound(entries)
}
/// `Data.CODEC` decoded: size, rotation, ignore_entities and status are
/// required, or the whole value is dropped.
pub fn load(tag: &Tag) -> Option<Self> {
let size = match tag.get("size")? {
Tag::IntArray(v) if v.len() == 3 => [v[0], v[1], v[2]],
_ => return None,
};
Some(Self {
test: text(tag.get("test")),
size,
rotation: Rotation::from_name(&text(tag.get("rotation"))?)?,
ignore_entities: number(tag.get("ignore_entities")).map(|v| v != 0)?,
status: Status::of_serialized_name(&text(tag.get("status"))?)?,
error_message: tag.get("error_message").cloned(),
})
}
}
/// `TestInstanceBlockEntity.ErrorMarker`.
#[derive(Clone, Debug, PartialEq)]
pub struct ErrorMarker {
pub pos: [i32; 3],
pub text: Tag,
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct TestInstanceBlock {
pub data: Data,
pub error_markers: Vec<ErrorMarker>,
}
impl TestInstanceBlock {
/// `saveAdditional` (also `getUpdateTag`, which is `saveCustomOnly`).
pub fn save(&self) -> Vec<(&'static str, Tag)> {
let mut entries = vec![("data", self.data.save())];
if !self.error_markers.is_empty() {
let markers = self
.error_markers
.iter()
.map(|m| {
Tag::compound([
("pos", Tag::IntArray(m.pos.to_vec())),
("text", m.text.clone()),
])
})
.collect();
entries.push(("errors", Tag::List(markers)));
}
entries
}
/// `loadAdditional`: the data is replaced only when it decodes, and the
/// markers are always replaced.
pub fn load(&mut self, tag: &Tag) {
if let Some(data) = tag.get("data").and_then(Data::load) {
self.data = data;
}
self.error_markers.clear();
if let Some(Tag::List(markers)) = tag.get("errors") {
for m in markers {
if let (Some(Tag::IntArray(p)), Some(text)) = (m.get("pos"), m.get("text"))
&& p.len() == 3
{
self.error_markers.push(ErrorMarker {
pos: [p[0], p[1], p[2]],
text: text.clone(),
});
}
}
}
}
/// `getStructurePos(blockPos)`.
pub fn structure_pos_of(block: [i32; 3]) -> [i32; 3] {
[0, 1, 2].map(|i| block[i] + STRUCTURE_OFFSET[i])
}
/// `getTransformedSize`: the size with the axes swapped for a quarter turn.
pub fn transformed_size(size: [i32; 3], rotation: Rotation) -> [i32; 3] {
if matches!(
rotation,
Rotation::Clockwise90 | Rotation::Counterclockwise90
) {
[size[2], size[1], size[0]]
} else {
size
}
}
/// `getRenderableBox`: the offset from the block and the size to draw,
/// given the test's `padding` and rotation.
pub fn renderable_box(&self, padding: i32, rotation: Rotation) -> ([i32; 3], [i32; 3]) {
let offset = STRUCTURE_OFFSET.map(|o| o + padding);
(offset, Self::transformed_size(self.data.size, rotation))
}
/// `getStructureBoundingBox`.
pub fn structure_box(&self, block: [i32; 3], padding: i32, rotation: Rotation) -> BoundingBox {
let corner = Self::structure_pos_of([0, 1, 2].map(|i| block[i] + padding));
let size = Self::transformed_size(self.data.size, rotation);
BoundingBox::from_corners(corner, [0, 1, 2].map(|i| corner[i] + size[i] - 1))
}
/// `getStartCorner`: the corner the template is placed from.
pub fn start_corner(&self, block: [i32; 3], padding: i32, rotation: Rotation) -> [i32; 3] {
let [x, _, z] = self.data.size;
let c = Self::structure_pos_of([0, 1, 2].map(|i| block[i] + padding));
match rotation {
Rotation::None => c,
Rotation::Clockwise90 => [c[0] + z - 1, c[1], c[2]],
Rotation::Clockwise180 => [c[0] + x - 1, c[1], c[2] + z - 1],
Rotation::Counterclockwise90 => [c[0], c[1], c[2] + x - 1],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> TestInstanceBlock {
TestInstanceBlock {
data: Data {
test: Some("minecraft:always_pass".into()),
size: [3, 4, 5],
rotation: Rotation::Clockwise180,
ignore_entities: true,
status: Status::Finished,
error_message: Some(string("boom")),
},
error_markers: vec![ErrorMarker {
pos: [1, 2, 3],
text: string("here"),
}],
}
}
#[test]
fn saves_and_loads_like_test_instance_block_entity() {
let block = sample();
let tag = Tag::compound(block.save());
assert_eq!(
text(tag.get("data").unwrap().get("rotation")).as_deref(),
Some("180")
);
let mut loaded = TestInstanceBlock::default();
loaded.load(&tag);
assert_eq!(loaded, block);
// No markers, no `errors` key; a bad `data` leaves the old one.
let mut plain = block.clone();
plain.error_markers.clear();
assert!(Tag::compound(plain.save()).get("errors").is_none());
let mut kept = block;
kept.load(&Tag::compound([(
"data",
Tag::compound(Vec::<(&str, Tag)>::new()),
)]));
assert_eq!(kept.data.test.as_deref(), Some("minecraft:always_pass"));
assert!(kept.error_markers.is_empty());
}
#[test]
fn boxes_follow_padding_and_rotation() {
let block = sample();
// A quarter turn swaps the horizontal sizes.
let (offset, size) = block.renderable_box(2, Rotation::Clockwise90);
assert_eq!((offset, size), ([2, 3, 3], [5, 4, 3]));
let bounds = block.structure_box([10, 64, 10], 2, Rotation::None);
assert_eq!(bounds.min, [12, 67, 13]);
assert_eq!(bounds.max, [14, 70, 17]);
assert_eq!(
block.start_corner([10, 64, 10], 2, Rotation::Counterclockwise90),
[12, 67, 15]
);
assert_eq!(Status::by_index(7), Status::Cleared);
}
}
+1 -1
View File
@@ -928,7 +928,7 @@ pub fn multiline_word_position(value: &str, dir: isize, from: usize) -> usize {
}
/// `Character.isWhitespace`: Unicode spaces and line breaks but not the no-break spaces, plus
/// the ASCII separators U+001C to U+001F.
fn java_whitespace(c: char) -> bool {
pub(crate) fn java_whitespace(c: char) -> bool {
(c.is_whitespace() && !matches!(c, '\u{85}' | '\u{a0}' | '\u{2007}' | '\u{202f}'))
|| ('\u{1c}'..='\u{1f}').contains(&c)
}
@@ -93,6 +93,10 @@ pub struct World {
/// Every block position written since the log was last drained, kept
/// only while the world is open to the network (see `lan`).
pub block_log: Option<Vec<(i32, i32, i32)>>,
/// Every write since the mobs last ticked (position, old and new state),
/// for `ServerLevel.sendBlockUpdated`'s path recomputing; kept only while
/// the mobs of this world tick (`game/creatures.rs`).
pub path_writes: Option<Vec<((i32, i32, i32), u32, u32)>>,
/// The sections whose light changed since the log was last drained, as
/// a bit per section of each column, kept while `block_log` is
/// (`ChunkHolder.sectionLightChanged`).
@@ -195,6 +199,7 @@ impl World {
day_time: 1000,
weather: Weather::default(),
block_log: None,
path_writes: None,
light_log: None,
moving_piston_fallback: Vec::new(),
moving_pistons: Vec::new(),
@@ -293,6 +298,9 @@ impl World {
if let Some(log) = &mut self.block_log {
log.push((x, y, z));
}
if let Some(writes) = &mut self.path_writes {
writes.push(((x, y, z), previous, state.0));
}
self.modified.insert((x >> 4, z >> 4));
// The mesher reads a 3x3x3 neighbourhood for AO, so a write marks
// the sections it can reach: this section, the one above/below
+4 -1
View File
@@ -22,6 +22,8 @@ horizon-world = { path = "../horizon-world" }
horizon-protocol = { path = "../horizon-protocol" }
# server.properties, eula.txt and the user lists, shared with horizon-server.
horizon-server-files = { path = "../horizon-server-files" }
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
horizon-anticheat = { path = "../horizon-anticheat" }
# Shader packs: reading them and turning their GLSL into naga modules.
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
@@ -65,7 +67,8 @@ unicode-bidi = "0.3.18"
[target.'cfg(unix)'.dependencies]
# The server thread's CPU time per tick (`HORIZON_SPIKES`, spans.rs).
rustix = { version = "1", default-features = false, features = ["std", "time", "process"] }
# `LevelStorageAccess.estimateDiskSpace` (statvfs) too.
rustix = { version = "1", default-features = false, features = ["std", "time", "process", "fs"] }
[target.'cfg(windows)'.dependencies]
# Use the registered URI handler without routing links through Explorer/cmd.
@@ -12,6 +12,7 @@ use horizon_client::{
assets::Assets,
blocks::BlockFacts,
crafting::{Recipe, Recipes},
enchant::Enchantments,
items::{ItemStack, Items},
mod_content::{self, ContentMod},
stations::StationRecipes,
@@ -81,6 +82,7 @@ fn main() -> Result<()> {
let items = Items::load(&root, registry, &facts, &PackOverlay::NONE).map_err(step("items"))?;
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).map_err(step("recipes"))?;
let stations = StationRecipes::load(&root, &items, &PackOverlay::NONE);
let enchantments = Enchantments::load(&root);
let collisions =
horizon_client::collisions::load(&root, registry).map_err(step("collisions"))?;
let loaded = began.elapsed().as_secs_f64() - built;
@@ -198,7 +200,35 @@ fn main() -> Result<()> {
item_rows.insert(
name.clone(),
match items.items.get(name.as_str()) {
Some(i) => json!({"max_stack_size": i.max_stack, "block": i.block.map(|b| b.0)}),
Some(i) => {
let props = enchantments.props(name);
let repairable: Vec<String> = props
.into_iter()
.flat_map(|p| &p.repairable)
.flat_map(|r| match r.strip_prefix('#') {
Some(tag) => items.item_tag_order.get(tag).cloned().unwrap_or_default(),
None => vec![r.clone()],
})
.collect();
json!({
"max_stack_size": i.max_stack,
"block": i.block.map(|b| b.0),
"max_damage": i.max_damage,
"modifiers": i.modifiers.iter().map(|(attribute, id, amount, operation, slot)| {
json!({"type": attribute, "id": id, "amount": amount, "operation": operation, "slot": slot})
}).collect::<Vec<_>>(),
"tool": i.tool_damage_per_block.map(|per_block| json!({
"rules": i.tool.iter().map(|r| json!({"blocks": r.blocks, "speed": r.speed, "correct_for_drops": r.correct})).collect::<Vec<_>>(),
"default_mining_speed": i.tool_default_speed,
"damage_per_block": per_block,
})),
"enchantable": props.map_or(0, |p| p.enchantable),
"repairable": repairable,
"equip_slot": i.equip_slot,
"equip_asset": i.equip_asset,
"food": i.food.map(|(nutrition, saturation)| json!({"nutrition": nutrition, "saturation": saturation})),
})
}
None => Value::Null,
},
);
@@ -76,6 +76,7 @@ mod recipe_book_fixture;
mod recipe_book_render_fixture;
mod recipe_book_view_fixture;
mod recipe_fixture;
mod remote_attack_probe;
mod scene_collision_probe;
mod scene_motion_probe;
mod scoreboard_fixture;
@@ -171,6 +172,7 @@ probes! {
recipe_book_render_fixture,
recipe_book_view_fixture,
recipe_fixture,
remote_attack_probe,
scene_collision_probe,
scene_motion_probe,
scoreboard_fixture,
@@ -25,7 +25,7 @@ use horizon_client::{
};
use horizon_core::{
geometry::{Aabb, Vec3},
random::LegacyRandom,
random::{LegacyRandom, RandomSource},
};
use horizon_world::loot::PackOverlay;
use horizon_world::{chunk::BlockState, registry::BlockRegistry, registry::BlockStateId};
@@ -59,6 +59,8 @@ struct Scenario {
creative: bool,
/// The tick from which the player faces another yaw.
turn: Option<(i32, f32)>,
/// The player rides a boat that moves this far each tick (x, z).
boat: Option<[f64; 2]>,
/// Creaking hearts placed when the scenario starts.
hearts: Vec<Vec<String>>,
/// `ServerLevel.isSpawningMonsters`.
@@ -132,6 +134,9 @@ fn parse(text: &str) -> Vec<Scenario> {
s.turn = Some((tick.parse().unwrap(), yaw.parse().unwrap()));
} else if let Some(item) = opt.strip_prefix("hand=") {
s.hand = Some(item.to_owned());
} else if let Some(v) = opt.strip_prefix("boat=") {
let (x, z) = v.split_once(',').unwrap();
s.boat = Some([x.parse().unwrap(), z.parse().unwrap()]);
} else {
panic!("unknown player option {opt}");
}
@@ -1042,6 +1047,15 @@ pub fn main() -> horizon_client::Result<()> {
}
}
m.random = LegacyRandom::new(spec.seed);
if matches!(
m.species(),
Species::Cod | Species::Salmon | Species::TropicalFish
) {
// The oracle draws FollowFlockLeaderGoal's first delay again once seeded.
let delay =
horizon_client::mobs::aquatic::FollowFlockLeader::next_start_tick(&mut m);
m.ai.goals.set_start_delay("FollowFlockLeaderGoal", delay);
}
if m.species() == Species::Bee {
// The oracle draws the constructor's cooldowns again once seeded.
horizon_client::mobs::bee::draw_cooldowns(&mut m);
@@ -1115,10 +1129,11 @@ pub fn main() -> horizon_client::Result<()> {
sleeping: false,
sleep_timer: 0,
passenger: false,
vehicle: None,
}
});
let mut scratch = LegacyRandom::new(0);
// The level's random for eating sounds: only goats and sniffers draw
// The level random for eating sounds: only goats and sniffers draw
// from it, and the scenarios feed neither.
let mut level_random = LegacyRandom::new(0);
for (i, m) in mobs.iter().enumerate() {
@@ -1144,6 +1159,7 @@ pub fn main() -> horizon_client::Result<()> {
player: player.clone(),
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
others: $snapshot,
leash_boats: &[],
others_grid: $grid,
others_index: None,
daylight: (world.day_time.rem_euclid(24000)) < 12000,
@@ -1182,6 +1198,30 @@ pub fn main() -> horizon_client::Result<()> {
BlockEntity::CreakingHeart(Box::new(h.clone())),
);
}
if let (Some(p), Some([vx, vz]), Some(start)) = (player.as_mut(), s.boat, s.player) {
// The boat the player rides, moved by the script (the oracle's does not tick).
let at = [
start[0] + vx * f64::from(t),
start[1],
start[2] + vz * f64::from(t),
];
p.position = Vec3::new(at[0], at[1], at[2]);
p.bounds = Aabb::new(
Vec3::new(at[0] - 0.3, at[1], at[2] - 0.3),
Vec3::new(at[0] + 0.3, at[1] + 1.8, at[2] + 0.3),
);
p.eye = [at[0], at[1] + f64::from(1.62_f32), at[2]];
p.passenger = true;
p.riding = true;
p.vehicle = Some(horizon_client::mobs::Vehicle {
nautilus: false,
bounds: Aabb::new(
Vec3::new(at[0] - 0.6875, at[1], at[2] - 0.6875),
Vec3::new(at[0] + 0.6875, at[1] + 0.5625, at[2] + 0.6875),
),
velocity: Vec3::new(vx, 0.0, vz),
});
}
if let (Some(p), Some((tick, yaw))) = (player.as_mut(), s.turn)
&& t >= tick
{
@@ -1478,6 +1518,24 @@ pub fn main() -> horizon_client::Result<()> {
})
.collect();
for (at, open) in doors {
// DoorBlock.setOpen: the sound (a wooden door's), its pitch from the level's
// random, when the door was not already so.
let was = registry
.state(world.block(at[0], at[1], at[2]))
.is_some_and(|s| s.properties.get("open").map(String::as_str) == Some("true"));
if was != open {
let pitch = scratch.next_float() * 0.1 + 0.9;
events.push(MobEvent::Sound(
if open {
"block.wooden_door.open"
} else {
"block.wooden_door.close"
},
[0.0; 3],
1.0,
pitch,
));
}
let Some(half) = registry
.state(world.block(at[0], at[1], at[2]))
.and_then(|s| s.properties.get("half").cloned())
@@ -0,0 +1,52 @@
//! The attacking client's side of a hit, as `Game::remote_player_attack` decides it, against
//! VanillaRemoteAttackOracle's real `Player.attack` (tools/test_remote_attack_parity.py).
#![forbid(unsafe_code)]
use horizon_client::{
Result,
attack_strength::{ATTACKER_SLOWDOWN, AttackStrength, attacker_slows_after_hit},
};
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn double(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
pub fn main() -> Result<()> {
let args: Vec<_> = crate::args().skip(1).collect();
let mut out = BufWriter::new(File::create(&args[1])?);
for line in BufReader::new(File::open(&args[0])?).lines() {
let c: Value = serde_json::from_str(&line?)?;
let mut attack = AttackStrength::default();
attack.ticker = c["ticker"].as_i64().unwrap() as i32;
let strength = attack.scale(0.5, double(&c["speed"]));
let damage = match c["spin"].as_f64() {
Some(spin) => spin as f32,
None => double(&c["damage"]) as f32,
};
let mut sprinting = c["sprint"].as_bool().unwrap();
let velocity: Vec<f64> = c["vel"].as_array().unwrap().iter().map(double).collect();
let (mut x, y, mut z) = (velocity[0], velocity[1], velocity[2]);
// `level_players` keeps a mob out of remote_player_attack: Entity.hurtClient is false.
if c["target"] == "player"
&& attacker_slows_after_hit(damage, double(&c["knockback"]) as f32, strength, sprinting)
{
x *= ATTACKER_SLOWDOWN;
z *= ATTACKER_SLOWDOWN;
sprinting = false;
}
writeln!(
out,
"{:08x} {:016x} {:016x} {:016x} {}",
strength.to_bits(),
x.to_bits(),
y.to_bits(),
z.to_bits(),
sprinting
)?;
}
Ok(())
}
+25
View File
@@ -479,6 +479,8 @@ pub struct EntityView {
/// mainhand, offhand, head, chest, legs, feet, body.
pub equipment: [Option<ItemView>; 7],
pub vehicle: Option<Box<EntityView>>,
/// `Entity.getPassengers`, for `PassengerPredicate`.
pub passengers: Vec<EntityView>,
/// Player: what it looks at within 100 blocks (`PlayerPredicate.lookingAt`).
pub looking_at: Option<Box<EntityView>>,
pub effects: Vec<(String, i32, i32)>,
@@ -763,6 +765,11 @@ pub fn entity(v: &Value, e: &EntityView, w: &dyn World, origin: [f64; 3]) -> boo
.vehicle
.as_ref()
.is_some_and(|veh| entity(p, veh, w, origin)),
// PassengerPredicate.matches: any passenger matches the nested predicate.
"minecraft:passenger" | "passenger" => e
.passengers
.iter()
.any(|passenger| entity(p, passenger, w, origin)),
"minecraft:effects" | "effects" => p.as_object().is_some_and(|m| {
m.iter().all(|(id, want)| {
e.effects.iter().any(|(eid, amp, dur)| {
@@ -1401,6 +1408,24 @@ mod tests {
&& matches!(id, "minecraft:fireball" | "minecraft:arrow"))
}
}
/// `PassengerPredicate.matches`: ride_a_boat_with_a_goat's vehicle holds a goat.
#[test]
fn passenger_predicate_needs_a_matching_passenger() {
let want = serde_json::json!({
"minecraft:entity_type": "minecraft:oak_boat",
"minecraft:passenger": {"minecraft:entity_type": "minecraft:goat"}
});
let entity = |kind: &str| EntityView {
kind: kind.into(),
..Default::default()
};
let mut boat = entity("minecraft:oak_boat");
assert!(!super::entity(&want, &boat, &Flat, [0.0; 3]));
boat.passengers = vec![entity("minecraft:player")];
assert!(!super::entity(&want, &boat, &Flat, [0.0; 3]));
boat.passengers.push(entity("minecraft:goat"));
assert!(super::entity(&want, &boat, &Flat, [0.0; 3]));
}
/// Return to Sender's `killing_blow`: only the ghast's own fireball, as a
/// projectile, kills it for the advancement; a sword or an arrow does not.
#[test]
@@ -68,6 +68,42 @@ impl AttackStrength {
}
}
/// `Player.attack`'s extra knockback: `getKnockback` (`weapon`, halved: the
/// ATTACK_KNOCKBACK value, plus on a server the weapon's knockback enchantment)
/// and a sprinting full-strength hit (0.5), and whether the hit is that
/// sprinting knockback attack.
pub fn extra_knockback(weapon: f32, strength: f32, sprinting: bool) -> (f32, bool) {
let knockback_attack = strength > 0.9 && sprinting;
let bonus = if knockback_attack { 0.5 } else { 0.0 };
(weapon / 2.0 + bonus, knockback_attack)
}
/// `causeExtraKnockback`'s cut of the attacker's horizontal speed.
pub const ATTACKER_SLOWDOWN: f64 = 0.6;
/// What the attacking client's own `Player.attack` does to itself on a hit
/// at another player, whose `RemotePlayer.hurtClient` lets the hit through:
/// whether `causeExtraKnockback` cuts its horizontal speed and stops its
/// sprint. This runs on the client's player, not the server's: held items and
/// effects only change attributes on a server and neither of these two is
/// synced, so `attack_damage` and `knockback` are the client's own ATTACK_DAMAGE
/// (or a riptide spin's damage) and ATTACK_KNOCKBACK, whatever the weapon or
/// Weakness. Enchantments don't count either: only `ServerPlayer` overrides
/// `getEnchantedDamage`, and `getKnockback` asks for a `ServerLevel`.
/// `strength` is `getAttackStrengthScale(0.5)`.
pub fn attacker_slows_after_hit(
attack_damage: f32,
knockback: f32,
strength: f32,
sprinting: bool,
) -> bool {
let base = attack_damage * (0.2 + strength * strength * 0.8);
let (knockback, _) = extra_knockback(knockback, strength, sprinting);
// Player.attack stops before the hurt and the knockback when no damage is
// left (`magicBoost` is 0 here).
base > 0.0 && knockback > 0.0
}
#[cfg(test)]
mod tests {
use super::*;
@@ -107,4 +143,55 @@ mod tests {
s.reset_attack();
assert_eq!((s.ticker, s.swap_ticker), (0, 1));
}
/// A client's own ATTACK_DAMAGE: Player's base value, as no item or effect
/// changes it there.
const CLIENT_DAMAGE: f32 = 1.0;
#[test]
fn a_charged_sprint_hit_slows_the_attacker() {
assert!(attacker_slows_after_hit(CLIENT_DAMAGE, 0.0, 1.0, true));
assert_eq!(extra_knockback(0.0, 1.0, true), (0.5, true));
}
#[test]
fn an_uncharged_sprint_hit_does_not() {
assert!(!attacker_slows_after_hit(CLIENT_DAMAGE, 0.0, 0.5, true));
assert!(
!attacker_slows_after_hit(CLIENT_DAMAGE, 0.0, 0.9, true),
"the bonus needs more than 0.9"
);
}
#[test]
fn a_hit_without_sprinting_or_knockback_does_not() {
assert!(!attacker_slows_after_hit(CLIENT_DAMAGE, 0.0, 1.0, false));
}
#[test]
fn a_synced_knockback_attribute_slows_even_uncharged() {
assert!(attacker_slows_after_hit(CLIENT_DAMAGE, 1.0, 0.2, false));
}
#[test]
fn a_hit_that_does_no_damage_stops_first() {
// Only a server that sends ATTACK_DAMAGE can leave a client without any.
assert!(!attacker_slows_after_hit(0.0, 0.0, 1.0, true));
assert!(!attacker_slows_after_hit(0.0, 1.0, 1.0, true));
}
#[test]
fn a_server_attack_speed_charges_at_once() {
// 1.8-style servers raise ATTACK_SPEED: with 1024 the delay is under
// a tick, so getAttackStrengthScale(0.5) is full right after a hit.
let mut s = AttackStrength::default();
s.reset_attack();
assert_eq!(s.scale(0.5, 1024.0), 1.0);
assert!(attacker_slows_after_hit(
CLIENT_DAMAGE,
0.0,
s.scale(0.5, 1024.0),
true
));
// A sword's own 1.6 leaves it uncharged.
assert!(!attacker_slows_after_hit(
CLIENT_DAMAGE,
0.0,
s.scale(0.5, 1.6),
true
));
}
}
+138
View File
@@ -0,0 +1,138 @@
//! `IntegratedServer.reportChunkLoadFailure` / `reportChunkSaveFailure` /
//! `sendLowDiskSpaceWarning`: the region storage runs on the server thread and
//! its writer thread, and the toasts are the window's. Each failure is logged,
//! checked for low disk space (`MinecraftServer.warnOnLowDiskSpace`) and queued
//! here; the window drains the queue every frame (`Minecraft.execute`).
//! `SystemToast.addOrUpdate` makes a repeat reset the toast it already shows,
//! so only the newest chunk of each kind is kept.
use crate::{lang, save, toasts::SystemToast};
use std::{path::Path, sync::Mutex};
/// What the window has still to toast.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Pending {
/// `SystemToast.onLowDiskSpace`.
pub low_disk_space: bool,
/// `SystemToast.onChunkLoadFailure`: the newest chunk.
pub load_failure: Option<(i32, i32)>,
/// `SystemToast.onChunkSaveFailure`: the newest chunk.
pub save_failure: Option<(i32, i32)>,
}
static PENDING: Mutex<Pending> = Mutex::new(Pending {
low_disk_space: false,
load_failure: None,
save_failure: None,
});
fn with(update: impl FnOnce(&mut Pending)) {
let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
update(&mut pending);
}
/// `MinecraftServer.warnOnLowDiskSpace` for the world folder `dir`.
fn warn_on_low_disk_space(dir: &Path) {
if save::low_disk_space(dir) {
// `MinecraftServer.sendLowDiskSpaceWarning`.
log::warn!("Low disk space! Might not be able to save the world.");
with(|p| p.low_disk_space = true);
}
}
/// `reportChunkLoadFailure` for the chunk at (`x`, `z`) of the dimension in `dir`.
pub fn report_load_failure(dir: &Path, x: i32, z: i32, error: &dyn std::fmt::Display) {
log::error!("Failed to load chunk {x},{z}: {error}");
warn_on_low_disk_space(dir);
with(|p| p.load_failure = Some((x, z)));
}
/// `reportChunkSaveFailure`.
pub fn report_save_failure(dir: &Path, x: i32, z: i32, error: &dyn std::fmt::Display) {
log::error!("Failed to save chunk {x},{z}: {error}");
warn_on_low_disk_space(dir);
with(|p| p.save_failure = Some((x, z)));
}
/// `Minecraft.sendLowDiskSpaceWarning`: a server's `ClientboundLowDiskSpaceWarningPacket`
/// (`ClientPacketListener.handleLowDiskSpaceWarning`) toasts like the local report.
pub fn report_low_disk_space() {
with(|p| p.low_disk_space = true);
}
/// The reports since the last call.
pub fn take() -> Pending {
let mut pending = PENDING.lock().unwrap_or_else(|e| e.into_inner());
std::mem::take(&mut *pending)
}
/// `ChatFormatting.RED`.
fn red() -> u32 {
crate::hud::rgba(255, 85, 85, 255)
}
/// `SystemToast.onLowDiskSpace` (`SystemToastId.LOW_DISK_SPACE`: ten seconds).
pub fn low_disk_space_toast() -> SystemToast {
SystemToast::new(
lang::tr("chunk.toast.lowDiskSpace", &[]),
Some(lang::tr("chunk.toast.lowDiskSpace.description", &[])),
)
.with_display_time(SystemToast::LONG_DISPLAY_TIME)
}
fn chunk_toast(key: &str, x: i32, z: i32) -> SystemToast {
// `Component.translationArg(ChunkPos)` is `ChunkPos.toString`.
let pos = format!("[{x}, {z}]");
SystemToast::new(
lang::tr(key, &[&pos]),
Some(lang::tr("chunk.toast.checkLog", &[])),
)
.with_title_color(red())
}
/// `SystemToast.onChunkLoadFailure`.
pub fn load_failure_toast(x: i32, z: i32) -> SystemToast {
chunk_toast("chunk.toast.loadFailure", x, z)
}
/// `SystemToast.onChunkSaveFailure`.
pub fn save_failure_toast(x: i32, z: i32) -> SystemToast {
chunk_toast("chunk.toast.saveFailure", x, z)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn failures_keep_the_newest_chunk_of_each_kind() {
let _ = take();
let missing = Path::new("/nonexistent-horizon-world");
report_load_failure(missing, 1, 2, &"a");
report_load_failure(missing, 3, 4, &"b");
report_save_failure(missing, 5, 6, &"c");
// A folder that cannot be asked has no low-space warning.
assert_eq!(
take(),
Pending {
low_disk_space: false,
load_failure: Some((3, 4)),
save_failure: Some((5, 6)),
}
);
assert_eq!(take(), Pending::default());
}
#[test]
fn a_servers_warning_packet_toasts_low_disk_space() {
let _ = take();
report_low_disk_space();
assert!(take().low_disk_space);
}
#[test]
fn low_disk_space_toast_stays_ten_seconds() {
let toast = low_disk_space_toast();
assert_eq!(toast.display_time(), 10000.0);
assert_eq!(load_failure_toast(0, 0).display_time(), 5000.0);
}
}
+29 -4
View File
@@ -158,6 +158,18 @@ fn item(
}
}
/// `TextInputHandler.addControl`: the 20 high `EditBox`, or the
/// `MultiLineEditBox` of the options' height, by default as tall as their most
/// lines (4 without a limit) need, at most 512.
pub fn text_height(multiline: Option<(Option<i32>, Option<i32>)>) -> f32 {
match multiline {
None => 20.0,
Some((max_lines, height)) => {
height.unwrap_or_else(|| (9 * max_lines.unwrap_or(4) + 8).min(512)) as f32
}
}
}
/// `InputControlHandlers`: the control for input `i`.
fn input(font: &Font, i: usize, control: &Control) -> Element {
match control {
@@ -165,16 +177,18 @@ fn input(font: &Font, i: usize, control: &Control) -> Element {
width,
label,
label_visible,
multiline,
..
} => {
let w = *width as f32;
let h = text_height(*multiline);
if !label_visible {
return Element {
w,
h: 20.0,
h,
parts: vec![Part::Field {
input: i,
rect: [0.0, 0.0, w, 20.0],
rect: [0.0, 0.0, w, h],
}],
};
}
@@ -187,12 +201,12 @@ fn input(font: &Font, i: usize, control: &Control) -> Element {
}
Element {
w: w.max(font.width_text(label)),
h: 9.0 + 4.0 + 20.0,
h: 9.0 + 4.0 + h,
parts: vec![
Part::Line(shown, 0.0, 0.0),
Part::Field {
input: i,
rect: [0.0, 13.0, w, 20.0],
rect: [0.0, 13.0, w, h],
},
],
}
@@ -439,6 +453,17 @@ mod tests {
assert_eq!(one.w, 100.0);
}
#[test]
fn a_multiline_input_is_as_tall_as_its_lines_or_its_height() {
assert_eq!(text_height(None), 20.0);
// No options: four lines and the padding.
assert_eq!(text_height(Some((None, None))), 44.0);
assert_eq!(text_height(Some((Some(10), None))), 98.0);
// Never over 512; a height wins over the lines.
assert_eq!(text_height(Some((Some(100), None))), 512.0);
assert_eq!(text_height(Some((Some(2), Some(70)))), 70.0);
}
#[test]
fn the_waiting_screens_button_counts_down() {
assert!(!waiting_button(0).1);
+149 -3
View File
@@ -1,6 +1,7 @@
//! Non-player entities simulated on the client world. Dropped items follow
//! `ItemEntity.tick`: gravity 0.04, 0.98 drag (block friction on the ground),
//! a ground bounce of -0.5, water buoyancy, pickup delay, 6000-tick despawn,
//! a ground bounce of -0.5, slime landing restitution (0.8, including a moving
//! piston that is carrying slime), water buoyancy, pickup delay, 6000-tick despawn,
//! and `HoneyBlock.entityInside` when falling against a honey block's side.
use crate::{
blocks::{BlockFacts, Fluid},
@@ -8,9 +9,51 @@ use crate::{
items::ItemStack,
world::World,
};
use horizon_core::geometry::{Aabb, Vec3};
use horizon_core::geometry::{Aabb, Direction, Vec3};
use horizon_world::block_collision::BlockCollisionRegistry;
/// Gravity applied each tick when the item is not in a fluid (`ItemEntity.tick`).
const ITEM_GRAVITY: f64 = 0.04;
/// Non-living slime `bounceRestitution` (`1.0 * 0.8`) from
/// `Entity.restituteMovementAfterCollisions`.
const ITEM_SLIME_BOUNCE: f64 = 0.8;
/// After a vertical collision: slime bounces a dropped item by 0.8, matching
/// non-living `Entity.restituteMovementAfterCollisions`. Anything else zeros Y.
fn item_vertical_collision_velocity(wanted_y: f64, slime: bool) -> f64 {
if slime && wanted_y < 0.0 && -wanted_y > ITEM_GRAVITY {
-wanted_y * ITEM_SLIME_BOUNCE
} else {
0.0
}
}
fn slime_support_name(block: &str, moving_moved: Option<&str>) -> bool {
block == "minecraft:slime_block" || moving_moved == Some("minecraft:slime_block")
}
/// The block under an item, including `Entity.getOnPos` (~1e-5) as well as the
/// friction sample (`y - 0.999999`). A piston-moved slime is `moving_piston`
/// with `movedState = slime`, which has the bounce, not the moving piston.
fn item_support_is_slime(world: &World, facts: &BlockFacts, position: Vec3) -> bool {
let x = position.x.floor() as i32;
let z = position.z.floor() as i32;
let friction_y = (position.y - 0.999_999).floor() as i32;
let on_y = (position.y - f64::from(1.0e-5_f32)).floor() as i32;
slime_at(world, facts, x, friction_y, z)
|| (on_y != friction_y && slime_at(world, facts, x, on_y, z))
}
fn slime_at(world: &World, facts: &BlockFacts, x: i32, y: i32, z: i32) -> bool {
let block = &*facts.get(world.block(x, y, z)).name;
let moving_moved = world
.moving_pistons
.iter()
.find(|(pos, _)| *pos == [x, y, z])
.map(|(_, piston)| &*facts.get(piston.moved_state).name);
slime_support_name(block, moving_moved)
}
mod fluids;
#[derive(Clone, Debug)]
@@ -236,7 +279,11 @@ impl ItemEntity {
self.velocity.z = 0.0;
}
if moved.y != wanted.y {
self.velocity.y = 0.0;
// Entity.move restitutes instead of zeroing Y. A slime block
// (or a moving piston carrying one) bounces a dropped item;
// MovingPistonBlock itself has no bounceRestitution.
let slime = item_support_is_slime(world, facts, self.position);
self.velocity.y = item_vertical_collision_velocity(wanted.y, slime);
}
// Entity.move -> applyEffectsFromBlocks: HoneyBlock.entityInside.
self.apply_honey_slide(world, facts);
@@ -263,6 +310,14 @@ impl ItemEntity {
self.update_fluids(world, fluids);
self.age < 6000 && self.position.y > -128.0
}
/// `PistonMovingBlockEntity.causeBounce` for a dropped item. Vanilla then
/// runs `Entity.move(MoverType.PISTON)`, which leaves the ground on an
/// upward push; without that, `ItemEntity.tick` skips movement 3 of 4 ticks
/// while `onGround` with no horizontal speed and gravity eats the bounce.
pub fn piston_slime_bounce(&mut self, movement: Direction) {
self.velocity = horizon_world::piston_push::bounce_velocity(movement, self.velocity);
self.on_ground = false;
}
/// `HoneyBlock.entityInside` for the honey blocks this item's box is in
/// (`Entity.checkInsideBlocks`, deflated by `1.0E-5`). Slide sounds and
/// entity event 53 are skipped (`doesEntityDoHoneyBlockSlideEffects` is
@@ -1406,3 +1461,94 @@ mod honey_slide_tests {
assert_eq!(slow.velocity.y, -0.04);
}
}
#[cfg(test)]
mod slime_bounce_tests {
use super::*;
#[test]
fn landing_on_slime_bounces_a_dropped_item() {
assert_eq!(item_vertical_collision_velocity(-1.0, true), 0.8);
assert_eq!(item_vertical_collision_velocity(-0.5, true), 0.4);
assert_eq!(
item_vertical_collision_velocity(-0.04, true),
0.0,
"gravity alone is not a bounce"
);
assert_eq!(item_vertical_collision_velocity(-0.041, true), 0.041 * 0.8);
}
#[test]
fn landing_on_stone_or_hitting_a_ceiling_zeros_y() {
assert_eq!(item_vertical_collision_velocity(-1.0, false), 0.0);
assert_eq!(item_vertical_collision_velocity(1.0, true), 0.0);
assert_eq!(item_vertical_collision_velocity(0.96, true), 0.0);
}
#[test]
fn a_moving_piston_carrying_slime_still_counts_as_slime() {
assert!(slime_support_name("minecraft:slime_block", None));
assert!(slime_support_name(
"minecraft:moving_piston",
Some("minecraft:slime_block")
));
assert!(slime_support_name(
"minecraft:air",
Some("minecraft:slime_block")
));
assert!(!slime_support_name(
"minecraft:moving_piston",
Some("minecraft:stone")
));
assert!(!slime_support_name("minecraft:stone", None));
assert!(!slime_support_name(
"minecraft:honey_block",
Some("minecraft:honey_block")
));
}
fn grounded_item() -> ItemEntity {
let at = Vec3::new(0.5, 2.0, 0.5);
ItemEntity {
id: 1,
thrower: None,
target: None,
stack: ItemStack {
item: std::sync::Arc::from("minecraft:stone"),
count: 1,
damage: 0,
extra: None,
},
health: 5,
fire_ticks: 0,
position: at,
previous: at,
velocity: Vec3::ZERO,
on_ground: true,
age: 10,
pickup_delay: 0,
bob_offset: 0.0,
ticks: 10,
}
}
#[test]
fn a_piston_slime_bounce_launches_the_item_off_the_ground() {
let mut item = grounded_item();
item.piston_slime_bounce(Direction::Up);
assert_eq!(item.velocity, Vec3::new(0.0, 1.0, 0.0));
assert!(!item.on_ground);
item.piston_slime_bounce(Direction::East);
assert_eq!(item.velocity, Vec3::new(1.0, 1.0, 0.0));
assert!(!item.on_ground);
}
#[test]
fn a_slime_landing_stays_upward_after_item_friction() {
// ItemEntity then multiplies Y by 0.98 and applies -0.5 only if still
// negative on the ground. A slime bounce must remain positive.
let bounced = item_vertical_collision_velocity(-1.0, true) * 0.98;
assert!(bounced > 0.0);
assert_eq!(bounced, 0.8 * 0.98);
}
}
+11
View File
@@ -1118,6 +1118,8 @@ pub fn put_brain(map: &mut Compound, m: &Mob, dimension: &str) {
for &(name, value, ttl) in &m.extra.memories {
let value = match name {
"hunted_recently" => Tag::Byte(value as i8),
// `Unit` memories save an empty compound.
"is_pregnant" => Tag::Compound(Compound::new()),
"home" => match m.extra.home {
Some(p) => Tag::compound([
("dimension", string(dimension)),
@@ -1537,6 +1539,15 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
});
}
}
if m.species() == crate::mobs::Species::Frog
&& tag
.get("Brain")
.and_then(|b| b.get("memories"))
.and_then(|b| b.get("minecraft:is_pregnant"))
.is_some()
{
crate::mobs::frog::set_pregnant(&mut m, true);
}
if crate::mobs::camel::is_camel(m.species()) {
// The tick is a game time: `camel::finish_load` puts it on the mob's
// clock once the world's time is known.
+56
View File
@@ -514,6 +514,12 @@ impl HandHeights {
}
}
/// `FirstPersonHandsAndItemsRenderer.submit`: the displayed stack's model
/// scales the interpolated equip displacement independently for each hand.
pub fn equip_displacement(height: f32, swap_animation_scale: f32) -> f32 {
swap_animation_scale * (1.0 - height)
}
/// `LocalPlayer.xBob` and `yBob`: the hands lag behind the view when it
/// turns. `LocalPlayer.applyInput` eases them halfway to the rotation every
/// tick; `FirstPersonHandsAndItemsRenderer` turns the hands by a tenth of
@@ -910,6 +916,56 @@ impl Clicks {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn equip_displacement_uses_the_visible_items_scale() {
use horizon_assets::item_model::{ClientItem, ItemModels, StackView};
use serde_json::json;
let mut models = ItemModels::default();
for (item, scale) in [("minecraft:spear", 1.95), ("minecraft:sword", 1.0)] {
models.definitions.insert(
item.into(),
ClientItem::decode(&json!({
"model": { "type": "minecraft:empty" },
"swap_animation_scale": scale,
}))
.unwrap(),
);
models.prototypes.insert(
item.into(),
[("minecraft:item_model".into(), json!(item))].into(),
);
}
let spear = StackView::new("minecraft:spear", 1);
let sword = StackView::new("minecraft:sword", 1);
let height = 0.5;
// The model of the visible stack determines each hand's displacement.
assert_eq!(
equip_displacement(height, models.swap_animation_scale(&spear)),
0.975
);
assert_eq!(
equip_displacement(height, models.swap_animation_scale(&sword)),
0.5
);
assert_eq!(
equip_displacement(1.0, models.swap_animation_scale(&spear)),
0.0
);
// A component override (such as a resource-pack model) takes precedence.
let mut overridden = spear;
overridden.patch.insert(
"minecraft:item_model".into(),
Some(json!("minecraft:sword")),
);
assert_eq!(
equip_displacement(height, models.swap_animation_scale(&overridden)),
0.5
);
}
#[test]
fn drawing_a_bow_or_holding_a_charged_crossbow_hides_a_hand() {
let (bow, apple) = (
File diff suppressed because it is too large. Load diff
+125 -35
View File
@@ -81,14 +81,17 @@ impl Toast {
pub(super) struct TravelMarks {
/// `startingToFallPosition`.
pub(super) fall: Option<[f64; 3]>,
/// `currentExplosionImpactPos`, and whether a wind charge made the blast.
pub(super) blast: Option<([f64; 3], bool)>,
/// `currentExplosionImpactPos` and `currentExplosionCause` (the entity
/// that made the blast, if any).
pub(super) blast: Option<([f64; 3], Option<EntityView>)>,
/// `enteredLavaOnVehiclePosition`.
pub(super) lava_ride: Option<[f64; 3]>,
/// `enteredNetherPosition`.
pub(super) nether: Option<[f64; 3]>,
/// The (effect, amplifier) pairs `EffectsChangedTrigger` last saw.
pub(super) effects: Vec<(&'static str, i32)>,
/// `PlayerEffects::changes` as the last tick saw it.
pub(super) effect_changes: u32,
}
#[derive(Default)]
pub(super) struct AdvState {
@@ -407,6 +410,12 @@ impl Game<'_> {
.iter()
.find(|m| m.id == id)
.map(|m| Box::new(self.mob_entity(m)))
.or_else(|| {
self.boats
.iter()
.find(|b| b.id == id)
.map(|b| Box::new(self.boat_entity(b)))
})
});
EntityView {
kind: "minecraft:player".into(),
@@ -429,6 +438,43 @@ impl Game<'_> {
..Default::default()
}
}
/// The player as a passenger of the vehicle `id` (`Entity.getPassengers`).
fn rider_entity(&self, id: u32) -> Option<EntityView> {
let p = self.player.as_ref().filter(|_| self.riding == Some(id))?;
Some(EntityView {
kind: "minecraft:player".into(),
position: [p.position.x, p.position.y, p.position.z],
gamemode: Some(self.mode.name().to_owned()),
..Default::default()
})
}
/// A boat as the predicates see it: its type, and its passengers (the
/// mobs seated on it, then the player riding it).
pub(super) fn boat_entity(&self, b: &super::boats::Boat) -> EntityView {
EntityView {
kind: b.entity_type(),
position: [b.position.x, b.position.y, b.position.z],
on_ground: b.on_ground,
passengers: self
.mobs
.iter()
.filter(|m| m.extra.mount == Some(b.id))
.map(|m| self.mob_entity(m))
.chain(self.rider_entity(b.id))
.collect(),
..Default::default()
}
}
/// A dropped item as the predicates see it.
pub(super) fn item_entity_view(&self, e: &horizon_client::entities::ItemEntity) -> EntityView {
EntityView {
kind: "minecraft:item".into(),
position: [e.position.x, e.position.y, e.position.z],
on_ground: e.on_ground,
on_fire: e.fire_ticks > 0,
..Default::default()
}
}
pub(super) fn mob_entity(&self, m: &horizon_client::mobs::Mob) -> EntityView {
// mainhand, offhand, head, chest, legs, feet.
let slot = |i: &Option<std::sync::Arc<str>>| {
@@ -462,6 +508,13 @@ impl Game<'_> {
.map(|(id, value)| (id.to_owned(), value))
.into_iter()
.collect(),
passengers: self
.mobs
.iter()
.filter(|r| r.extra.mount == Some(m.id))
.map(|r| self.mob_entity(r))
.chain(self.rider_entity(m.id))
.collect(),
..Default::default()
}
}
@@ -773,10 +826,21 @@ impl Game<'_> {
/// `KilledTrigger` (`player_killed_entity`): the entity killed and the
/// killing blow's damage source (none: any `killing_blow` fails).
pub(super) fn trigger_killed(&mut self, target: &EntityView, blow: Option<&adv::DamageView>) {
self.trigger_killed_as("minecraft:player_killed_entity", target, blow);
}
/// `KilledTrigger.trigger` for any of its three instances
/// (`player_killed_entity`, `entity_killed_player`,
/// `kill_mob_near_sculk_catalyst`).
pub(super) fn trigger_killed_as(
&mut self,
name: &str,
target: &EntityView,
blow: Option<&adv::DamageView>,
) {
let me = self.player_entity();
let target = target.clone();
let blow = blow.cloned();
self.trigger("minecraft:player_killed_entity", move |c, w| {
self.trigger(name, move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
origin: me.position,
@@ -843,13 +907,13 @@ impl Game<'_> {
match (falling, self.adv.marks.fall) {
(true, None) => {
self.adv.marks.fall = Some(at);
// The blast a wind charge made is spent by the fall, and counts
// only if the player was above where it hit.
if let Some((impact, wind_charge)) = self.adv.marks.blast.take()
&& wind_charge
// `trackStartFallingPosition`: the last blast is spent by the
// fall (whatever made it), and counts only if the player was
// above where it hit.
if let Some((impact, cause)) = self.adv.marks.blast.take()
&& impact[1] <= at[1]
{
self.trigger_fall_after_explosion(impact);
self.trigger_fall_after_explosion(impact, cause);
}
}
// `resetFallDistance`: a fall ends by landing, climbing or water.
@@ -874,17 +938,31 @@ impl Game<'_> {
self.adv.marks.nether = None;
self.trigger_distance("minecraft:nether_travel", start);
}
let effects: Vec<(&'static str, i32)> = self
.potions
let effects = self.effects_seen();
// `onEffectAdded`, `onEffectUpdated` (an `update` that changed the
// duration, amplifier or flags too) and `onEffectsRemoved`.
let changes = self.potions.effects.changes;
if effects != self.adv.marks.effects || changes != self.adv.marks.effect_changes {
self.adv.marks.effects = effects;
self.adv.marks.effect_changes = changes;
self.trigger_effects_changed(None);
}
}
/// The (effect, amplifier) pairs `marks.effects` compares.
fn effects_seen(&self) -> Vec<(&'static str, i32)> {
self.potions
.effects
.list
.iter()
.map(|e| (e.id, e.amplifier))
.collect();
if effects != self.adv.marks.effects {
self.adv.marks.effects = effects;
self.trigger_effects_changed(None);
}
.collect()
}
/// `EffectsChangedTrigger` for a change the caller knows the source of;
/// the end-of-tick check then does not fire it a second time.
pub(super) fn trigger_effects_changed_by(&mut self, source: Option<EntityView>) {
self.adv.marks.effects = self.effects_seen();
self.adv.marks.effect_changes = self.potions.effects.changes;
self.trigger_effects_changed(source);
}
/// `DistanceTrigger`: the player's `player` condition, `start_position`
/// at `start`, and the `distance` from `start` to the player.
@@ -902,15 +980,12 @@ impl Game<'_> {
&& (c["distance"].is_null() || adv::distance(&c["distance"], start, me.position))
});
}
/// `FallAfterExplosionTrigger` for a wind charge's blast that hit the
/// player at `impact`, measured to where the player is now.
fn trigger_fall_after_explosion(&mut self, impact: [f64; 3]) {
/// `FallAfterExplosionTrigger.trigger`: a blast that hit the player at
/// `impact` (made by `cause`, or by nothing), measured to where the
/// player is now. `matches` tests `cause` only when the instance has one,
/// and a missing cause fails it.
fn trigger_fall_after_explosion(&mut self, impact: [f64; 3], cause: Option<EntityView>) {
let me = self.player_entity();
let cause = EntityView {
kind: "minecraft:wind_charge".into(),
position: impact,
..EntityView::default()
};
self.trigger("minecraft:fall_after_explosion", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
@@ -918,21 +993,24 @@ impl Game<'_> {
tool: None,
block_pos: None,
};
let cctx = adv::Entities {
this: Some(&cause),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["player"], &pctx, w)
&& adv::location(&c["start_position"], impact, w)
&& (c["distance"].is_null() || adv::distance(&c["distance"], impact, me.position))
&& adv::conditions(&c["cause"], &cctx, w)
&& (c["cause"].is_null()
|| cause.as_ref().is_some_and(|cause| {
let cctx = adv::Entities {
this: Some(cause),
origin: me.position,
tool: None,
block_pos: None,
};
adv::conditions(&c["cause"], &cctx, w)
}))
});
}
/// `EffectsChangedTrigger`: the player's `effects`, and for an effect that
/// `source` gave, that entity (`source`).
pub(super) fn trigger_effects_changed(&mut self, source: Option<EntityView>) {
fn trigger_effects_changed(&mut self, source: Option<EntityView>) {
let me = self.player_entity();
self.trigger("minecraft:effects_changed", move |c, w| {
let pctx = adv::Entities {
@@ -961,15 +1039,25 @@ impl Game<'_> {
adv::conditions(&c["player"], &pctx, w) && effects && source
});
}
/// `ItemDurabilityTrigger`: a player's item takes damage (`ItemStack.applyDamage`),
/// `stack` as it is before the damage. The durability and delta ranges are not
/// read: no advancement in the data uses them.
pub(super) fn trigger_item_durability(&mut self, stack: &ItemStack) {
/// `ItemDurabilityTrigger.trigger` and `TriggerInstance.matches`: a player's
/// item takes damage (`ItemStack.applyDamage`), `stack` as it is before the
/// damage and `new_damage` the unclamped damage value it is about to get.
/// `durability` tests `maxDamage - newDamage`, `delta` tests
/// `damageValue - newDamage`.
pub(super) fn trigger_item_durability(&mut self, stack: &ItemStack, new_damage: u32) {
if self.remote.is_some() || self.no_player_here() {
return;
}
let me = self.player_entity();
let item = item_view(stack);
let max = self
.res
.items()
.get(&stack.item)
.map_or(0, |i| i.max_damage) as i64;
// ItemStack.getDamageValue clamps to 0..=getMaxDamage.
let damage = i64::from(stack.damage).clamp(0, max);
let new_damage = i64::from(new_damage);
self.trigger("minecraft:item_durability_changed", move |c, w| {
let pctx = adv::Entities {
this: Some(&me),
@@ -979,6 +1067,8 @@ impl Game<'_> {
};
adv::conditions(&c["player"], &pctx, w)
&& (c["item"].is_null() || adv::item(&c["item"], &item, w))
&& adv::range_i(&c["durability"], max - new_damage)
&& adv::range_i(&c["delta"], damage - new_damage)
});
}
/// `ServerPlayer.updateUsingItem`: USING_ITEM for the item in use, with
+13
View File
@@ -422,6 +422,7 @@ impl Game<'_> {
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::BlockEditor => "block-editor",
Screen::Win => "win",
}
}
@@ -602,6 +603,17 @@ impl Game<'_> {
Mode::BackupBeforeUpgrade { world, .. } => {
json!({ "mode": "backup-before-upgrade", "folder": world.folder, "name": world.name })
}
Mode::OptimizeConfirm {
world, erase_cache, ..
} => {
json!({ "mode": "optimize-world-confirm", "folder": world.folder, "name": world.name, "erase_cache": erase_cache })
}
Mode::Optimize { world } => {
json!({ "mode": "optimize-world", "folder": world.folder, "name": world.name })
}
Mode::LowDiskSpace { world } => {
json!({ "mode": "low-disk-space", "folder": world.folder, "name": world.name })
}
Mode::Recover { world, issues } => {
let issue = |i: &Option<horizon_client::save::DatIssue>| match i {
None => json!(null),
@@ -625,6 +637,7 @@ impl Game<'_> {
Mode::MultiplayerWarning { check } => {
json!({ "mode": "multiplayer-warning", "check": check })
}
Mode::Onboarding(_) => json!({ "mode": "accessibility-onboarding" }),
}
}
+19 -8
View File
@@ -75,7 +75,14 @@ impl ArmorStandAssets {
}
let mut equipment = HashMap::new();
let mut trim_overrides = HashMap::new();
if let Ok(entries) = std::fs::read_dir(root.join("assets/minecraft/equipment")) {
// EquipmentAssetManager reads every namespace: vanilla's and the content mods'.
let namespaces = std::iter::once("minecraft".to_owned())
.chain(horizon_client::mod_content::namespaces(root));
for namespace in namespaces {
let Ok(entries) = std::fs::read_dir(root.join(format!("assets/{namespace}/equipment")))
else {
continue;
};
for e in entries.flatten() {
let path = e.path();
let Some(name) = path.file_stem().and_then(|s| s.to_str()) else {
@@ -87,6 +94,7 @@ impl ArmorStandAssets {
continue;
};
let Ok(v) = v else { continue };
let id = format!("{namespace}:{name}");
let mut kinds = HashMap::new();
for kind in ["humanoid", "humanoid_leggings", "wings"] {
// Every layer of the kind: leather's overlay is layer 1.
@@ -95,22 +103,25 @@ impl ArmorStandAssets {
.into_iter()
.flatten()
.map(|layer| {
let tex = layer["texture"]
.as_str()
.unwrap_or(name)
.trim_start_matches("minecraft:");
let tex = layer["texture"].as_str().unwrap_or(&id);
let (tex_namespace, tex) =
tex.split_once(':').unwrap_or(("minecraft", tex));
let sprite = match tex_namespace {
"minecraft" => format!("entity/equipment/{kind}/{tex}"),
other => format!("{other}:entity/equipment/{kind}/{tex}"),
};
let undyed = layer["dyeable"]["color_when_undyed"]
.as_i64()
.map(|c| c as u32 & 0xFF_FFFF);
(format!("entity/equipment/{kind}/{tex}"), undyed)
(sprite, undyed)
})
.collect();
if !layers.is_empty() {
kinds.insert(kind.to_owned(), layers);
}
}
trim_overrides.insert(format!("minecraft:{name}"), TrimOverride::list(&v));
equipment.insert(format!("minecraft:{name}"), kinds);
trim_overrides.insert(id.clone(), TrimOverride::list(&v));
equipment.insert(id, kinds);
}
}
Self {
+41 -1
View File
@@ -14,6 +14,7 @@ use horizon_client::{
items::ItemStack,
mesher::Vertex,
mobs::render::{DYE, DYE_NAMES},
test_instance_block::Status,
};
use horizon_protocol::packets::play::{self, Serverbound as S};
@@ -84,6 +85,30 @@ fn average(a: u32, b: u32) -> u32 {
.fold(0, |acc, &s| acc | ((ch(a, s) + ch(b, s)) / 2) << s)
}
impl Game<'_> {
/// `TestInstanceBlockEntity.getBeamSections`: grey while running, green
/// for a pass, red for a failed required test and orange for an optional
/// one; each section is one block tall and the last runs to the sky.
fn test_instance_beam(&self, e: &BlockEntity) -> Option<Vec<(u32, i32)>> {
let BlockEntity::TestInstanceBlock(b) = e else {
return None;
};
let color = match b.data.status {
Status::Cleared => return None,
Status::Running => 0x80_8080,
Status::Finished if b.data.error_message.is_none() => 0x00_FF00,
Status::Finished => {
let required = b
.data
.test
.as_deref()
.and_then(|key| self.test_def(key))
.is_none_or(|t| t.required);
if required { 0xFF_0000 } else { 0xFF_8000 }
}
};
Some(vec![(color, 1)])
}
/// The beam color of a `BeaconBeamBlock` (stained glass and panes, the beacon).
fn beam_color(&self, name: &str) -> Option<u32> {
if name == "minecraft:beacon" {
@@ -598,16 +623,27 @@ impl Game<'_> {
self.far_beacons.retain(|&pos, _| {
let (x, _, z) = pos;
!world.chunks.contains_key(&(x >> 4, z >> 4))
|| world.block_entities.get(&pos).and_then(beam).is_some()
|| world
.block_entities
.get(&pos)
.is_some_and(|e| beam(e).is_some() || is_test_instance_beam(e))
});
} else {
self.far_beacons.clear();
}
let test_beams: Vec<_> = world
.block_entities
.iter()
.filter_map(|(k, e)| Some((*k, self.test_instance_beam(e)?)))
.collect();
for (k, e) in &world.block_entities {
if let Some(sections) = beam(e) {
self.far_beacons.insert(*k, sections.to_vec());
}
}
for (k, sections) in test_beams {
self.far_beacons.insert(k, sections);
}
for (k, sections) in &self.far_beacons {
let base = glam::DVec3::new(f64::from(k.0) + 0.5, f64::from(k.1), f64::from(k.2) + 0.5);
let offset = base - camera;
@@ -628,6 +664,10 @@ impl Game<'_> {
}
}
}
/// Whether a test instance block shows a beam (`getBeamSections` not empty).
fn is_test_instance_beam(e: &BlockEntity) -> bool {
matches!(e, BlockEntity::TestInstanceBlock(b) if b.data.status != Status::Cleared)
}
/// The beam sections of an active beacon (`BeaconRenderer` draws no beam for
/// a beacon without a pyramid or with all sections cleared).
fn beam(e: &BlockEntity) -> Option<&[(u32, i32)]> {
@@ -0,0 +1,645 @@
//! The edit screens of the game master blocks (`StructureBlockEditScreen`,
//! `JigsawBlockEditScreen`, `TestBlockEditScreen`,
//! `TestInstanceBlockEditScreen`): one screen that runs an [`Editor`], which
//! lays out its widgets, takes their presses and builds the packet for the
//! server. The screens change the client's copy of the block entity as they
//! are used, as vanilla's do, so the box preview follows them.
use super::{Game, Screen};
use horizon_client::{
containers::BlockEntity,
hud::{HudBatch, rgba},
text::Text,
ui::{self, EditBox},
};
use horizon_protocol::packets::play::Serverbound as S;
use winit::{
event::{ElementState, KeyEvent},
keyboard::{KeyCode, PhysicalKey},
};
pub(super) type Pos = (i32, i32, i32);
/// `Screen.extractRenderState`'s grey for the labels: `-6250336`.
pub(super) const LABEL: u32 = 0xFFA0_A0A0;
/// A widget of an edit screen at GUI pixels.
pub(super) enum Item {
Label {
text: Text,
x: f32,
y: f32,
/// Right-aligned at `x` (the labels beside the right-hand buttons).
right: bool,
},
Title {
text: Text,
y: f32,
},
Edit {
index: usize,
x: f32,
y: f32,
w: f32,
},
Button {
id: u8,
x: f32,
y: f32,
w: f32,
label: Text,
active: bool,
},
/// A multi-line message (`FittingMultiLineTextWidget`), wrapped to `w`
/// and cut to what fits `h`.
Info {
text: Text,
x: f32,
y: f32,
w: f32,
h: f32,
},
/// An `AbstractSliderButton`: `value` is 0..1.
Slider {
id: u8,
x: f32,
y: f32,
w: f32,
label: Text,
value: f32,
},
}
/// What a press on a button does to the screen.
pub(super) enum Outcome {
Stay,
/// Send the packet and close.
Send(S),
/// Send the packets in order and close.
SendAll(Vec<S>),
/// Close, after the editor reverted what it changed.
Cancel,
}
/// One edit screen's widgets and rules.
pub(super) trait Editor {
/// The text boxes, in tab order.
fn edits(&mut self) -> &mut [EditBox];
/// The widgets for the block entity as it is now, in a `w`×`h` screen.
fn items(&self, entity: &BlockEntity, w: f32, h: f32) -> Vec<Item>;
/// A button pressed (`shift` held), possibly changing the entity.
fn press(
&mut self,
entity: &mut BlockEntity,
pos: Pos,
id: u8,
shift: bool,
alt: bool,
) -> Outcome;
/// The screen opened: packets it sends first (`init`).
fn opened(&mut self, _entity: &BlockEntity, _pos: Pos) -> Vec<S> {
Vec::new()
}
/// The text of box `edit` changed (`EditBox.setResponder`): packets to send.
fn edited(&mut self, _edit: usize, _pos: Pos) -> Vec<S> {
Vec::new()
}
/// A status from the server (`ClientboundTestInstanceBlockStatus`).
fn status(&mut self, _entity: &BlockEntity, _status: Text, _size: Option<[i32; 3]>) {}
/// A slider moved to `value` (0..1).
fn slide(&mut self, _id: u8, _value: f32) {}
/// Done: the packet that carries the screen's values.
fn done(&mut self, entity: &mut BlockEntity, pos: Pos) -> Outcome;
/// Cancel: put the block entity back.
fn cancel(&mut self, entity: &mut BlockEntity);
/// Whether the box takes the typed character (`EditBox.charTyped`).
fn accepts(&self, _edit: usize, _current: &str, _cursor: usize, _c: char) -> bool {
true
}
/// Whether Enter is Done (a screen with its own `keyPressed`).
fn enter_confirms(&self) -> bool {
true
}
/// The box that has the focus when the screen opens.
fn initial_focus(&self) -> usize {
0
}
}
/// The open edit screen.
pub(super) struct BlockEditScreen {
pub(super) pos: Pos,
pub(super) editor: Box<dyn Editor>,
/// The text box with the focus, and which of them show.
focus: Option<usize>,
/// The text of each box when the screen last looked, to see what changed.
values: Vec<String>,
/// The slider being dragged: its id and the x and width it is drawn at.
dragging: Option<(u8, f32, f32)>,
}
impl BlockEditScreen {
pub(super) fn new(pos: Pos, editor: Box<dyn Editor>) -> Self {
let focus = Some(editor.initial_focus());
Self {
pos,
editor,
focus,
values: Vec::new(),
dragging: None,
}
}
}
impl Game<'_> {
/// `LocalPlayer.open*Block`: the edit screen for the block entity at
/// `pos`.
pub(super) fn open_block_editor(&mut self, pos: Pos, editor: Box<dyn Editor>) {
let mut screen = BlockEditScreen::new(pos, editor);
let opening = self
.world
.block_entities
.get(&pos)
.map(|entity| screen.editor.opened(entity, pos))
.unwrap_or_default();
screen.values = screen
.editor
.edits()
.iter()
.map(|e| e.value.clone())
.collect();
self.block_editor = Some(screen);
self.screen = Screen::BlockEditor;
self.capture(false);
for packet in opening {
self.send(packet);
}
}
/// A use of a game master block on a server's client, which opens the
/// screen itself (`BlockEntity.usedBy`) while the server only answers the
/// click. The block at `pos`, when the player may use it.
pub(super) fn open_block_editor_if_editable(&mut self, pos: Pos) {
if !self.can_use_game_master_blocks() {
return;
}
match self.world.block_entities.get(&pos) {
Some(BlockEntity::StructureBlock(_)) => self.open_structure_block(pos),
Some(BlockEntity::JigsawBlock(_)) => self.open_jigsaw_block(pos),
Some(BlockEntity::TestBlock(_)) => self.open_test_block(pos),
Some(BlockEntity::TestInstanceBlock(_)) => self.open_test_instance_block(pos),
_ => {}
}
}
pub(super) fn close_block_editor(&mut self) {
self.block_editor = None;
self.screen = Screen::Playing;
self.capture(true);
}
/// Run `f` on the open screen with its block entity; the screen closes
/// when the block is gone (`BlockEntity` removed).
fn with_block_editor<R>(
&mut self,
f: impl FnOnce(&mut BlockEditScreen, &mut BlockEntity) -> R,
) -> Option<R> {
let mut screen = self.block_editor.take()?;
let result = self
.world
.block_entities
.get_mut(&screen.pos)
.map(|entity| f(&mut screen, entity));
if result.is_some() {
self.block_editor = Some(screen);
} else {
self.screen = Screen::Playing;
self.capture(true);
}
result
}
fn block_editor_outcome(&mut self, outcome: Outcome) {
match outcome {
Outcome::Stay => {}
Outcome::Send(packet) => {
self.send(packet);
self.close_block_editor();
}
Outcome::SendAll(packets) => {
for packet in packets {
self.send(packet);
}
self.close_block_editor();
}
Outcome::Cancel => self.close_block_editor(),
}
}
pub(super) fn draw_block_editor(&mut self, batch: &mut HudBatch) {
let cursor = self.cursor;
let Some(items) = self.with_block_editor(|s, e| {
let items = s.editor.items(e, batch.width, batch.height);
for (i, edit) in s.editor.edits().iter_mut().enumerate() {
edit.focused = s.focus == Some(i);
}
items
}) else {
return;
};
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
batch.fill_gradient(
atlas,
(0.0, 0.0, w, h),
rgba(0x10, 0x10, 0x10, 0xC0),
rgba(0x10, 0x10, 0x10, 0xD0),
);
let Some(mut screen) = self.block_editor.take() else {
return;
};
// Buttons first, then the labels and boxes, as `Screen` renders its
// widgets before the screen draws its own.
for item in &items {
if let Item::Button {
x,
y,
w: bw,
label,
active,
..
} = item
{
let hover = ui::hovered(cursor, *x, *y, *bw, 20.0);
ui::button(batch, atlas, *x, *y, *bw, 20.0, label, *active, hover);
}
}
for item in &items {
match item {
Item::Title { text, y } => {
let tw = atlas.font.width_text(text);
batch.text_styled(
atlas,
text,
(w / 2.0 - tw / 2.0).floor(),
*y,
u32::MAX,
true,
);
}
Item::Label { text, x, y, right } => {
let x = if *right {
x - atlas.font.width_text(text)
} else {
*x
};
batch.text_styled(atlas, text, x, *y, LABEL, true);
}
Item::Edit { index, x, y, w: ew } => {
if let Some(edit) = screen.editor.edits().get_mut(*index) {
(edit.x, edit.y, edit.width, edit.height) = (*x, *y, *ew, 20.0);
edit.draw(batch, atlas, None);
}
}
Item::Button { .. } => {}
Item::Info {
text,
x,
y,
w: iw,
h: ih,
} => {
let lines = atlas.font.wrap_components(text, *iw as i32);
for (i, line) in lines.iter().take((*ih / 9.0) as usize).enumerate() {
batch.text_styled(atlas, line, *x, *y + i as f32 * 9.0, u32::MAX, true);
}
}
Item::Slider {
id,
x,
y,
w: sw,
label,
value,
} => {
let hover = ui::hovered(cursor, *x, *y, *sw, 20.0);
let dragging = screen.dragging.is_some_and(|(d, _, _)| d == *id);
ui::slider(
batch, atlas, *x, *y, *sw, label, *value, hover, true, dragging,
);
}
}
}
self.block_editor = Some(screen);
}
pub(super) fn block_editor_click(&mut self) {
let size = self
.renderer
.as_ref()
.map_or([1, 1], |r| [r.config.width, r.config.height]);
let b = HudBatch::new(size);
let cursor = self.cursor;
let shift = self.keys.contains(&KeyCode::ShiftLeft);
let alt = self.keys.contains(&KeyCode::AltLeft);
let atlas_click = |game: &Game<'_>, edit: &mut EditBox| {
edit.click(&game.gui.atlas, cursor.0, false, false);
};
let Some(items) = self.with_block_editor(|s, e| s.editor.items(e, b.width, b.height))
else {
return;
};
// The widgets in the order they were added: the last on top.
for item in items.iter().rev() {
match item {
Item::Button {
id,
x,
y,
w,
active,
..
} if *active && ui::hovered(cursor, *x, *y, *w, 20.0) => {
self.audio.play_button_click();
let id = *id;
if let Some(outcome) = self.with_block_editor(|s, e| {
let pos = s.pos;
s.editor.press(e, pos, id, shift, alt)
}) {
self.block_editor_outcome(outcome);
}
return;
}
Item::Slider { id, x, y, w, .. } if ui::hovered(cursor, *x, *y, *w, 20.0) => {
let (id, x, w) = (*id, *x, *w);
let value = ui::slider_value(x, w, cursor.0);
if let Some(mut screen) = self.block_editor.take() {
screen.dragging = Some((id, x, w));
screen.editor.slide(id, value);
self.block_editor = Some(screen);
}
return;
}
Item::Edit { index, x, y, w } if ui::hovered(cursor, *x, *y, *w, 20.0) => {
let index = *index;
if let Some(mut screen) = self.block_editor.take() {
screen.focus = Some(index);
if let Some(edit) = screen.editor.edits().get_mut(index) {
(edit.x, edit.width) = (*x, *w);
atlas_click(self, edit);
}
self.block_editor = Some(screen);
}
return;
}
_ => {}
}
}
}
/// The cursor moved with a slider held (`AbstractSliderButton.onDrag`).
pub(super) fn block_editor_drag(&mut self) {
let x = self.cursor.0;
if let Some(screen) = self.block_editor.as_mut()
&& let Some((id, sx, sw)) = screen.dragging
{
screen.editor.slide(id, ui::slider_value(sx, sw, x));
}
}
/// The left button released: a held slider lets go.
pub(super) fn block_editor_release(&mut self) {
if let Some(screen) = self.block_editor.as_mut() {
screen.dragging = None;
}
}
pub(super) fn block_editor_key(&mut self, event: &KeyEvent) {
if event.state != ElementState::Pressed {
return;
}
let PhysicalKey::Code(key) = event.physical_key else {
return;
};
let shortcut = self.edit_shortcut(event);
self.block_editor_key_code(key, shortcut, event.text.as_deref());
}
/// A key pressed on an edit screen: Enter is Done, Escape cancels, Tab
/// moves between the boxes, the rest edits the focused one.
pub(super) fn block_editor_key_code(
&mut self,
key: KeyCode,
shortcut: Option<horizon_client::clipboard::EditShortcut>,
text: Option<&str>,
) {
let ctrl = self.keys.contains(&KeyCode::ControlLeft);
let shift = self.keys.contains(&KeyCode::ShiftLeft);
let types_text = self.types_text();
let size = self
.renderer
.as_ref()
.map_or([1, 1], |r| [r.config.width, r.config.height]);
let b = HudBatch::new(size);
match key {
KeyCode::Escape => {
let outcome = self
.with_block_editor(|s, e| {
s.editor.cancel(e);
Outcome::Cancel
})
.unwrap_or(Outcome::Cancel);
self.block_editor_outcome(outcome);
}
KeyCode::Enter | KeyCode::NumpadEnter => {
let confirms = self
.block_editor
.as_ref()
.is_some_and(|s| s.editor.enter_confirms());
if !confirms {
return;
}
if let Some(outcome) = self.with_block_editor(|s, e| {
let pos = s.pos;
s.editor.done(e, pos)
}) {
self.block_editor_outcome(outcome);
}
}
KeyCode::Tab => {
self.with_block_editor(|s, e| {
// The boxes on screen, in the order they were added.
let shown: Vec<usize> = s
.editor
.items(e, b.width, b.height)
.iter()
.filter_map(|i| match i {
Item::Edit { index, .. } => Some(*index),
_ => None,
})
.collect();
if shown.is_empty() {
return;
}
let at = s.focus.and_then(|f| shown.iter().position(|&i| i == f));
let next = match at {
None => 0,
Some(p) if shift => (p + shown.len() - 1) % shown.len(),
Some(p) => (p + 1) % shown.len(),
};
s.focus = Some(shown[next]);
});
}
_ => {
let Some(mut screen) = self.block_editor.take() else {
return;
};
if let Some(focus) = screen.focus
&& let Some(edit) = screen.editor.edits().get_mut(focus)
{
if let Some(shortcut) = shortcut {
edit.shortcut(shortcut, &mut self.clipboard);
} else if !edit.key(key, ctrl, shift) && types_text {
for c in text.unwrap_or("").chars() {
self.block_editor_type_into(&mut screen, focus, c);
}
}
}
self.block_editor = Some(screen);
self.block_editor_after_input();
}
}
}
/// What typing changed: the boxes whose text differs from before tell
/// the editor, which may have packets to send.
fn block_editor_after_input(&mut self) {
let Some(mut screen) = self.block_editor.take() else {
return;
};
let pos = screen.pos;
let now: Vec<String> = screen
.editor
.edits()
.iter()
.map(|e| e.value.clone())
.collect();
let mut packets = Vec::new();
for (i, value) in now.iter().enumerate() {
if screen.values.get(i) != Some(value) {
packets.extend(screen.editor.edited(i, pos));
}
}
screen.values = now;
self.block_editor = Some(screen);
for packet in packets {
self.send(packet);
}
}
/// A status for the open screen.
pub(super) fn block_editor_status(&mut self, status: Text, size: Option<[i32; 3]>) {
let Some(mut screen) = self.block_editor.take() else {
return;
};
if let Some(entity) = self.world.block_entities.get(&screen.pos) {
screen.editor.status(entity, status, size);
}
screen.values = screen
.editor
.edits()
.iter()
.map(|e| e.value.clone())
.collect();
self.block_editor = Some(screen);
}
fn block_editor_type_into(&mut self, screen: &mut BlockEditScreen, focus: usize, c: char) {
if c.is_control() {
return;
}
let Some(edit) = screen.editor.edits().get(focus) else {
return;
};
let (value, cursor) = (edit.value.clone(), edit.cursor);
if !screen.editor.accepts(focus, &value, cursor, c) {
return;
}
if let Some(edit) = screen.editor.edits().get_mut(focus) {
edit.insert_text(&c.to_string());
}
}
/// Characters typed (`type TEXT` from the agent control): into the box
/// with the focus.
pub(super) fn block_editor_type(&mut self, typed: &str) {
let Some(mut screen) = self.block_editor.take() else {
return;
};
if let Some(focus) = screen.focus {
for c in typed.chars() {
self.block_editor_type_into(&mut screen, focus, c);
}
}
self.block_editor = Some(screen);
self.block_editor_after_input();
}
}
/// `Screen.isValidCharacterForName`: the characters an identifier takes,
/// with one `:` before any `/`, and `/` only after it.
pub(super) fn valid_character_for_name(current: &str, c: char, cursor: usize) -> bool {
let colon = current.chars().position(|x| x == ':');
let slash = current.chars().position(|x| x == '/');
match c {
':' => slash.is_none_or(|s| cursor <= s) && colon.is_none(),
'/' => colon.map_or(-1, |c| c as i64) < cursor as i64,
'_' | '-' | '.' | 'a'..='z' | '0'..='9' => true,
_ => false,
}
}
/// `CycleButton.cycleValue`: the value `delta` steps on in `values`, wrapping;
/// a current value that is not in the list steps from -1.
pub(super) fn cycle<T: Copy + PartialEq>(values: &[T], current: T, delta: i32) -> T {
let at = values
.iter()
.position(|v| *v == current)
.map_or(-1, |i| i as i32);
let len = values.len() as i32;
values[(at + delta).rem_euclid(len) as usize]
}
/// `CommonComponents.OPTION_ON` / `OPTION_OFF`.
pub(super) fn on_off(on: bool) -> Text {
Text::translate(if on { "options.on" } else { "options.off" }, &[])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn name_characters_follow_screen_is_valid_character_for_name() {
assert!(valid_character_for_name("", 'a', 0));
assert!(valid_character_for_name("a", '_', 1));
assert!(!valid_character_for_name("a", 'A', 1));
assert!(!valid_character_for_name("a", ' ', 1));
// One colon, not after a slash.
assert!(valid_character_for_name("ns", ':', 2));
assert!(!valid_character_for_name("ns:x", ':', 4));
assert!(!valid_character_for_name("a/b", ':', 3));
assert!(valid_character_for_name("a/b", ':', 1));
// A slash only after the colon.
assert!(valid_character_for_name("ns:a", '/', 4));
assert!(!valid_character_for_name("ns:a", '/', 1));
assert!(valid_character_for_name("a", '/', 1));
}
#[test]
fn cycling_wraps_and_steps_from_minus_one_off_the_list() {
assert_eq!(cycle(&[1, 2, 3], 3, 1), 1);
assert_eq!(cycle(&[1, 2, 3], 1, -1), 3);
assert_eq!(cycle(&[1, 2, 3], 9, 1), 1);
assert_eq!(cycle(&[1, 2, 3], 9, -1), 2);
}
}
@@ -23,6 +23,8 @@ impl Game<'_> {
if !self.world.block_entities.has_states() {
let states = self.ticking_states();
self.world.block_entities.set_states(states);
let containers = self.native_containers();
self.world.block_entities.set_mod_containers(containers);
}
// Only lingering cooldowns are dropped; a hopper that ticks checks
// its own block. Once a second is soon enough for the rest.
@@ -64,6 +66,7 @@ impl Game<'_> {
Some(BlockEntity::Brewing(_)) => self.tick_brewing_stand(pos)?,
Some(BlockEntity::DaylightDetector) => self.tick_daylight_detector(pos),
Some(BlockEntity::Campfire(_)) => self.tick_campfire(pos)?,
Some(BlockEntity::Mod(_)) => self.tick_mod_block_entity(pos),
Some(BlockEntity::PotentSulfur(_)) => {
self.tick_potent_sulfur(pos)?;
// A window that simulates the world itself is its own
@@ -129,6 +132,7 @@ impl Game<'_> {
if record { Ticking::Yes } else { Ticking::Idle }
}
n if FurnaceKind::from_block(n).is_some() => Ticking::Yes,
n if !n.starts_with("minecraft:") && self.native_ticks(n) => Ticking::Yes,
_ => Ticking::No,
}
})
@@ -170,7 +174,7 @@ impl Game<'_> {
{
s.properties.insert("lit".into(), lit.to_string());
if let Ok(id) = self.pack.assets.registry.resolve(&s) {
self.world.set(pos.0, pos.1, pos.2, id)?;
self.level_set([pos.0, pos.1, pos.2], id)?;
}
}
Ok(())
+2 -2
View File
@@ -1,7 +1,7 @@
//! Control-socket block commands for scripted tests: `setblock`, `fill`,
//! `getblock` and `container` (list or put items in a block entity).
use super::Game;
use horizon_client::{Result, containers::BlockEntity, items::ItemStack};
use horizon_client::{Result, items::ItemStack};
use horizon_world::{chunk::BlockState, registry::BlockStateId};
use serde_json::{Value, json};
use std::collections::BTreeMap;
@@ -50,7 +50,7 @@ impl Game<'_> {
let name = self.res.facts().get(state).name.clone();
self.world.block_entities.remove(&(pos[0], pos[1], pos[2]));
self.level_set(pos, state)?;
if let Some(entity) = BlockEntity::for_block(&name) {
if let Some(entity) = self.new_block_entity(&name) {
self.world
.block_entities
.insert((pos[0], pos[1], pos[2]), entity);
@@ -0,0 +1,544 @@
//! Leads on boats and rafts (`AbstractBoat implements Leashable`): putting a
//! lead on one, taking it off, `Leashable.tickLeash` (the pull of the rope,
//! the lead snapping past twelve blocks), saving who holds it, and dropping
//! the lead when the boat breaks. The rope is drawn by `leads.rs`
//! (`LeashFeatureRenderer`, four ropes from a boat to a happy ghast).
//! A boat is also a holder (`Holder::Boat`): `Entity.interact`'s sneak click
//! ties the player's mobs and boats to it.
//!
//! The elastic pull of a ridden boat: `Leashable.tickLeash` runs on the
//! server only, and the server's boat that its rider's client drives takes
//! that client's position back (`handleMoveVehicle`), so vanilla never moves
//! it by the rope either. Here the pull lands on the server's copy and the
//! rider's client overwrites it the same way (`tick_boats`).
use super::{Game, boats::Boat};
use horizon_client::mobs::leash::{self, Holder, HolderState, Pending};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::Tag;
/// `AbstractBoat`'s `getBbWidth` and `getBbHeight`.
const WIDTH: f32 = 1.375;
const HEIGHT: f32 = 0.5625;
/// Where the other end of a boat's lead is.
enum Held {
/// The holder is gone: the lead comes off (`canInteractWithLevel`).
Gone,
/// In another level or not loaded: nothing happens (`level() ==`).
Elsewhere,
/// Its state, and whether it holds a quad lead.
At(HolderState, bool),
}
fn center(b: &Boat) -> Vec3 {
Vec3::new(
b.position.x,
b.position.y + f64::from(HEIGHT) / 2.0,
b.position.z,
)
}
/// A boat as a lead's holder (`HolderState`), with its id.
pub(super) fn boat_state(b: &Boat) -> (u32, HolderState) {
(
b.id,
HolderState {
position: b.position,
center: center(b),
width: f64::from(WIDTH),
height: f64::from(HEIGHT),
yaw: b.yaw,
velocity: b.velocity,
},
)
}
fn distance(a: Vec3, b: Vec3) -> f64 {
((a.x - b.x).powi(2) + (a.y - b.y).powi(2) + (a.z - b.z).powi(2)).sqrt()
}
impl Game<'_> {
/// The player whose lead this is: the lead's `leash_player`, else the
/// local player.
fn boat_leash_player(&self, b: &Boat) -> Option<[i32; 4]> {
b.leash_player.or(self.level.player_uuid)
}
fn boat_holder(&self, holder: Holder, player: Option<[i32; 4]>) -> Held {
match holder {
Holder::Player => {
let want = player.or(self.level.player_uuid);
// A server's guest (single player's window included) first.
let guest = self.lan.as_ref().and_then(|host| {
let g = host.hub.guests.iter().find(|g| {
Some(horizon_client::lan::playerdata::uuid_ints(g.uuid)) == want
})?;
Some((host, g))
});
if let Some((host, g)) = guest {
if !host.hub.here(g) || !g.loaded {
return Held::Elsewhere;
}
if g.dead || g.spectator() {
return Held::Gone;
}
let bounds = g.bounds();
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let moved = [0, 1, 2].map(|k| g.position[k] - g.previous_position[k]);
return Held::At(
HolderState {
position: Vec3::new(g.position[0], g.position[1], g.position[2]),
center: Vec3::new(
(lo.x + hi.x) / 2.0,
(lo.y + hi.y) / 2.0,
(lo.z + hi.z) / 2.0,
),
width: hi.x - lo.x,
height: hi.y - lo.y,
yaw: g.yaw,
velocity: Vec3::new(moved[0], moved[1], moved[2]),
},
false,
);
}
if want != self.level.player_uuid {
// A player who left: Entity.remove drops what they led.
return Held::Gone;
}
let Some(v) = self.player_view() else {
return Held::Elsewhere;
};
if !v.alive || v.spectator {
return Held::Gone;
}
let lo = v.bounds.min_position();
let size = v.bounds.max_position().subtract(lo);
Held::At(
HolderState {
position: v.position,
center: Vec3::new(v.position.x, lo.y + size.y / 2.0, v.position.z),
width: size.x,
height: size.y,
yaw: v.yaw,
velocity: v.velocity,
},
false,
)
}
Holder::Mob(id) => {
let Some(m) = self.mobs.iter().find(|m| m.id == id && m.alive()) else {
return Held::Gone;
};
let (lo, hi) = (m.bounds().min_position(), m.bounds().max_position());
Held::At(
HolderState {
position: m.position,
center: Vec3::new(
(lo.x + hi.x) / 2.0,
(lo.y + hi.y) / 2.0,
(lo.z + hi.z) / 2.0,
),
width: hi.x - lo.x,
height: hi.y - lo.y,
yaw: m.yaw,
velocity: m.velocity,
},
leash::quad_holder_offsets(m).is_some(),
)
}
Holder::Boat(id) => match self.boats.iter().find(|o| o.id == id) {
// supportQuadLeashAsHolder is the ghasts' alone.
Some(o) => Held::At(boat_state(o).1, false),
None => Held::Gone,
},
Holder::Knot(pos) => {
let name = self
.res
.facts()
.get(self.world.block(pos[0], pos[1], pos[2]));
let fence = self
.res
.items()
.block_tags
.get("minecraft:fences")
.is_some_and(|s| s.contains(&*name.name));
if fence {
Held::At(leash::knot_state(pos), false)
} else {
Held::Gone
}
}
}
}
/// `Leashable.dropLeash` / `removeLeash` on a boat: the lead falls as an
/// item (`drop`). The change reaches clients through the entity tracker.
fn release_boat_leash(&mut self, b: &mut Boat, drop: bool) {
if b.leash.take().is_none() {
return;
}
b.leash_player = None;
if drop {
self.drop_lead_item([b.position.x, b.position.y, b.position.z]);
}
}
/// `AbstractBoat.remove`: a boat that is destroyed drops its lead.
pub(super) fn boat_removed(&mut self, b: &Boat) {
let mut b = b.clone();
let pending = b.leash_pending.take().is_some();
if b.leash.is_some() || pending {
let at = [b.position.x, b.position.y, b.position.z];
b.leash = None;
self.drop_lead_item(at);
}
}
/// `Leashable.tickLeash` for a boat, at the end of `Entity.baseTick`.
pub(super) fn tick_boat_leash(&mut self, b: &mut Boat) {
b.age += 1;
if let Some(pending) = b.leash_pending {
self.restore_boat_leash(b, pending);
}
let Some(holder) = b.leash else {
return;
};
let (state, quad) = match self.boat_holder(holder, b.leash_player) {
Held::Elsewhere => return,
// !leashHolder.canInteractWithLevel(): dropLeash with entity_drops
// on, else removeLeash.
Held::Gone => {
let drops = self.ui.rules.bool("entity_drops");
self.release_boat_leash(b, drops);
return;
}
Held::At(state, quad) => (state, quad),
};
// Leashable.whenLeashedTo: `leashHolder.notifyLeashHolder(this)`; a
// boat supports a quad lead.
if let Holder::Mob(id) = holder
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
horizon_client::mobs::happy_ghast::notify_leash_holder(m);
}
let away = distance(state.center, center(b));
if away > leash::SNAP {
let p = state.position;
self.lead_sound("break", [p.x, p.y, p.z]);
self.release_boat_leash(b, true);
return;
}
if away > leash::ELASTIC - state.width - f64::from(WIDTH)
&& let Some((force, torque)) = leash::elastic_pull(
b.position,
b.yaw,
(f64::from(WIDTH), f64::from(HEIGHT)),
&state,
b.velocity,
quad,
)
{
b.leash_spin += 10.0 * torque;
let relative = Vec3::new(
state.velocity.x - b.velocity.x,
state.velocity.y - b.velocity.y,
state.velocity.z - b.velocity.z,
);
b.velocity = Vec3::new(
b.velocity.x + force[0] * 0.8 + relative.x * 0.11,
b.velocity.y + force[1] * 0.2 + relative.y * 0.11,
b.velocity.z + force[2] * 0.8 + relative.z * 0.11,
);
}
b.yaw = (f64::from(b.yaw) - b.leash_spin) as f32;
b.leash_spin *= f64::from(self.boat_angular_friction(b));
}
/// `Leashable.angularFriction`.
fn boat_angular_friction(&self, b: &Boat) -> f32 {
if b.on_ground {
let (x, z) = (b.position.x.floor() as i32, b.position.z.floor() as i32);
let y = (b.position.y - 0.500_001).floor() as i32;
self.res.facts().get(self.world.block(x, y, z)).friction * 0.91
} else if b.in_liquid() {
0.8
} else {
0.91
}
}
/// `Leashable.restoreLeashFromSave`: a holder read from a save becomes
/// real once its entity is found; one that never shows up in 100 ticks
/// drops the lead.
fn restore_boat_leash(&mut self, b: &mut Boat, pending: Pending) {
let found = match pending {
Pending::Knot(p) => Some((Holder::Knot(p), None)),
Pending::Uuid(u) => {
let guest = self.lan.as_ref().is_some_and(|h| {
h.hub
.guests
.iter()
.any(|g| horizon_client::lan::playerdata::uuid_ints(g.uuid) == u)
});
if guest || (self.level.player_uuid == Some(u) && self.player.is_some()) {
Some((Holder::Player, Some(u)))
} else {
self.mob_by_uuid(u)
.map(|id| (Holder::Mob(id), None))
.or_else(|| self.boat_by_uuid(u).map(|id| (Holder::Boat(id), None)))
}
}
};
if let Some((holder, player)) = found {
b.leash = Some(holder);
b.leash_player = player;
b.leash_pending = None;
} else if b.age > 100 {
b.leash_pending = None;
self.drop_lead_item([b.position.x, b.position.y, b.position.z]);
}
}
/// The `leash` tag of a boat about to be saved.
pub(super) fn write_boat_leash(&mut self, tag: &mut Tag, index: usize) {
let Tag::Compound(map) = tag else { return };
map.remove(&"leash".into());
let b = &self.boats[index];
let (holder, pending, player) = (b.leash, b.leash_pending, self.boat_leash_player(b));
let value = match (holder, pending) {
(Some(Holder::Mob(id)), _) => {
let uuid = self.mob_uuid(id);
leash::to_nbt(Holder::Mob(id), Some(uuid))
}
(Some(Holder::Boat(id)), _) => {
let uuid = self.uuid_for(id);
leash::to_nbt(Holder::Boat(id), Some(uuid))
}
(Some(h), _) => leash::to_nbt(h, player),
(None, Some(Pending::Knot(p))) => leash::to_nbt(Holder::Knot(p), None),
(None, Some(Pending::Uuid(u))) => leash::to_nbt(Holder::Player, Some(u)),
_ => None,
};
if let Some(v) = value {
map.insert("leash".into(), v);
}
}
/// Whether the player acting holds this boat's lead.
fn boat_led_by_player(&mut self, i: usize) -> bool {
let me = self.acting_player_uuid();
let b = &self.boats[i];
b.leash == Some(Holder::Player) && self.boat_leash_player(b) == Some(me)
}
/// The boat with this UUID (`ServerLevel.getEntity`).
pub(super) fn boat_by_uuid(&self, uuid: [i32; 4]) -> Option<u32> {
self.persist
.uuids
.iter()
.find(|(id, u)| **u == uuid && self.boats.iter().any(|b| b.id == **id))
.map(|(id, _)| *id)
}
/// The boats on the acting player's lead within 32 blocks of a point
/// (`Leashable.leashableInArea`).
pub(super) fn player_led_boats(&mut self, around: glam::DVec3) -> Vec<usize> {
(0..self.boats.len())
.filter(|&i| {
let c = center(&self.boats[i]);
self.boat_led_by_player(i)
&& (c.x - around.x).abs() <= 16.0 + f64::from(WIDTH) / 2.0
&& (c.y - around.y).abs() <= 16.0
&& (c.z - around.z).abs() <= 16.0 + f64::from(WIDTH) / 2.0
})
.collect()
}
/// The boats whose lead hangs on `holder`.
pub(super) fn boats_on(&self, holder: Holder) -> Vec<usize> {
(0..self.boats.len())
.filter(|&i| self.boats[i].leash == Some(holder))
.collect()
}
/// `Leashable.canHaveALeashAttachedTo` for the boat at `i` and an entity
/// whose box centre is `at`.
fn boat_can_attach(&self, i: usize, at: glam::DVec3) -> bool {
let c = center(&self.boats[i]);
distance(Vec3::new(at.x, at.y, at.z), c) <= leash::SNAP
}
/// The acting player's boats on a lead go to `to` (`Entity.interact` of a
/// sneak click on a mob, `LeadItem.bindPlayerMobs`, the knot's
/// `interact`), the ones in reach of `at`. Whether any did.
pub(super) fn tie_player_boats(&mut self, at: glam::DVec3, to: Holder) -> bool {
let mut any = false;
for i in self.player_led_boats(at) {
// Leashable.setLeashedTo: `this != holder`.
if to == Holder::Boat(self.boats[i].id) {
continue;
}
if self.boat_can_attach(i, at) {
self.boats[i].leash = Some(to);
self.boats[i].leash_player = None;
any = true;
}
}
any
}
/// `LeashFenceKnotEntity.interact`: the boats on the knot go back on the
/// acting player's lead. Whether any did.
pub(super) fn take_knot_boats(&mut self, knot: [i32; 3]) -> bool {
let me = self.acting_player_uuid();
let player = self.player_center();
let mut any = false;
for i in self.boats_on(Holder::Knot(knot)) {
if self.boat_can_attach(i, player) {
self.boats[i].leash = Some(Holder::Player);
self.boats[i].leash_player = Some(me);
any = true;
}
}
any
}
/// `Leashable.dropLeash` for each boat in `list`.
pub(super) fn drop_boat_leashes(&mut self, list: &[usize]) {
for &i in list {
let mut b = self.boats[i].clone();
self.release_boat_leash(&mut b, true);
self.boats[i] = b;
}
}
/// `Entity.interact`'s lead part for the boat at `i`; whether it took
/// the click.
pub(super) fn interact_boat_leash(&mut self, i: usize) -> bool {
let item = self.held().map(|s| s.item.to_string());
let c = center(&self.boats[i]);
let at = [c.x, c.y, c.z];
let id = self.boats[i].id;
// The sneak branch: the player's mobs and boats on leads are re-tied
// to this boat.
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
if sneaking
&& self.tie_player_leads(glam::DVec3::new(c.x, c.y, c.z), Holder::Boat(id), None)
{
self.lead_game_event("minecraft:entity_action", at);
self.lead_sound("tied", at);
return true;
}
// Entity.shearOffAllLeashConnections: its own lead and every one
// hanging on it.
if item.as_deref() == Some("minecraft:shears") {
let held_mobs: Vec<u32> = self
.mobs
.iter()
.filter(|m| m.extra.leash == Some(Holder::Boat(id)))
.map(|m| m.id)
.collect();
let mut held_boats = self.boats_on(Holder::Boat(id));
if self.boats[i].leash.is_some() {
held_boats.push(i);
}
if !held_mobs.is_empty() || !held_boats.is_empty() {
for m in held_mobs {
self.break_leash(m, true, false);
}
self.drop_boat_leashes(&held_boats);
self.lead_game_event("minecraft:shear", at);
self.audio
.play("minecraft:item.shears.snip", Some(at), 1.0, 1.0);
self.damage_held_tool();
return true;
}
}
if self.boat_led_by_player(i) {
// hasInfiniteMaterials: removeLeash, else dropLeash.
let drop = !self.creative;
let mut b = self.boats[i].clone();
self.release_boat_leash(&mut b, drop);
self.boats[i] = b;
self.lead_game_event("minecraft:entity_interact", at);
self.lead_sound("untied", at);
return true;
}
let taken_by_player = self.boats[i].leash == Some(Holder::Player);
if item.as_deref() == Some("minecraft:lead") && !taken_by_player {
let player = self.player_center();
if self.boat_can_attach(i, player) {
let mut b = self.boats[i].clone();
self.release_boat_leash(&mut b, true);
b.leash = Some(Holder::Player);
b.leash_player = Some(self.acting_player_uuid());
self.boats[i] = b;
self.lead_sound("tied", at);
// The lead is always used up.
if let Some(stack) = self.held_mut().as_mut() {
stack.count -= 1;
if stack.count == 0 {
*self.held_mut() = None;
}
}
return true;
}
}
false
}
/// The leads on boats that hang on a player or a mob, for the entity
/// tracker: the holder's entity id (`ClientboundSetEntityLinkPacket`).
pub(super) fn boat_leash_link(
&self,
b: &Boat,
host_entity: Option<i32>,
guests: &[(u128, i32)],
) -> Option<i32> {
match b.leash? {
Holder::Player => {
let uuid = self.boat_leash_player(b);
match guests
.iter()
.find(|(u, _)| Some(horizon_client::lan::playerdata::uuid_ints(*u)) == uuid)
{
Some(&(_, id)) => Some(id),
None if uuid == self.level.player_uuid => host_entity,
None => None,
}
}
Holder::Mob(id) | Holder::Boat(id) => Some(id as i32),
Holder::Knot(p) => self.knot_ids.get(&p).map(|&id| id as i32),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_boat_is_centred_half_its_height_up() {
let b = Boat::new(1, "oak".into(), Vec3::new(1.0, 64.0, 2.0), 0.0);
let c = center(&b);
assert_eq!((c.x, c.z), (1.0, 2.0));
assert!((c.y - 64.28125).abs() < 1e-9);
}
#[test]
fn a_boat_holds_a_lead_from_its_centre() {
let mut b = Boat::new(7, "oak".into(), Vec3::new(1.0, 64.0, 2.0), 90.0);
b.velocity = Vec3::new(0.1, 0.0, 0.0);
let (id, s) = boat_state(&b);
assert_eq!(id, 7);
assert!((s.center.y - 64.28125).abs() < 1e-9);
assert!((s.width - 1.375).abs() < 1e-9 && (s.height - 0.5625).abs() < 1e-9);
assert_eq!((s.yaw, s.velocity.x), (90.0, 0.1));
}
#[test]
fn a_boat_holder_is_an_entity_not_a_player() {
assert_eq!(Holder::Boat(7).who(), None);
assert_ne!(Holder::Boat(7), Holder::Mob(7));
}
#[test]
fn distances_are_euclidean() {
let d = distance(Vec3::new(0.0, 0.0, 0.0), Vec3::new(3.0, 4.0, 0.0));
assert!((d - 5.0).abs() < 1e-12);
}
}
+48 -1
View File
@@ -8,7 +8,12 @@ use horizon_client::{
collisions::{EntityCollisions, SceneCollisions},
items::ItemStack,
mesher::Vertex,
mobs::{Part, anim::Poses, riders},
mobs::{
Part,
anim::Poses,
leash::{Holder, Pending},
riders,
},
};
use horizon_core::{
geometry::{Aabb, Vec3},
@@ -51,6 +56,16 @@ pub(super) struct Boat {
/// `ChestBoat`: a chest with 27 slots in the back seat.
pub chest: bool,
pub items: Vec<Option<ItemStack>>,
/// `AbstractBoat.leashData`: who holds its lead (`boat_leads.rs`).
pub leash: Option<Holder>,
/// The player a `Holder::Player` lead is tied to (their UUID).
pub leash_player: Option<[i32; 4]>,
/// A holder read from a save that is not in the world yet.
pub leash_pending: Option<Pending>,
/// `LeashData.angularMomentum`.
pub leash_spin: f64,
/// `Entity.tickCount`, for the 100 ticks a saved holder is waited for.
pub age: i32,
}
/// `ChestBoat.CONTAINER_SIZE`.
pub(super) const CHEST_SLOTS: usize = 27;
@@ -81,6 +96,11 @@ impl Boat {
on_ground: false,
chest: false,
items: Vec::new(),
leash: None,
leash_player: None,
leash_pending: None,
leash_spin: 0.0,
age: 0,
}
}
/// A chest boat or chest raft.
@@ -101,6 +121,13 @@ impl Boat {
pub fn under_water(&self) -> bool {
matches!(self.status, Status::UnderWater | Status::UnderFlowingWater)
}
/// `Entity.isInLiquid`, for `Leashable.angularFriction`.
pub fn in_liquid(&self) -> bool {
matches!(
self.status,
Status::InWater | Status::UnderWater | Status::UnderFlowingWater
)
}
/// `getMaxPassengers`: two, one beside a chest.
pub fn max_passengers(&self) -> usize {
if self.chest { 1 } else { 2 }
@@ -349,6 +376,11 @@ impl Game<'_> {
pub(super) fn tick_boat(&mut self, mut b: Boat, controlled: bool) -> Boat {
b.previous = b.position;
b.previous_yaw = b.yaw;
// Entity.baseTick ends with Leashable.tickLeash, on a server only
// (a ridden boat's lead ticks in `tick_boats`).
if !controlled && self.remote.is_none() {
self.tick_boat_leash(&mut b);
}
b.old_status = b.status;
b.status = self.boat_status(&mut b);
b.out_of_control = if matches!(b.status, Status::UnderWater | Status::UnderFlowingWater) {
@@ -513,6 +545,12 @@ impl Game<'_> {
|| driven.contains(&self.boats[i].id)
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
{
// Its rider's client moves it, but the lead still ticks.
if ticking(p.x.floor() as i32, p.z.floor() as i32) && self.remote.is_none() {
let mut b = self.boats[i].clone();
self.tick_boat_leash(&mut b);
self.boats[i] = b;
}
continue;
}
let b = self.boats[i].clone();
@@ -790,6 +828,11 @@ impl Game<'_> {
if !self.before_block(d) {
return false;
}
// AbstractBoat.interact starts with Entity.interact: the lead.
if self.interact_boat_leash(i) {
self.swing_arm();
return true;
}
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
if self.boats[i].chest && (sneaking || self.riding.is_some() || !self.boat_has_room(i)) {
let b = &self.boats[i];
@@ -815,6 +858,8 @@ impl Game<'_> {
}
self.riding = Some(self.boats[i].id);
self.yaw = self.boats[i].yaw;
// ServerPlayer.startRiding.
self.trigger_player("minecraft:started_riding");
true
}
/// `VehicleEntity.hurtServer` from the player's attack.
@@ -847,6 +892,8 @@ impl Game<'_> {
self.eject_boat_mobs(i, true);
let b = self.boats.remove(i);
let at = [b.position.x, b.position.y, b.position.z];
// AbstractBoat.remove: a destroyed boat drops its lead.
self.boat_removed(&b);
if !creative && self.ui.rules.bool("entity_drops") {
// ChestBoat.destroy: chestVehicleDestroyed spills the contents.
for stack in b.items.iter().flatten() {
+19 -3
View File
@@ -833,6 +833,23 @@ impl Game<'_> {
self.lectern_menu_done(key, &menu);
self.follow_lectern();
}
/// LecternScreen's Take Book: the menu's button 3. The book leaves the
/// lectern, so the screen closes (the server closes the menu once the
/// book is gone; a server's client closes at once rather than wait).
fn take_lectern_book(&mut self, key: (i32, i32, i32)) {
self.lectern_button(key, 3);
if self.may_build()
&& matches!(
self.book,
Some(BookScreen::View {
lectern: Some(_),
..
})
)
{
self.close_book();
}
}
/// The `book` control command: a button of the open book or lectern
/// screen (the page arrows, Take Book, Done), then its page.
pub(super) fn book_control(
@@ -849,7 +866,7 @@ impl Game<'_> {
Some("next") => self.turn_page(true),
Some("back") => self.turn_page(false),
Some("take") => match lectern {
Some(key) => self.lectern_button(key, 3),
Some(key) => self.take_lectern_book(key),
None => return Err("not a lectern".into()),
},
// The book and quill's buttons: Sign, then Sign and Close; Done
@@ -1064,8 +1081,7 @@ impl Game<'_> {
self.close_book();
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
click(self);
// LecternScreen's Take Book: the menu's button 3.
self.lectern_button(key, 3);
self.take_lectern_book(key);
}
} else if inside((w - 200.0) / 2.0, 196.0, 200.0, 20.0) {
click(self);
+95 -3
View File
@@ -6,7 +6,7 @@
//! screen draws the preview with its error and lock sprites.
use super::{
Game,
maps::{MapData, map_id},
maps::{MapData, map_id, shown_on_frame},
};
use horizon_client::{hud::HudBatch, items::ItemStack, menu::Maps};
use std::{
@@ -116,12 +116,13 @@ impl Game<'_> {
);
};
let preview = |batch: &mut HudBatch, game: &mut Self, x: f32, y: f32, scale: f32| {
if let Some(rect) = id
if let Some((id, rect)) = id
.filter(|_| state.is_some())
.and_then(|id| game.map_texture(id))
.and_then(|id| game.map_texture(id).map(|rect| (id, rect)))
{
let size = 128.0 * scale;
batch.rect_uv(ox + x, oy + y, size, size, rect, u32::MAX);
game.draw_map_decorations(batch, id, ox + x, oy + y, scale);
}
};
if scaling && !locked {
@@ -142,6 +143,41 @@ impl Game<'_> {
}
}
}
impl Game<'_> {
/// `GuiGraphicsExtractor.map`: map `id`'s decorations over its image at
/// `(x, y)` drawn `scale` GUI pixels per map pixel. Only those with
/// `renderOnFrame` (`MapDecorationType.showOnItemFrame`) are drawn: the
/// screen's `map` skips the player arrows and the two markers.
fn draw_map_decorations(&self, batch: &mut HudBatch, id: i32, x: f32, y: f32, scale: f32) {
let Some(map) = self.maps.data.get(&id) else {
return;
};
for d in map.decorations.iter().filter(|d| shown_on_frame(d.sprite)) {
let Some(s) = self.gui.atlas.sprites.get(d.sprite).copied() else {
continue;
};
batch.quad(
decoration_corners(d, x, y, scale),
[[s.u0, s.v0], [s.u1, s.v0], [s.u1, s.v1], [s.u0, s.v1]],
u32::MAX,
);
}
}
}
/// Decoration `d`'s corners on screen, for a map drawn at `(x, y)` with
/// `scale` GUI pixels per map pixel: (-1, 1), (1, 1), (1, -1), (-1, -1)
/// under `MapRenderer.render`'s pose.
fn decoration_corners(d: &super::maps::Decoration, x: f32, y: f32, scale: f32) -> [[f32; 2]; 4] {
let m = glam::Mat4::from_translation(glam::Vec3::new(x, y, 0.0))
* glam::Mat4::from_scale(glam::Vec3::new(scale, scale, 1.0))
* super::maps::decoration_pose(d, 0.0);
[(-1.0, 1.0), (1.0, 1.0), (1.0, -1.0), (-1.0, -1.0)].map(|(cx, cy)| {
let p = m.transform_point3(glam::Vec3::new(cx, cy, 0.0));
[p.x, p.y]
})
}
impl MapData {
/// `MapItemSavedData.scaled`: a fresh map one scale up around the same centre.
pub(super) fn scaled(&self) -> MapData {
@@ -172,3 +208,59 @@ impl MapData {
m
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::maps::Decoration;
fn close(a: [[f32; 2]; 4], b: [[f32; 2]; 4]) {
for (a, b) in a.iter().zip(&b) {
assert!(
(a[0] - b[0]).abs() < 1e-4 && (a[1] - b[1]).abs() < 1e-4,
"{a:?} {b:?}"
);
}
}
#[test]
fn the_preview_skips_what_an_item_frame_skips() {
// GuiGraphicsExtractor.map: `if (decoration.renderOnFrame)`.
assert!(!shown_on_frame("map/decorations/player"));
assert!(!shown_on_frame("map/decorations/red_marker"));
assert!(shown_on_frame("map/decorations/frame"));
assert!(shown_on_frame("map/decorations/red_x"));
}
#[test]
fn map_markers_sit_on_the_preview_like_on_a_held_map() {
let player = |x, y, rot| Decoration {
sprite: "map/decorations/player",
x,
y,
rot,
holder: None,
};
// The centre, facing north: 8 map pixels wide, nudged by (-0.5, 0.5).
close(
decoration_corners(&player(0, 0, 0), 0.0, 0.0, 1.0),
[[59.5, 68.5], [67.5, 68.5], [67.5, 60.5], [59.5, 60.5]],
);
// A quarter turn (rotation 4 of 16) turns the quad about its marker.
close(
decoration_corners(&player(0, 0, 4), 0.0, 0.0, 1.0),
[[59.5, 59.5], [59.5, 67.5], [67.5, 67.5], [67.5, 59.5]],
);
// The preview's offset and scale apply after: the top-left corner
// marker on the 0.45 preview at (71, 17).
close(
decoration_corners(&player(-128, -128, 0), 71.0, 17.0, 0.45),
[
[71.0 - 4.5 * 0.45, 17.0 + 4.5 * 0.45],
[71.0 + 3.5 * 0.45, 17.0 + 4.5 * 0.45],
[71.0 + 3.5 * 0.45, 17.0 - 3.5 * 0.45],
[71.0 - 4.5 * 0.45, 17.0 - 3.5 * 0.45],
],
);
}
}
+50 -10
View File
@@ -83,6 +83,20 @@ pub struct ChatDraft {
pub text: String,
pub command: bool,
}
/// `ChatScreen.ExitReason`: why the chat screen went away.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum ChatExit {
/// Another screen replaced it (death, a container, a disconnect).
Interrupted,
/// The player closed it (Escape).
Intentional,
/// A message was sent.
Done,
}
/// `ChatScreen.shouldDiscardDraft`.
fn should_discard_draft(reason: ChatExit, save_chat_drafts: bool) -> bool {
reason != ChatExit::Interrupted && (reason != ChatExit::Intentional || !save_chat_drafts)
}
/// UI and command state that lives beside the game.
#[derive(Default)]
pub struct UiState {
@@ -560,7 +574,7 @@ impl Game<'_> {
self.right = false;
self.capture(false);
}
pub(super) fn close_chat(&mut self) {
pub(super) fn close_chat(&mut self, reason: ChatExit) {
// InBedChatScreen.onClose: leaving the screen means getting up; the
// screen goes once the player has woken (`tick_in_bed_chat`).
if self.ui.in_bed_chat && self.sleeping.is_some() {
@@ -572,7 +586,7 @@ impl Game<'_> {
.chat_screen
.as_ref()
.map_or_else(String::new, |s| s.value().to_owned());
self.keep_chat_draft(&text);
self.keep_chat_draft(&text, reason);
self.ui.chat_draft_shown = false;
self.ui.chat_screen = None;
self.ui.chat.reset_scroll();
@@ -581,13 +595,16 @@ impl Game<'_> {
self.capture(true);
}
}
/// `ChatScreen.removed`: the text left in the box becomes the draft when
/// Save Chat Drafts is on, else (and when blank) the draft is discarded. A
/// draft still shown unchanged stays as it is.
fn keep_chat_draft(&mut self, text: &str) {
/// `ChatScreen.removed`: the text left in the box becomes the draft unless
/// `shouldDiscardDraft` (closed on purpose without Save Chat Drafts, or
/// sent); blank text discards the draft. A draft still shown unchanged
/// stays as it is.
fn keep_chat_draft(&mut self, text: &str, reason: ChatExit) {
let unchanged =
self.ui.chat_draft_shown && self.ui.chat_draft.as_ref().is_some_and(|d| d.text == text);
if !self.settings.file.bool("saveChatDrafts") || text.trim().is_empty() {
if should_discard_draft(reason, self.settings.file.bool("saveChatDrafts"))
|| text.trim().is_empty()
{
self.ui.chat_draft = None;
} else if !unchanged {
self.ui.chat_draft = Some(ChatDraft {
@@ -600,6 +617,19 @@ impl Game<'_> {
/// woken (`onPlayerWokeUp`) it closes, or turns into the plain chat
/// screen if something was typed.
pub(super) fn tick_in_bed_chat(&mut self) {
// Another screen took over the open chat (death, a container the
// server opened): `ChatScreen.removed` runs with the INTERRUPTED reason.
if self.screen != Screen::Chat && self.ui.chat_screen.is_some() {
let text = self
.ui
.chat_screen
.as_ref()
.map_or_else(String::new, |s| s.value().to_owned());
self.keep_chat_draft(&text, ChatExit::Interrupted);
self.ui.chat_draft_shown = false;
self.ui.chat_screen = None;
self.ui.chat.reset_scroll();
}
if self.screen != Screen::Chat {
self.ui.in_bed_chat = false;
}
@@ -611,7 +641,7 @@ impl Game<'_> {
.chat_screen
.as_ref()
.map_or_else(String::new, |s| s.value().to_owned());
self.close_chat();
self.close_chat(ChatExit::Interrupted);
if !text.is_empty() {
self.open_chat(&text);
}
@@ -734,7 +764,7 @@ impl Game<'_> {
ChatAction::Close => {
// Escape: the box still holds its text, which close_chat keeps as the draft.
self.ui.chat_screen = Some(screen);
self.close_chat();
self.close_chat(ChatExit::Intentional);
}
ChatAction::Submit(message) => {
// InBedChatScreen sends without closing (`closeOnSubmit` is
@@ -746,7 +776,7 @@ impl Game<'_> {
} else {
// The screen is taken here, so close_chat sees no text: a sent
// message discards the draft (DONE).
self.close_chat();
self.close_chat(ChatExit::Done);
}
if !message.is_empty() {
self.handle_chat_input(&message);
@@ -1416,6 +1446,16 @@ const LINK_COPY: usize = 1;
mod tests {
use super::*;
/// `ChatScreen.shouldDiscardDraft`.
#[test]
fn draft_discard_follows_the_exit_reason() {
assert!(!should_discard_draft(ChatExit::Interrupted, false));
assert!(!should_discard_draft(ChatExit::Interrupted, true));
assert!(should_discard_draft(ChatExit::Intentional, false));
assert!(!should_discard_draft(ChatExit::Intentional, true));
assert!(should_discard_draft(ChatExit::Done, true));
}
#[test]
fn resource_ids_are_listed_once_per_root() {
let root = std::env::temp_dir().join(format!("horizon-ids-{}", std::process::id()));
+128
View File
@@ -24,8 +24,51 @@ struct Entry {
/// (`DamageSource.directEntity`: a potion with no thrower, a cloud).
direct: Option<Text>,
}
/// What is stuck in a player's body: `LivingEntity.DATA_ARROW_COUNT_ID` and
/// `DATA_STINGER_COUNT_ID` with the timers that pull them out
/// (`removeArrowTime`, `removeStingerTime`). Kept with the combat record,
/// which is swapped with the body it belongs to.
#[derive(Default)]
pub(super) struct Stuck {
pub(super) arrows: i32,
pub(super) stingers: i32,
remove_arrow_time: i32,
remove_stinger_time: i32,
}
impl Stuck {
/// `AbstractArrow.onHitEntity`: a hit that hurt, from an arrow that
/// does not pierce, leaves it stuck.
pub(super) fn add_arrow(&mut self) {
self.arrows += 1;
}
/// `Bee.doHurtTarget`: a sting that hurt.
pub(super) fn add_stinger(&mut self) {
self.stingers += 1;
}
/// The server's part of `LivingEntity.tick`: one in a count's worth of
/// `20 * (30 - count)` ticks falls out. Whether the counts changed.
pub(super) fn tick(&mut self) -> bool {
let (arrows, stingers) = (self.arrows, self.stingers);
Self::decay(&mut self.arrows, &mut self.remove_arrow_time);
Self::decay(&mut self.stingers, &mut self.remove_stinger_time);
(arrows, stingers) != (self.arrows, self.stingers)
}
fn decay(count: &mut i32, remove_time: &mut i32) {
if *count > 0 {
if *remove_time <= 0 {
*remove_time = 20 * (30 - *count);
}
*remove_time -= 1;
if *remove_time <= 0 {
*count -= 1;
}
}
}
}
#[derive(Default)]
pub(super) struct CombatTracker {
/// The arrows and bee stingers stuck in the body.
pub(super) stuck: Stuck,
entries: Vec<Entry>,
last_damage: i64,
in_combat: bool,
@@ -58,6 +101,9 @@ pub(super) struct CombatTracker {
/// The mob exploding now (`ServerExplosion.source`): its team's friendly
/// fire decides whom the blast may hurt.
pub(super) explosion_source: Option<u32>,
/// The exploding mob as predicates see it (`ServerExplosion.source`,
/// `ServerPlayer.currentExplosionCause`), for a mob already out of the list.
pub(super) explosion_cause: Option<horizon_client::advancements::EntityView>,
/// The exploding mob named itself (`MobEvent::ExplosionSource`), so the
/// blast leaves it out (`getEntities(source, box)`). A source guessed
/// from the mobs near the centre is still hurt: it may be a bystander.
@@ -274,6 +320,48 @@ impl Game<'_> {
damage.in_tag(id, tag) || damage.in_tag(id, &format!("minecraft:{tag}"))
})
}
/// The killing blow as advancement predicates see it (`DamageSource` of the
/// last combat entry): its type and the causing entity. The projectile
/// damage types have the projectile as direct entity, not the shooter.
pub(super) fn combat_killing_blow(&self) -> Option<horizon_client::advancements::DamageView> {
use horizon_client::advancements::{DamageView, EntityView};
let e = self.combat.entries.last()?;
let source = e.attacker.as_ref().and_then(|(id, _)| {
self.mobs
.iter()
.find(|m| m.id == *id)
.map(|m| self.mob_entity(m))
});
let projectile = matches!(
e.damage_type.as_str(),
"minecraft:arrow"
| "minecraft:trident"
| "minecraft:mob_projectile"
| "minecraft:fireball"
| "minecraft:unattributed_fireball"
| "minecraft:wither_skull"
| "minecraft:wind_charge"
| "minecraft:thrown"
| "minecraft:spit"
);
let direct = if projectile {
Some(EntityView {
kind: e.damage_type.clone(),
..Default::default()
})
} else {
source.clone()
};
Some(DamageView {
kind: e.damage_type.clone(),
direct,
source,
is_direct: !projectile,
original: e.damage,
actual: e.damage,
blocked: false,
})
}
/// The type of the mob that dealt the killing blow (`killed_by`).
pub(super) fn combat_killer_kind(&self) -> Option<String> {
let (id, _) = self.combat.entries.last()?.attacker.clone()?;
@@ -527,3 +615,43 @@ fn mob_name(m: &Mob) -> Text {
(None, None) => Text::translate(&format!("entity.{}", m.kind.id.replace(':', ".")), &[]),
}
}
#[cfg(test)]
mod stuck_tests {
use super::Stuck;
/// `LivingEntity.tick`: with `n` stuck the timer starts at
/// `20 * (30 - n)` and one falls out when it runs down.
#[test]
fn arrows_fall_out_after_twenty_ticks_times_the_gap_to_thirty() {
let mut s = Stuck::default();
s.add_arrow();
s.add_arrow();
let wait = 20 * (30 - 2);
for _ in 0..wait - 1 {
assert!(!s.tick());
}
assert!(s.tick());
assert_eq!(s.arrows, 1);
// The timer starts again from the new count.
for _ in 0..20 * (30 - 1) - 1 {
assert!(!s.tick());
}
assert!(s.tick());
assert_eq!(s.arrows, 0);
assert!(!s.tick());
}
#[test]
fn stingers_have_their_own_timer() {
let mut s = Stuck::default();
s.add_stinger();
s.add_arrow();
for _ in 0..20 * 29 - 1 {
assert!(!s.tick());
}
// Both timers run 580 ticks.
assert!(s.tick());
assert_eq!((s.arrows, s.stingers), (0, 0));
}
}
@@ -31,12 +31,15 @@ type Pos = (i32, i32, i32);
/// The open `CommandBlockEditScreen`.
pub(super) struct CommandBlockScreen {
pos: Pos,
/// The command block minecart being edited (`MinecartCommandBlockEditScreen`);
/// `pos` is unused then.
cart: Option<u32>,
/// The command box with its `CommandSuggestions`.
command: ChatScreen,
pub(super) command: ChatScreen,
/// When the command box was focused, for its cursor's blink.
opened: std::time::Instant,
previous: EditBox,
track_output: bool,
pub(super) track_output: bool,
mode: Mode,
conditional: bool,
auto: bool,
@@ -62,23 +65,34 @@ enum Button {
Cancel,
}
/// `AbstractCommandBlockEditScreen.getPreviousY`.
fn previous_y(cart: bool) -> f32 {
if cart { 150.0 } else { 135.0 }
}
/// `EditBox.textColorUneditable`.
const UNEDITABLE: u32 = 0xFF70_7070;
impl CommandBlockScreen {
/// `AbstractCommandBlockEditScreen.init` and `CommandBlockEditScreen`'s
/// buttons, in GUI pixels of a `w`×`h` screen.
fn buttons(w: f32, h: f32) -> [(Button, f32, f32, f32); 6] {
/// A minecart's screen has no mode buttons (`getPreviousY` 150).
fn buttons(w: f32, h: f32, cart: bool) -> Vec<(Button, f32, f32, f32)> {
let mid = (w / 2.0).floor();
let low = (h / 4.0).floor() + 132.0;
[
(Button::Output, mid + 130.0, 135.0, 20.0),
(Button::Mode, mid - 154.0, 165.0, 100.0),
(Button::Conditional, mid - 50.0, 165.0, 100.0),
(Button::Auto, mid + 54.0, 165.0, 100.0),
let mut out = vec![(Button::Output, mid + 130.0, previous_y(cart), 20.0)];
if !cart {
out.extend([
(Button::Mode, mid - 154.0, 165.0, 100.0),
(Button::Conditional, mid - 50.0, 165.0, 100.0),
(Button::Auto, mid + 54.0, 165.0, 100.0),
]);
}
out.extend([
(Button::Done, mid - 154.0, low, 150.0),
(Button::Cancel, mid + 4.0, low, 150.0),
]
]);
out
}
fn label(&self, b: Button) -> Text {
let tr = |key: &str| Text::translate(key, &[]);
@@ -224,6 +238,14 @@ impl Game<'_> {
_ => None,
}
}
/// The `BaseCommandBlock` an edit screen shows: the block entity's or the
/// minecart's.
fn edit_base(&self, s: &CommandBlockScreen) -> Option<&CommandBlock> {
match s.cart {
Some(id) => self.carts.iter().find(|c| c.id == id).map(|c| &c.command),
None => self.command_block(s.pos),
}
}
fn block_state_at(&self, pos: Pos) -> Option<&'static BlockState> {
let registry = self.pack.assets.registry;
state_of(registry, self.world.block(pos.0, pos.1, pos.2))
@@ -246,7 +268,11 @@ impl Game<'_> {
/// edit screen shows refreshes its fields. The mode and conditional live in
/// the block state here (vanilla's `CommandBlockEntity.Mode`).
pub(super) fn command_block_data_arrived(&mut self, pos: Pos) {
if self.command_block_screen.as_ref().map(|s| s.pos) != Some(pos) {
if self
.command_block_screen
.as_ref()
.is_none_or(|s| s.cart.is_some() || s.pos != pos)
{
return;
}
let state = self.block_state_at(pos).map(|s| {
@@ -425,17 +451,11 @@ impl Game<'_> {
dimension: self.dim,
level: None,
block: Some(pos),
cart: None,
};
// performPrefixedCommand: a leading slash is allowed.
let line = command.strip_prefix('/').unwrap_or(&command).to_owned();
let outer = self.cmds.results.replace(Vec::new());
let level = std::mem::replace(&mut self.ui.registries.permission, 2);
self.with_source(source, |g| g.run_command(&line));
self.ui.registries.permission = level;
let results = std::mem::replace(&mut self.cmds.results, outer).unwrap_or_default();
let successes = results.iter().filter(|(success, _)| *success).count();
let successes = self.run_as_game_master(&command, source);
if let Some(c) = command_block_mut(&mut self.world, pos) {
c.success_count = successes as i32;
c.success_count = successes;
}
}
if let Some(c) = command_block_mut(&mut self.world, pos) {
@@ -447,6 +467,19 @@ impl Game<'_> {
}
true
}
/// `Commands.performPrefixedCommand` for a `BaseCommandBlock`: `command`
/// from `source` at game master level, and how many of its results
/// succeeded.
pub(super) fn run_as_game_master(&mut self, command: &str, source: Source) -> i32 {
// performPrefixedCommand: a leading slash is allowed.
let line = command.strip_prefix('/').unwrap_or(command).to_owned();
let outer = self.cmds.results.replace(Vec::new());
let level = std::mem::replace(&mut self.ui.registries.permission, 2);
self.with_source(source, |g| g.run_command(&line));
self.ui.registries.permission = level;
let results = std::mem::replace(&mut self.cmds.results, outer).unwrap_or_default();
results.iter().filter(|(success, _)| *success).count() as i32
}
/// `CloseableCommandBlockSource.sendSystemMessage`: the block's last
/// output, stamped with the time. Nothing when it doesn't track output
/// (`CommandSource.NULL`).
@@ -489,6 +522,18 @@ impl Game<'_> {
let Some(state) = self.block_state_at(pos) else {
return;
};
let mode = Mode::of_block(&state.name).unwrap_or(Mode::Redstone);
self.open_edit_screen(pos, None, block, mode, conditional(state));
}
/// The screen over `block`: a command block's at `pos`, or a minecart's.
pub(super) fn open_edit_screen(
&mut self,
pos: Pos,
cart: Option<u32>,
block: CommandBlock,
mode: Mode,
conditional: bool,
) {
let (w, h) = self.gui_size();
let m = Measure(&self.gui.atlas.font);
let c = Commands {
@@ -497,18 +542,19 @@ impl Game<'_> {
};
let mut command = ChatScreen::command_block(&m, &c, &block.command, w as i32, h as i32);
command.auto_suggestions = self.settings.auto_suggestions;
let mut previous = EditBox::new(0.0, 135.0, 276.0, 20.0);
let mut previous = EditBox::new(0.0, previous_y(cart.is_some()), 276.0, 20.0);
previous.max_length = 32500;
previous.text_color = UNEDITABLE;
previous.focused = false;
let mut screen = CommandBlockScreen {
pos,
cart,
command,
opened: std::time::Instant::now(),
previous,
track_output: block.track_output,
mode: Mode::of_block(&state.name).unwrap_or(Mode::Redstone),
conditional: conditional(state),
mode,
conditional,
auto: block.auto,
};
screen.show_previous(&block);
@@ -525,8 +571,8 @@ impl Game<'_> {
let Some(mut s) = self.command_block_screen.take() else {
return;
};
// AbstractCommandBlockEditScreen.tick: the block is gone.
if self.command_block(s.pos).is_none() {
// AbstractCommandBlockEditScreen.tick: the block (or the cart) is gone.
if self.edit_base(&s).is_none() {
self.close_command_block();
return;
}
@@ -556,11 +602,13 @@ impl Game<'_> {
batch.text_styled(atlas, &label, mid - 149.0, 40.0, grey, true);
if !s.previous.value.is_empty() {
let label = Text::translate("advMode.previousOutput", &[]);
batch.text_styled(atlas, &label, mid - 149.0, 125.0, grey, true);
// 75 + 5 * 9 + 1 + getPreviousY() - 135, then the label's 4.
let y = 125.0 + previous_y(s.cart.is_some()) - 135.0;
batch.text_styled(atlas, &label, mid - 149.0, y, grey, true);
s.previous.x = mid - 150.0;
s.previous.draw(batch, atlas, None);
}
for (b, x, y, bw) in CommandBlockScreen::buttons(w, h) {
for (b, x, y, bw) in CommandBlockScreen::buttons(w, h, s.cart.is_some()) {
let hover = ui::hovered(self.cursor, x, y, bw, 20.0);
ui::button(batch, atlas, x, y, bw, 20.0, &s.label(b), true, hover);
}
@@ -628,7 +676,7 @@ impl Game<'_> {
.as_ref()
.map_or([1, 1], |r| [r.config.width, r.config.height]);
let b = HudBatch::new(size);
let hit = CommandBlockScreen::buttons(b.width, b.height)
let hit = CommandBlockScreen::buttons(b.width, b.height, s.cart.is_some())
.into_iter()
.find(|(_, x, y, bw)| ui::hovered(self.cursor, *x, *y, *bw, 20.0))
.map(|b| b.0);
@@ -638,7 +686,7 @@ impl Game<'_> {
match hit {
Some(Button::Output) => {
s.track_output = !s.track_output;
if let Some(block) = self.command_block(s.pos) {
if let Some(block) = self.edit_base(&s) {
s.show_previous(block);
}
}
@@ -747,6 +795,10 @@ impl Game<'_> {
self.system_message(Text::translate("advMode.notAllowed", &[]));
return;
}
if let Some(id) = s.cart {
self.set_command_minecart(id, s);
return;
}
let edit = CommandBlockEdit {
pos: s.pos,
command: s.command.value().to_owned(),
@@ -887,3 +939,22 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_minecart_screen_has_no_mode_buttons_and_a_lower_output_row() {
let cart = CommandBlockScreen::buttons(400.0, 240.0, true);
let kinds: Vec<_> = cart.iter().map(|b| b.0).collect();
assert!(kinds == [Button::Output, Button::Done, Button::Cancel]);
// MinecartCommandBlockEditScreen.getPreviousY.
assert_eq!(cart[0].2, 150.0);
let block = CommandBlockScreen::buttons(400.0, 240.0, false);
assert_eq!(block.len(), 6);
assert_eq!(block[0].2, 135.0);
// Done and Cancel sit at the same height on both.
assert_eq!(cart[1].2, block[4].2);
}
}
@@ -211,6 +211,15 @@ impl Game<'_> {
"tick" => {
self.chat_capture = Some(Vec::new());
for _ in 0..op["n"].as_u64().unwrap_or(1) {
// The oracle's profile resolver answers at once; the
// game's asks the services (none here: they refuse at
// once) on a thread, which a tick can outrun.
let asked = Instant::now();
while self.profile_fetches_pending()
&& asked.elapsed() < Duration::from_secs(10)
{
std::thread::sleep(Duration::from_millis(1));
}
self.tick_server(false)?;
}
let messages = self.chat_capture.take().unwrap_or_default();
@@ -0,0 +1,200 @@
//! `MinecartCommandBlock`: its `BaseCommandBlock` run by a powered activator
//! rail (`performCommand`, `CloseableCommandBlockSource`), the entity data
//! that carries its command and last output to clients, and the edit screen's
//! `ServerboundSetCommandMinecartPacket` with `handleSetCommandMinecart`.
use super::{
Game,
command_blocks::CommandBlockScreen,
commands::{Target, execute::Source},
lan::LanHost,
minecarts::{CartKind, Minecart},
};
use horizon_client::{
command_block::Mode,
text::{self, Text},
};
use horizon_protocol::packets::play::{self, Serverbound as S};
impl Game<'_> {
fn command_cart_mut(&mut self, id: u32) -> Option<&mut Minecart> {
self.carts
.iter_mut()
.find(|c| c.id == id && c.kind == CartKind::CommandBlock)
}
/// `MinecartCommandBase.onUpdated`: the cart's command and last output go
/// into its synched data (`DATA_ID_COMMAND_NAME`, `DATA_ID_LAST_OUTPUT`).
fn cart_command_updated(&mut self, id: u32) {
if let Some(c) = self.command_cart_mut(id) {
c.synced_command = c.command.command.clone();
c.synced_output = c.command.last_output_string();
}
}
/// `BaseCommandBlock.performCommand` for the cart: false when it already
/// ran this game tick.
pub(super) fn perform_cart_command(&mut self, id: u32) -> bool {
let game_time = self.world.game_time;
let rules_work = self.ui.rules.bool("command_blocks_work");
let Some(c) = self.command_cart_mut(id) else {
return false;
};
if c.command.last_execution == game_time {
return false;
}
if c.command.command.eq_ignore_ascii_case("Searge") {
c.command.last_output = Some(Text::literal("#itzlipofutzli"));
c.command.success_count = 1;
return true;
}
c.command.success_count = 0;
let command = c.command.command.clone();
if rules_work && !command.is_empty() {
c.command.last_output = None;
// MinecartCommandBase.createCommandSourceStack: the cart's
// position and rotation at game master level.
let source = Source {
entity: Some(Target::Cart(id)),
pos: [c.position.x, c.position.y, c.position.z],
rot: [c.yaw, 0.0],
eyes: false,
silent: false,
store: None,
dimension: self.dim,
level: None,
block: None,
cart: Some(id),
};
let successes = self.run_as_game_master(&command, source);
if let Some(c) = self.command_cart_mut(id) {
c.command.success_count = successes;
}
}
if let Some(c) = self.command_cart_mut(id) {
c.command.last_execution = if c.command.update_last_execution {
game_time
} else {
-1
};
}
true
}
/// `CloseableCommandBlockSource.sendSystemMessage` for the cart: its last
/// output stamped with the time, then `onUpdated`. Nothing when it does
/// not track output (`CommandSource.NULL`).
pub(super) fn cart_command_output(&mut self, id: u32, message: Text) {
let Some(c) = self.command_cart_mut(id) else {
return;
};
if c.command.track_output {
let time = chrono::Local::now().format("[%H:%M:%S] ").to_string();
c.command.last_output = Some(Text::literal(time).append(message));
self.cart_command_updated(id);
}
}
/// `CommandSourceStack.sendSuccess` from a command minecart: its output
/// when `send_command_feedback`, and `broadcastToAdmins` when
/// `command_block_output`.
pub(super) fn cart_command_success(&mut self, id: u32, message: Text) {
let Some(name) = self
.command_cart_mut(id)
.filter(|c| c.command.track_output)
.map(|c| c.command.name())
else {
return;
};
let feedback = self.ui.rules.bool("send_command_feedback");
if feedback {
self.cart_command_output(id, message.clone());
}
if feedback && self.ui.rules.bool("command_block_output") {
let broadcast = Text::translate("chat.type.admin", &[name, message])
.with_color(text::GRAY)
.italic();
self.system_message(broadcast);
}
}
/// `LocalPlayer.openMinecartCommandBlock`: the editor over the cart's
/// command (`MinecartCommandBlockEditScreen`).
pub(super) fn open_cart_command_block(&mut self, id: u32) {
let Some(block) = self
.carts
.iter()
.find(|c| c.id == id && c.kind == CartKind::CommandBlock)
.map(|c| c.command.clone())
else {
return;
};
self.open_edit_screen((0, 0, 0), Some(id), block, Mode::Redstone, false);
}
/// The edit screen's Done for a cart (`populateAndSendPacket`, then
/// `onDone` clears the last output of a cart that no longer tracks it): a
/// server's client sends the packet, the window's own world applies it.
pub(super) fn set_command_minecart(&mut self, id: u32, s: &CommandBlockScreen) {
if !self.can_use_game_master_blocks() {
self.system_message(Text::translate("advMode.notAllowed", &[]));
return;
}
let command = s.command.value().to_owned();
let track_output = s.track_output;
// The output button set the cart's own flag as it was pressed.
if let Some(c) = self.command_cart_mut(id) {
c.command.track_output = track_output;
}
if self.remote.is_some() {
self.send(S::SetCommandMinecart(play::SetCommandMinecart {
entity: id as i32,
command,
track_output,
}));
} else {
self.apply_cart_command(id, command, track_output);
}
if !track_output && let Some(c) = self.command_cart_mut(id) {
c.command.last_output = None;
}
}
/// `ServerGamePacketListenerImpl.handleSetCommandMinecart` for a guest.
pub(super) fn lan_set_command_minecart(
&mut self,
host: &mut LanHost,
conn: u32,
p: play::SetCommandMinecart,
) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let allowed = g.creative() && g.permission >= 2;
self.as_guest(host, conn, |game| {
if allowed {
game.apply_cart_command(p.entity as u32, p.command, p.track_output);
} else {
game.system_message(Text::translate("advMode.notAllowed", &[]));
}
});
}
/// What `handleSetCommandMinecart` does once the player may edit: the
/// command (which resets the success count), output tracking, the cleared
/// last output, `onUpdated` while command blocks work, and the chat note.
pub(super) fn apply_cart_command(&mut self, id: u32, command: String, track_output: bool) {
let enabled = self.ui.rules.bool("command_blocks_work");
let Some(c) = self.command_cart_mut(id) else {
return;
};
c.command.set_command(&command);
c.command.track_output = track_output;
if !track_output {
c.command.last_output = None;
}
if enabled {
self.cart_command_updated(id);
}
if !command.is_empty() {
let key = if enabled {
"advMode.setCommand.success"
} else {
"advMode.setCommand.disabled"
};
self.system_message(Text::translate(key, &[Text::literal(command)]));
}
}
}
+136 -2
View File
@@ -11,8 +11,10 @@ mod misc;
mod place;
mod players;
pub(super) mod pregen;
mod profiles;
mod raid;
mod terrain_cmds;
mod test_runs;
mod text_commands;
mod world_cmds;
@@ -113,6 +115,25 @@ fn wrap_degrees(angle: f32) -> f32 {
}
}
/// The network id of a target that is no player: its entity id.
fn non_player_entity_id(t: Target) -> Option<i32> {
let (Target::Mob(id)
| Target::Item(id)
| Target::Orb(id)
| Target::Projectile(id)
| Target::Stand(id)
| Target::Frame(id)
| Target::Painting(id)
| Target::Boat(id)
| Target::Cart(id)
| Target::Other(id)
| Target::Crystal(id)
| Target::Dragon(id)) = t
else {
return None;
};
i32::try_from(id).ok()
}
impl Game<'_> {
/// Commands.performCommand for a line without its slash.
pub(super) fn run_command(&mut self, command: &str) -> bool {
@@ -201,8 +222,30 @@ impl Game<'_> {
self.command_block_output(block, message);
return;
}
if let Some(cart) = self.command_source.and_then(|s| s.cart) {
self.cart_command_output(cart, message);
return;
}
self.system_message(message);
}
/// `sendSuccess(message, false)`: the source is told (when it takes
/// success feedback), the operators are not.
pub(super) fn feedback_unbroadcast(&mut self, message: Text) {
if self.command_source.is_some_and(|s| s.silent) {
return;
}
if let Some(block) = self.command_source.and_then(|s| s.block) {
self.command_block_success(block, message);
return;
}
if let Some(cart) = self.command_source.and_then(|s| s.cart) {
self.cart_command_success(cart, message);
return;
}
if self.ui.rules.bool("send_command_feedback") {
self.system_message(message);
}
}
/// sendSuccess honouring `send_command_feedback`.
pub(super) fn feedback(&mut self, message: Text) {
if self.command_source.is_some_and(|s| s.silent) {
@@ -212,6 +255,10 @@ impl Game<'_> {
self.command_block_success(block, message);
return;
}
if let Some(cart) = self.command_source.and_then(|s| s.cart) {
self.cart_command_success(cart, message);
return;
}
if self.ui.rules.bool("send_command_feedback") {
self.system_message(message.clone());
}
@@ -923,6 +970,8 @@ impl Game<'_> {
id if id.starts_with("dialog.") => self.cmd_dialog(id, ctx),
id if id.starts_with("posteffect.") => self.cmd_posteffect(id, ctx),
"kick" => self.cmd_kick(ctx),
"op" | "deop" | "ban" | "pardon" => self.cmd_profiles(id, ctx),
id if id.starts_with("whitelist.") => self.cmd_whitelist(id, ctx),
"fetchprofile" => self.cmd_fetchprofile(ctx),
"publish" => self.cmd_publish(ctx),
"unpublish" => self.cmd_unpublish(),
@@ -1482,6 +1531,54 @@ impl Game<'_> {
});
true
}
/// `ServerPlayer.lookAt`: besides turning the player, tell their client
/// with a `ClientboundPlayerLookAtPacket` (`from` anchor, the point, and
/// the entity with its anchor when the target is one). Only a connected
/// player has a client to tell. The entity form is sent whenever the
/// target is an entity with a network id (`LookAt.LookAtEntity.perform`);
/// a player whose id is not known here (a guest's turn, with the host
/// out of reach) falls back to the point, which a client takes the same
/// unless the entity moves first.
/// `Entity.getId` as the players' clients know it: a player's is the one the
/// hub gave it, any other entity's is its own id.
pub(super) fn network_entity_id(&self, t: Target) -> Option<i32> {
let host = self.lan.as_ref();
match t {
Target::Player => match self.lan_acting {
Some(conn) => host?.hub.guest(conn).map(|g| g.entity_id),
None => host.map(|h| h.host_entity),
},
Target::Guest(conn) => host?.hub.guest(conn).map(|g| g.entity_id),
other => non_player_entity_id(other),
}
}
pub(super) fn send_player_look_at(
&mut self,
t: Target,
from_eyes: bool,
target: [f64; 3],
entity: Option<(Target, bool)>,
) {
let packet = C::PlayerLookAt(play::PlayerLookAt {
from: i32::from(from_eyes),
target,
entity: entity.and_then(|(e, eyes)| {
Some(play::LookAtEntity {
id: self.network_entity_id(e)?,
anchor: i32::from(eyes),
})
}),
});
match t {
Target::Player if self.lan_acting.is_some() => self.guest_packets.push(packet),
Target::Guest(conn) => {
if let Some(host) = self.lan.as_ref() {
host.hub.send_to(conn, &packet);
}
}
_ => {}
}
}
fn cmd_teleport(&mut self, id: &str, ctx: &Context) -> R<i32> {
let targets = if id.starts_with("teleport.targets") {
self.entities(ctx, "targets", false)?
@@ -1540,12 +1637,14 @@ impl Game<'_> {
};
None
});
let mut facing_entity = None;
let facing = match (facing, ctx.get("facingEntity")) {
(Some(f), _) => Some(f),
(None, Some(_)) => {
let e = self.entities(ctx, "facingEntity", false)?[0];
// EntityAnchorArgument.Anchor.apply: the feet, or the eyes.
let eyes = matches!(ctx.get("facingAnchor"), Some(Arg::Anchor(true)));
facing_entity = Some((e, eyes));
let eye_height = if eyes { self.target_eye_height(e) } else { 0.0 };
self.target_position(e)
.map(|p| [p[0], p[1] + eye_height, p[2]])
@@ -1588,6 +1687,10 @@ impl Game<'_> {
} else {
self.teleport_to(t, pos, rotation);
}
// LookAt.perform after a successful teleportTo.
if let Some(f) = facing {
self.send_player_look_at(t, anchored_eyes, f, facing_entity);
}
}
let [x, y, z] = pos.map(|v| Text::literal(format_double(v)));
let message = if targets.len() == 1 {
@@ -1830,7 +1933,13 @@ impl Game<'_> {
Target::Stand(id) => self.armor_stands.retain(|a| a.id != id),
Target::Frame(id) => self.item_frames.retain(|f| f.id != id),
Target::Painting(id) => self.paintings.retain(|p| p.id != id),
Target::Boat(id) => self.boats.retain(|b| b.id != id),
Target::Boat(id) => {
// AbstractBoat.remove: a killed boat drops its lead.
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
let b = self.boats.remove(i);
self.boat_removed(&b);
}
}
Target::Cart(id) => self.carts.retain(|c| c.id != id),
Target::Other(id) => self.others.retain(|o| o.id != id),
Target::Crystal(id) => self.kill_crystal(id),
@@ -2084,7 +2193,7 @@ impl Game<'_> {
let landed = self.world.block(pos[0], pos[1], pos[2]);
if old != new
&& self.res.facts().get(landed).name == new
&& let Some(entity) = horizon_client::containers::BlockEntity::for_block(&new)
&& let Some(entity) = self.new_block_entity(&new)
{
self.world.block_entities.insert(key, entity);
}
@@ -3388,3 +3497,28 @@ fn split_top_level(s: &str, sep: char) -> Vec<&str> {
out.push(&s[start..]);
out
}
#[cfg(test)]
mod look_at_tests {
use super::{Target, non_player_entity_id};
#[test]
fn look_at_entity_form_names_any_entity_by_its_id() {
for t in [
Target::Mob(7),
Target::Item(7),
Target::Orb(7),
Target::Projectile(7),
Target::Boat(7),
Target::Cart(7),
Target::Crystal(7),
Target::Dragon(7),
] {
assert_eq!(non_player_entity_id(t), Some(7), "{t:?}");
}
// Players are the hub's to name; a dragon part's id is no entity id.
assert_eq!(non_player_entity_id(Target::Player), None);
assert_eq!(non_player_entity_id(Target::Guest(3)), None);
assert_eq!(non_player_entity_id(Target::Mob(u32::MAX - 4096)), None);
}
}
@@ -703,7 +703,7 @@ impl Game<'_> {
/// Spawn an entity from its compound (`EntityType.loadEntityRecursive`);
/// returns what was created.
pub(super) fn spawn_entity_from_nbt(&mut self, tag: Tag) -> Option<Target> {
pub(in crate::game) fn spawn_entity_from_nbt(&mut self, tag: Tag) -> Option<Target> {
let key = {
let p = entity_nbt::position(&tag)?;
((p.x.floor() as i32) >> 4, (p.z.floor() as i32) >> 4)
@@ -32,6 +32,8 @@ pub(in crate::game) struct Source {
/// The command block whose output this source is
/// (`BaseCommandBlock.CloseableCommandBlockSource`).
pub block: Option<(i32, i32, i32)>,
/// The command block minecart whose output this source is.
pub cart: Option<u32>,
}
/// Where `execute store` writes.
@@ -94,6 +96,7 @@ impl Game<'_> {
dimension: self.dim,
level: None,
block: None,
cart: None,
}
}
/// The server as a source (function ticks and schedules): the world spawn.
@@ -108,9 +111,10 @@ impl Game<'_> {
dimension: self.dim,
level: None,
block: None,
cart: None,
}
}
pub(super) fn target_rotation(&self, t: Target) -> [f32; 2] {
pub(in crate::game) fn target_rotation(&self, t: Target) -> [f32; 2] {
match t {
Target::Player => [self.yaw, self.pitch],
Target::Guest(conn) => self
@@ -674,7 +678,8 @@ impl Game<'_> {
self.target_by_uuid(horizon_client::lan::playerdata::ints_uuid(uuid))
}
horizon_client::mobs::leash::Holder::Mob(i) => Some(Target::Mob(i)),
horizon_client::mobs::leash::Holder::Knot(_) => None,
horizon_client::mobs::leash::Holder::Boat(_)
| horizon_client::mobs::leash::Holder::Knot(_) => None,
})
.into_iter()
.collect(),
+281 -90
View File
@@ -6,12 +6,15 @@
use super::{Arg, Context, Game, R, Target, err};
use horizon_client::{
command::CommandError,
text::{self, Style, Text},
skins::{NameOrId, Resolve, ResolvedProfile},
snbt,
text::{self, ClickEvent, InlineObject, Style, Text},
};
use horizon_protocol::packets::{
Empty,
play::{self, Clientbound as C, DialogHolder},
};
use horizon_world::nbt::Tag;
use std::{collections::HashMap, time::Instant};
/// Waypoint.Icon: the locator bar style and colour of a transmitter.
@@ -29,11 +32,95 @@ impl Default for WaypointIcon {
}
}
/// A `/fetchprofile` lookup in flight: what it resolves, the message ids it
/// answers with (`commands.fetchprofile.<kind>.success|failure`), the
/// argument they show, and the source to answer.
pub(in crate::game) struct ProfileFetch {
key: NameOrId,
kind: &'static str,
argument: Text,
source: Option<super::execute::Source>,
}
/// `ResolvableProfile.CODEC` of a resolved profile as NBT (`id` as an int
/// array, `name`, and `properties` only when there are some, each with its
/// `signature` only when signed), and the same as the JSON a head object
/// draws from.
fn encode_profile(profile: &ResolvedProfile) -> (Tag, serde_json::Value) {
let ints = [
(profile.id >> 96) as i32,
(profile.id >> 64) as i32,
(profile.id >> 32) as i32,
profile.id as i32,
];
let mut fields = vec![
("id", Tag::IntArray(ints.to_vec())),
("name", Tag::String(profile.name.as_str().into())),
];
let mut json = serde_json::json!({ "id": ints, "name": profile.name });
if !profile.properties.is_empty() {
let mut list = Vec::new();
let mut json_list = Vec::new();
for (name, value, signature) in &profile.properties {
let mut entry = vec![
("name", Tag::String(name.as_str().into())),
("value", Tag::String(value.as_str().into())),
];
let mut j = serde_json::json!({ "name": name, "value": value });
if let Some(signature) = signature {
entry.push(("signature", Tag::String(signature.as_str().into())));
j["signature"] = signature.clone().into();
}
list.push(Tag::compound(entry));
json_list.push(j);
}
fields.push(("properties", Tag::List(list)));
json["properties"] = json_list.into();
}
(Tag::compound(fields), json)
}
#[cfg(test)]
mod fetchprofile_tests {
use super::*;
#[test]
fn unsigned_profile_without_properties_omits_them() {
let profile = ResolvedProfile {
id: 0x0000_0001_0000_0002_0000_0003_0000_0004,
name: "Steve".into(),
properties: Vec::new(),
};
let (tag, json) = encode_profile(&profile);
assert_eq!(snbt::to_snbt(&tag), "{id:[I;1,2,3,4],name:\"Steve\"}");
assert!(json.get("properties").is_none());
}
#[test]
fn properties_keep_the_signature_only_when_signed() {
let profile = ResolvedProfile {
id: 1,
name: "Alex".into(),
properties: vec![
("textures".into(), "v".into(), Some("s".into())),
("other".into(), "w".into(), None),
],
};
let (tag, _) = encode_profile(&profile);
assert_eq!(
snbt::to_snbt(&tag),
"{id:[I;0,0,0,1],name:\"Alex\",properties:[{name:\"textures\",signature:\"s\",value:\"v\"},{name:\"other\",value:\"w\"}]}"
);
}
}
/// Server-side player and server state these commands keep.
#[derive(Default)]
pub(in crate::game) struct CommandExtras {
/// ServerPlayer.camera: the entity `/spectate` attached the view to.
pub camera: Option<Target>,
/// The cameras of the guests of a server, by connection
/// (game/lan_camera.rs).
pub guest_cameras: HashMap<u32, super::super::lan_camera::GuestCamera>,
/// `/spectate` for a guest: connection and the camera's network id
/// (None to stop), taken in the guests' tick.
pub camera_requests: Vec<(u32, Option<i32>)>,
/// ServerPlayer.postEffects, in insertion order.
pub post_effects: Vec<String>,
/// MinecraftServer's tick profiler (`/debug start`): when it started and
@@ -41,8 +128,8 @@ pub(in crate::game) struct CommandExtras {
pub profiler: Option<(Instant, u64)>,
/// `debugCommandProfilerDelayStart`: the profiler starts with the next tick.
pub profiler_delay: bool,
/// Messages the server sends on a later tick (profile lookups).
pub pending: Vec<Text>,
/// `/fetchprofile name|id` lookups still running.
pub profile_fetches: Vec<ProfileFetch>,
/// The `/jfr start` recording: when it started and the tick count then.
pub jfr: Option<(Instant, u64, String)>,
/// Waypoint icons changed by `/waypoint modify`.
@@ -101,7 +188,7 @@ fn civil(secs: i64) -> (i64, i64, i64, i64, i64, i64) {
impl Game<'_> {
/// CommandSourceStack.getPlayerOrException.
fn source_player(&self) -> R<Target> {
pub(super) fn source_player(&self) -> R<Target> {
match self.command_source.map(|s| s.entity) {
None | Some(Some(Target::Player)) => Ok(Target::Player),
_ => Err(err("permissions.requires.player", &[])),
@@ -144,7 +231,19 @@ impl Game<'_> {
&[self.target_name(t)],
));
}
self.set_camera(target);
// A guest's camera is the hub's: the view moves in the guests' tick.
let guest = match player {
Target::Player => self.lan_acting,
Target::Guest(conn) => Some(conn),
_ => None,
};
match guest {
Some(conn) => {
let camera = target.and_then(|t| self.network_entity_id(t));
self.cmds.camera_requests.push((conn, camera));
}
None => self.set_camera(target),
}
match target {
Some(t) => {
let name = self.target_name(t);
@@ -179,9 +278,7 @@ impl Game<'_> {
self.cmds.profiler_delay = false;
self.cmds.profiler = Some((Instant::now(), self.ticks));
}
for message in std::mem::take(&mut self.cmds.pending) {
self.system_message(message);
}
self.tick_profile_fetches();
self.tick_camera();
self.tick_waypoints();
}
@@ -458,11 +555,53 @@ impl Game<'_> {
// ---- /posteffect ----------------------------------------------------
/// `ServerPlayer.postEffects` of a player target: a connected player's
/// (told to their client at the end of their tick), or the host's own.
fn post_effects_of(&self, t: Target) -> Vec<String> {
let conn = match t {
Target::Guest(conn) => Some(conn),
Target::Player => self.lan_acting,
_ => return Vec::new(),
};
match conn {
Some(conn) => self
.command_guest(conn)
.map(|g| g.post_effects.clone())
.unwrap_or_default(),
None => self.cmds.post_effects.clone(),
}
}
/// Changes a player target's `postEffects`; whether `change` did, which
/// sets `postEffectsDirty` for the packet.
fn change_post_effects(
&mut self,
t: Target,
change: impl FnOnce(&mut Vec<String>) -> bool,
) -> bool {
let conn = match t {
Target::Guest(conn) => Some(conn),
Target::Player => self.lan_acting,
_ => return false,
};
match conn {
Some(conn) => {
let Some(g) = self.lan.as_mut().and_then(|h| h.hub.guest_mut(conn)) else {
return false;
};
let changed = change(&mut g.post_effects);
g.post_effects_dirty |= changed;
changed
}
None => change(&mut self.cmds.post_effects),
}
}
pub(super) fn cmd_posteffect(&mut self, id: &str, ctx: &Context) -> R<i32> {
if id == "posteffect.list" {
let t = self.one_entity(ctx, "target", true)?;
let name = self.target_name(t);
let effects = self.cmds.post_effects.clone();
let effects = self.post_effects_of(t);
if effects.is_empty() {
self.feedback(Text::translate("commands.posteffect.list.empty", &[name]));
} else {
@@ -480,24 +619,27 @@ impl Game<'_> {
};
let mut changed: Vec<Target> = Vec::new();
for t in &targets {
let done = match id {
let done = self.change_post_effects(*t, |list| match id {
// ServerPlayer.addPostEffect.
"posteffect.add" => {
if self.cmds.post_effects.contains(&effect) {
if list.contains(&effect) {
false
} else {
self.cmds.post_effects.push(effect.clone());
list.push(effect.clone());
true
}
}
"posteffect.remove" => {
let before = self.cmds.post_effects.len();
if let Some(i) = self.cmds.post_effects.iter().position(|e| *e == effect) {
self.cmds.post_effects.remove(i);
// ServerPlayer.removePostEffect.
"posteffect.remove" => match list.iter().position(|e| *e == effect) {
Some(i) => {
list.remove(i);
true
}
before != self.cmds.post_effects.len()
}
_ => !std::mem::take(&mut self.cmds.post_effects).is_empty(),
};
None => false,
},
// ServerPlayer.clearPostEffects.
_ => !std::mem::take(list).is_empty(),
});
if done {
changed.push(*t);
}
@@ -531,32 +673,16 @@ impl Game<'_> {
// ---- /test ----------------------------------------------------------
/// `TestCommand`. Horizon has no game test instances or test runner: every
/// finder comes back empty, as in a world without test instance blocks.
/// `TestCommand`: running, resetting and clearing tests is
/// `test_runs`; `verify`, `create`, `locate` and `pos` are not ported and
/// answer as in a world without test instance blocks.
pub(super) fn cmd_test(&mut self, id: &str, ctx: &Context) -> R<i32> {
match id {
// run <tests>, runmultiple, verify: GameTestRunner.
"test.run" | "test.verify" | "test.create" => Err(err("command.failed", &[])),
// runthese/runclosest/runthat/runfailed: TestFinder finds nothing.
"test.run.found" => {
if ctx.literals.iter().any(|l| l == "runthat") {
self.source_player()?;
}
self.feedback(Text::translate("commands.test.no_tests", &[]));
Ok(0)
"test.run" | "test.run.found" | "test.clear" | "test.stop" => {
self.cmd_test_runs(id, ctx)
}
// reset*/clear*: TestCommand.reset throws CLEAR_NO_TESTS too.
"test.clear" => {
if ctx
.literals
.iter()
.any(|l| l == "resetthat" || l == "clearthat")
{
self.source_player()?;
}
Err(err("commands.test.clear.error.no_tests", &[]))
}
"test.stop" => Ok(1),
// verify, create: not ported.
"test.verify" | "test.create" => Err(err("command.failed", &[])),
"test.locate" => {
// CommandSourceStack.sendSystemMessage: no feedback rule.
if !self.command_source.is_some_and(|s| s.silent) {
@@ -680,31 +806,37 @@ impl Game<'_> {
// ---- /fetchprofile --------------------------------------------------
/// `FetchProfileCommand`: Horizon resolves no profiles over the network,
/// so name and id lookups fail like an unreachable profile service; an
/// entity with a profile (the player) prints its resolved profile.
/// `FetchProfileCommand.resolveName` and `resolveId`: the lookup runs on
/// background threads (`Util.nonCriticalIoPool`, here the cached profile
/// resolver's), and the answer is reported by a later tick;
/// `printForEntity` reports the player's own profile at once.
pub(super) fn cmd_fetchprofile(&mut self, ctx: &Context) -> R<i32> {
if ctx.literals.iter().any(|l| l == "name") {
let name = match ctx.get("name") {
Some(Arg::Str(s)) => s.clone(),
_ => String::new(),
};
// Resolved off-thread; the answer arrives on a later tick.
self.cmds.pending.push(
Text::translate("commands.fetchprofile.name.failure", &[Text::literal(name)])
.with_color(text::RED),
);
self.cmds.profile_fetches.push(ProfileFetch {
key: NameOrId::Name(name.clone()),
kind: "name",
argument: Text::literal(name),
source: self.command_source,
});
return Ok(1);
}
if ctx.literals.iter().any(|l| l == "id") {
let id = match ctx.get("id") {
Some(Arg::Str(s)) => s.to_ascii_lowercase(),
_ => String::new(),
};
self.cmds.pending.push(
Text::translate("commands.fetchprofile.id.failure", &[Text::literal(id)])
.with_color(text::RED),
);
Some(Arg::Str(s)) => horizon_client::command::selector::java_uuid(s),
_ => None,
}
.unwrap_or_default();
// `Component.translationArg(id)`: the UUID's string form.
self.cmds.profile_fetches.push(ProfileFetch {
key: NameOrId::Id(id),
kind: "id",
argument: Text::literal(horizon_protocol::crypto::uuid_string(id)),
source: self.command_source,
});
return Ok(1);
}
let t = self.one_entity(ctx, "entity", false)?;
@@ -714,57 +846,116 @@ impl Game<'_> {
&[self.target_name(t)],
));
}
let profile = self.player_profile_snbt();
let [a, b, c, d] = self.level.player_uuid.unwrap_or([0; 4]);
let id = (u128::from(a as u32) << 96)
| (u128::from(b as u32) << 64)
| (u128::from(c as u32) << 32)
| u128::from(d as u32);
let name = super::super::chat::PLAYER_NAME;
let head = format!("{{type:\"minecraft:player\",player:{profile}}}");
let item = |key: &str, args: &[Text], run: Option<String>| {
let inner = Text::translate(key, args).with_color(text::GREEN);
let mut t = Text::translate("chat.square_brackets", &[inner]);
if let Some(cmd) = run {
t = t.with_style(&Style {
click: None,
self.report_resolved_profile(
"commands.fetchprofile.entity.success",
Text::literal(name),
&ResolvedProfile {
id,
name: name.to_owned(),
properties: Vec::new(),
},
);
Ok(1)
}
/// The answers of the profile lookups that have come in
/// (`server.execute(() -> result.ifPresentOrElse(...))`).
/// Whether a `/fetchprofile` lookup is still in flight (the command
/// harness waits for them, as vanilla's oracle resolves at once).
pub(in crate::game) fn profile_fetches_pending(&mut self) -> bool {
let mut resolver = self.skin_profiles.borrow_mut();
self.cmds
.profile_fetches
.iter()
.any(|f| matches!(resolver.state(&f.key), Resolve::Pending))
}
fn tick_profile_fetches(&mut self) {
let fetches = std::mem::take(&mut self.cmds.profile_fetches);
for fetch in fetches {
let state = self.skin_profiles.borrow_mut().state(&fetch.key);
match state {
Resolve::Pending => self.cmds.profile_fetches.push(fetch),
Resolve::Done(result) => {
let outer = std::mem::replace(&mut self.command_source, fetch.source);
match result {
Some(profile) => self.report_resolved_profile(
&format!("commands.fetchprofile.{}.success", fetch.kind),
fetch.argument,
&profile,
),
None => self.command_failure(Text::translate(
&format!("commands.fetchprofile.{}.failure", fetch.kind),
&[fetch.argument],
)),
}
self.command_source = outer;
}
}
}
}
/// `FetchProfileCommand.reportResolvedProfile`: the profile as
/// `ResolvableProfile.CODEC` encodes it (`id`, `name`, and `properties`
/// only when there are some), four clickable actions and the head object,
/// sent with `sendSuccess(..., false)`.
fn report_resolved_profile(
&mut self,
message_id: &str,
argument: Text,
profile: &ResolvedProfile,
) {
let (profile_tag, json) = encode_profile(profile);
let encoded_profile = snbt::to_snbt(&profile_tag);
// `Component.object(new PlayerSprite(profile, true))`, encoded: `hat`
// is left out at its default.
let encoded_component = snbt::to_snbt(&Tag::compound([("player", profile_tag)]));
let mut head = Text::literal("\u{FFFC}");
head.spans[0].style.inline_object = Some(InlineObject::Player {
profile: json,
hat: true,
fallback: format!("[{} head]", profile.name),
});
let item = |key: &str, args: &[Text], click: ClickEvent| {
let inner = Text::translate(key, args)
.with_color(text::GREEN)
.with_style(&Style {
click: Some(click),
..Style::default()
});
let _ = cmd;
}
t
Text::translate("chat.square_brackets", &[inner])
};
let placeholder = Text::literal("\u{FFFC}").with_color(text::WHITE);
let mut clickable = item("commands.fetchprofile.copy_component", &[], None);
let mut clickable = item(
"commands.fetchprofile.copy_component",
&[],
ClickEvent::CopyToClipboard(encoded_profile.clone()),
);
clickable.push(" ");
clickable.push(item(
"commands.fetchprofile.give_item",
&[],
Some(format!("give @s minecraft:player_head[profile={profile}]")),
ClickEvent::RunCommand(format!(
"give @s minecraft:player_head[profile={encoded_profile}]"
)),
));
clickable.push(" ");
clickable.push(item(
"commands.fetchprofile.summon_mannequin",
&[],
Some(format!(
"summon minecraft:mannequin ~ ~ ~ {{profile:{profile}}}"
ClickEvent::RunCommand(format!(
"summon minecraft:mannequin ~ ~ ~ {{profile:{encoded_profile}}}"
)),
));
clickable.push(" ");
clickable.push(item(
"commands.fetchprofile.copy_text",
&[placeholder],
None,
&[head.with_color(text::WHITE)],
ClickEvent::CopyToClipboard(encoded_component),
));
let _ = head;
self.feedback(Text::translate(
"commands.fetchprofile.entity.success",
&[Text::literal(name), clickable],
));
Ok(1)
}
/// ResolvableProfile.CODEC of the player's profile as SNBT.
fn player_profile_snbt(&self) -> String {
let [a, b, c, d] = self.level.player_uuid.unwrap_or([0; 4]);
format!(
"{{id:[I;{a},{b},{c},{d}],name:\"{}\",properties:[]}}",
super::super::chat::PLAYER_NAME
)
self.feedback_unbroadcast(Text::translate(message_id, &[argument, clickable]));
}
// ---- /publish, /unpublish -------------------------------------------
@@ -16,7 +16,7 @@ fn num(n: impl std::fmt::Display) -> Text {
}
impl Game<'_> {
fn state_transforms(&self) -> Option<&'static StateTransforms> {
pub(in crate::game) fn state_transforms(&self) -> Option<&'static StateTransforms> {
static TRANSFORMS: OnceLock<Option<StateTransforms>> = OnceLock::new();
TRANSFORMS
.get_or_init(|| {
@@ -33,6 +33,16 @@ impl Game<'_> {
/// A template by id: a structure block's save in the world, then the
/// vanilla templates.
fn load_template(&self, id: &str) -> Option<Arc<StructureTemplate>> {
let tag = self.read_template_tag(id)?;
Some(Arc::new(StructureTemplate::from_nbt(
&tag,
self.pack.assets.registry,
self.state_transforms()?,
)))
}
/// A template's tree by id, as `StructureTemplateManager` reads it from
/// disk.
pub(in crate::game) fn read_template_tag(&self, id: &str) -> Option<Tag> {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
let candidates = [
self.world_root
@@ -46,7 +56,6 @@ impl Game<'_> {
.join(ns)
.join(format!("{path}.nbt")),
];
let transforms = self.state_transforms()?;
for file in candidates {
let Ok(bytes) = std::fs::read(&file) else {
continue;
@@ -57,11 +66,7 @@ impl Game<'_> {
let Ok((named, _)) = nbt::read_named(&raw, Limits::templates()) else {
continue;
};
return Some(Arc::new(StructureTemplate::from_nbt(
&named.tag,
self.pack.assets.registry,
transforms,
)));
return Some(named.tag);
}
None
}
@@ -152,7 +157,7 @@ impl Game<'_> {
position: pos,
};
let placed = self.with_level(|b, level, ticks| {
b.place_jigsaw(level, ticks, pos, || terrain.jigsaw_pieces(&query))
b.place_jigsaw(level, ticks, pos, false, || terrain.jigsaw_pieces(&query))
});
self.process_level_events()
.map_err(|_| err("command.failed", &[]))?;
@@ -0,0 +1,277 @@
//! `/op`, `/deop`, `/ban`, `/pardon` and `/whitelist` (OpCommand,
//! DeOpCommands, BanPlayerCommands, PardonCommand, WhitelistCommand):
//! commands of a dedicated server, run on its lists by an operator in game
//! (the console has its own handlers in game/dedicated.rs).
use super::{Arg, Context, Game, R, Target, err};
use horizon_client::{
server::user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
text::Text,
};
use horizon_protocol::text::translatable;
impl Game<'_> {
/// Commands.CommandSelection.DEDICATED: on an integrated server these do
/// not exist.
fn dedicated_lists(
&self,
) -> R<std::sync::Arc<std::sync::Mutex<super::super::dedicated::Lists>>> {
match (&self.server_lists, self.options.dedicated.is_some()) {
(Some(lists), true) => Ok(lists.clone()),
_ => Err(err("command.unknown.command", &[])),
}
}
/// `NameAndId` of a player target.
fn target_profile(&self, t: Target) -> Option<NameAndId> {
match t {
Target::Player => Some(NameAndId {
id: self.lan_acting_uuid?,
name: self.acting_player_name().to_owned(),
}),
Target::Guest(conn) => self.command_guest(conn).map(|g| NameAndId {
id: g.uuid,
name: g.name.clone(),
}),
_ => None,
}
}
/// `GameProfileArgument.getGameProfiles`: a selector's players
/// (`SelectorResult.getNames`, no players is an error), else the name in
/// the user cache.
fn game_profiles(&mut self, ctx: &Context, name: &str) -> R<Vec<NameAndId>> {
match ctx.get(name).cloned() {
Some(Arg::Entity(s)) => {
let players: Vec<Target> = self
.select(&s)
.into_iter()
.filter(|t| t.is_player())
.collect();
if players.is_empty() {
return Err(err("argument.entity.notfound.player", &[]));
}
Ok(players
.into_iter()
.filter_map(|t| self.target_profile(t))
.collect())
}
Some(Arg::Str(text)) => self
.resolve_name(&text)
.map(|p| vec![p])
.ok_or_else(|| err("argument.player.unknown", &[])),
_ => Err(err("argument.player.unknown", &[])),
}
}
/// `CommandSourceStack.getTextName`.
fn source_text_name(&self) -> String {
if self.console_broadcast.is_some() {
"Server".to_owned()
} else {
self.acting_player_name().to_owned()
}
}
/// `OpCommand.opPlayers`, `DeOpCommands.deopPlayers`,
/// `BanPlayerCommands.banPlayers` and `PardonCommand.pardonPlayers`.
pub(super) fn cmd_profiles(&mut self, id: &str, ctx: &Context) -> R<i32> {
let lists = self.dedicated_lists()?;
let players = self.game_profiles(ctx, "targets")?;
let lit = |s: &str| Text::literal(s.to_owned());
let mut count = 0;
match id {
"op" => {
for player in &players {
let added = {
let mut l = lists.lock().unwrap();
let level = l.op_level;
!l.ops.contains(player)
&& l.ops.add(OpEntry {
user: player.clone(),
level,
bypasses_player_limit: false,
})
};
if added {
self.sync_ops(&lists);
count += 1;
self.feedback(Text::translate("commands.op.success", &[lit(&player.name)]));
}
}
if count == 0 {
return Err(err("commands.op.failed", &[]));
}
}
"deop" => {
for player in &players {
let removed = {
let mut l = lists.lock().unwrap();
l.ops.contains(player) && l.ops.remove(player)
};
if removed {
self.sync_ops(&lists);
count += 1;
self.feedback(Text::translate(
"commands.deop.success",
&[lit(&player.name)],
));
}
}
if count == 0 {
return Err(err("commands.deop.failed", &[]));
}
self.kick_unlisted_players(&lists);
}
"ban" => {
let reason = match ctx.get("reason") {
Some(Arg::Str(raw)) => {
let raw = raw.clone();
Some(self.expand_selectors(&raw).plain().to_string())
}
_ => None,
};
let source = self.source_text_name();
for player in &players {
let banned = lists.lock().unwrap().bans.contains(player);
if banned {
continue;
}
lists.lock().unwrap().bans.add(BanEntry::new(
player.clone(),
Some(source.clone()),
None,
reason.clone(),
));
count += 1;
// BanListEntry.getReasonMessage.
let shown = reason.as_deref().map_or_else(
|| Text::translate("multiplayer.disconnect.banned.reason.default", &[]),
lit,
);
self.feedback(Text::translate(
"commands.ban.success",
&[lit(&player.name), shown],
));
self.kick_uuid(
player.id,
translatable("multiplayer.disconnect.banned", &[]),
);
}
if count == 0 {
return Err(err("commands.ban.failed", &[]));
}
}
_ => {
for player in &players {
let removed = {
let mut l = lists.lock().unwrap();
l.bans.contains(player) && l.bans.remove(player)
};
if removed {
count += 1;
self.feedback(Text::translate(
"commands.pardon.success",
&[lit(&player.name)],
));
}
}
if count == 0 {
return Err(err("commands.pardon.failed", &[]));
}
}
}
Ok(count)
}
/// `WhitelistCommand`: `on`, `off`, `list`, `reload`, `add` and `remove`.
pub(super) fn cmd_whitelist(&mut self, id: &str, ctx: &Context) -> R<i32> {
let lists = self.dedicated_lists()?;
let lit = |s: &str| Text::literal(s.to_owned());
match id {
"whitelist.reload" => {
// PlayerList.reloadWhiteList.
let _ = lists.lock().unwrap().white.load();
self.feedback(Text::translate("commands.whitelist.reloaded", &[]));
self.kick_unlisted_players(&lists);
Ok(1)
}
"whitelist.on" => {
if lists.lock().unwrap().use_whitelist {
return Err(err("commands.whitelist.alreadyOn", &[]));
}
self.set_using_whitelist(&lists, true);
self.feedback(Text::translate("commands.whitelist.enabled", &[]));
self.kick_unlisted_players(&lists);
Ok(1)
}
"whitelist.off" => {
if !lists.lock().unwrap().use_whitelist {
return Err(err("commands.whitelist.alreadyOff", &[]));
}
self.set_using_whitelist(&lists, false);
self.feedback(Text::translate("commands.whitelist.disabled", &[]));
Ok(1)
}
"whitelist.list" => {
let names: Vec<String> = lists
.lock()
.unwrap()
.white
.entries()
.iter()
.map(|e| e.user.name.clone())
.collect();
if names.is_empty() {
self.feedback_unbroadcast(Text::translate("commands.whitelist.none", &[]));
} else {
self.feedback_unbroadcast(Text::translate(
"commands.whitelist.list",
&[lit(&names.len().to_string()), lit(&names.join(", "))],
));
}
Ok(names.len() as i32)
}
_ => {
let add = id == "whitelist.add";
let targets = self.game_profiles(ctx, "targets")?;
let mut count = 0;
for target in &targets {
let done = {
let mut l = lists.lock().unwrap();
if add {
!l.white.contains(target)
&& l.white.add(WhiteEntry {
user: target.clone(),
})
} else {
l.white.contains(target) && l.white.remove(target)
}
};
if done {
count += 1;
let key = if add {
"commands.whitelist.add.success"
} else {
"commands.whitelist.remove.success"
};
self.feedback(Text::translate(key, &[lit(&target.name)]));
}
}
if count == 0 {
return Err(err(
if add {
"commands.whitelist.add.failed"
} else {
"commands.whitelist.remove.failed"
},
&[],
));
}
if !add {
self.kick_unlisted_players(&lists);
}
Ok(count)
}
}
}
}
@@ -0,0 +1,420 @@
//! `TestCommand` over the test instance blocks of the level in place and the
//! game's test instances: `run`, `runmultiple`, `runthese`, `runclosest`,
//! `runthat`, `runfailed`, `resetclosest`/`these`/`that`,
//! `clearthat`/`these`/`all` and `stop`, with `TestFinder`'s searches.
//!
//! Not done: `verify`, `locate`, `pos`, `create`, retries (`numberOfTimes`,
//! `untilFailed`), tests of another dimension than the level in place.
use super::{Arg, Context, Game, R, err};
use crate::game::{
game_tests::{Grid, Info},
test_instances::TestDef,
};
use horizon_client::text::{self, Text};
use horizon_world::worldgen::structure::Rotation;
type Pos = (i32, i32, i32);
/// `TestFinder.Builder`'s finders that the command picks.
enum Finder {
/// `radius(c, r)`.
Radius(i32),
/// `nearest(c)`.
Nearest,
/// `allNearby(c)`.
AllNearby,
/// `lookedAt(c)`.
LookedAt,
/// `failedTests(c, onlyRequiredTests)`.
Failed(bool),
/// `byResourceSelection`, `copies` times over.
Selection { tests: Vec<String>, copies: i32 },
}
/// `StructureUtils.getRotationForRotationSteps`.
fn rotation_for_steps(steps: i32) -> Option<Rotation> {
usize::try_from(steps)
.ok()
.and_then(|i| Rotation::ALL.get(i).copied())
}
impl Game<'_> {
/// `CommandSourceStack.sendFailure`.
fn test_failure(&mut self, message: Text) {
if self.command_source.is_some_and(|s| s.silent) {
return;
}
self.system_message(message.with_color(text::RED));
}
/// `StructureUtils.findTestBlocks`: the test instance blocks within
/// `radius` of `center` (the POI search is a sphere).
fn find_test_instance_blocks(&self, center: Pos, radius: i32) -> Vec<Pos> {
let limit = i64::from(radius) * i64::from(radius);
let mut found: Vec<Pos> = self
.world
.block_entities
.iter()
.filter(|(_, e)| {
matches!(
e,
horizon_client::containers::BlockEntity::TestInstanceBlock(_)
)
})
.map(|(p, _)| *p)
.filter(|p| {
let d = [p.0 - center.0, p.1 - center.1, p.2 - center.2].map(i64::from);
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] <= limit
})
.collect();
found.sort_unstable();
found
}
/// The block of the source's position (`BlockPos.containing`).
fn test_source_block(&self) -> Pos {
let p = self.source_position();
(
p[0].floor() as i32,
p[1].floor() as i32,
p[2].floor() as i32,
)
}
/// `StructureUtils.lookedAtTestPos`: the test whose box the player's
/// view ray (250 blocks) meets, the nearest to the source.
fn looked_at_test(&self, center: Pos) -> Option<Pos> {
let player = self.player.as_ref()?;
let eye = [
player.position.x,
player.position.y + f64::from(player.dimensions.eye_height),
player.position.z,
];
let (yaw, pitch) = (
f64::from(self.yaw).to_radians(),
f64::from(self.pitch).to_radians(),
);
let look = [
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
];
let end = [0, 1, 2].map(|i| eye[i] + look[i] * 250.0);
self.find_test_instance_blocks(center, 250)
.into_iter()
.filter(|&p| {
self.test_structure_box(p).is_some_and(|b| {
let min = b.min.map(f64::from);
let max = b.max.map(|v| f64::from(v) + 1.0);
ray_hits_box(eye, end, min, max)
})
})
.min_by_key(|p| {
let d = [p.0 - center.0, p.1 - center.1, p.2 - center.2].map(i64::from);
d[0] * d[0] + d[1] * d[1] + d[2] * d[2]
})
}
/// The test instance blocks a finder gives (`findTestPos`).
fn finder_positions(&self, finder: &Finder) -> Vec<Pos> {
let center = self.test_source_block();
match finder {
Finder::Radius(radius) => self.find_test_instance_blocks(center, *radius),
Finder::Nearest => self
.find_test_instance_blocks(center, 15)
.into_iter()
.min_by_key(|p| {
(p.0 - center.0).abs() + (p.1 - center.1).abs() + (p.2 - center.2).abs()
})
.into_iter()
.collect(),
Finder::AllNearby => self.find_test_instance_blocks(center, 250),
Finder::LookedAt => self.looked_at_test(center).into_iter().collect(),
Finder::Failed(_) | Finder::Selection { .. } => Vec::new(),
}
}
/// The tests a finder gives (`findTests`), by id.
fn finder_tests(&self, finder: &Finder) -> Vec<(String, TestDef)> {
let keys: Vec<String> = match finder {
Finder::Failed(only_required) => self
.game_tests
.failed
.iter()
.filter(|k| !only_required || self.test_def(k).is_some_and(|def| def.required))
.cloned()
.collect(),
Finder::Selection { tests, copies } => {
(0..*copies).flat_map(|_| tests.iter().cloned()).collect()
}
_ => Vec::new(),
};
keys.into_iter()
.filter_map(|k| self.test_def(&k).map(|def| (k, def)))
.collect()
}
/// `verifyStructureExists`.
fn verify_test_structure(&mut self, structure: &str) -> bool {
if self.template_entry(structure).is_some() {
return true;
}
self.test_failure(Text::translate(
"commands.test.error.structure_not_found",
&[Text::literal(structure.to_owned())],
));
false
}
/// `createGameTestInfo`: the test of a test instance block.
fn test_info_at(&mut self, pos: Pos) -> Option<Info> {
let key = self.test_instance_at(pos).and_then(|b| b.data.test.clone());
if self.test_instance_at(pos).is_none() {
self.test_failure(Text::translate(
"commands.test.error.test_instance_not_found.position",
&[pos.0, pos.1, pos.2].map(|v| Text::literal(v.to_string())),
));
return None;
}
let Some(def) = key.as_deref().and_then(|k| self.test_def(k)) else {
let name = self.test_name(pos);
self.test_failure(Text::translate(
"commands.test.error.non_existant_test",
&[name],
));
return None;
};
let key = key.unwrap_or_default();
let key = if key.contains(':') {
key
} else {
format!("minecraft:{key}")
};
if !self.verify_test_structure(&def.structure) {
return None;
}
let rotation = self.test_rotation(pos);
let id = self.game_tests.next_id();
Some(Info::new(id, key, def, Some(pos), self.dim, rotation))
}
/// `toGameTestInfo`: a test without a block, in the level in place.
fn test_info_for(&mut self, key: String, def: TestDef, steps: i32) -> R<Option<Info>> {
if !self.verify_test_structure(&def.structure) {
return Ok(None);
}
if def.dimension != self.dim.id() {
self.test_failure(Text::literal(format!(
"Could not resolve level for dimension: {}",
def.dimension
)));
return Ok(None);
}
let rotation = rotation_for_steps(steps).ok_or_else(|| err("command.failed", &[]))?;
let id = self.game_tests.next_id();
Ok(Some(Info::new(id, key, def, None, self.dim, rotation)))
}
/// `createTestPositionAround`: three blocks south of the source, on the
/// surface of the level.
fn test_position_around(&self) -> Pos {
let p = self.source_position();
let (x, z) = (p[0].floor() as i32, p[2].floor() as i32);
let facts = self.res.facts();
let surface = self.world.world_surface(x, z + 3, |s| facts.is_air(s));
(x, surface, z + 3)
}
/// `TestCommand.run`.
fn run_tests(&mut self, finder: &Finder, steps: i32, per_row: i32) -> R<i32> {
self.clear_game_tests();
let mut infos = Vec::new();
for pos in self.finder_positions(finder) {
infos.extend(self.test_info_at(pos));
}
for (key, def) in self.finder_tests(finder) {
infos.extend(self.test_info_for(key, def, steps)?);
}
if infos.is_empty() {
self.feedback_unbroadcast(Text::translate("commands.test.no_tests", &[]));
return Ok(0);
}
self.game_tests.failed.clear();
self.feedback_unbroadcast(Text::translate(
"commands.test.run.running",
&[Text::literal(infos.len().to_string())],
));
let grid = Grid::new(self.test_position_around(), per_row);
self.track_and_start_runner(infos, false, Some(grid));
Ok(1)
}
/// `TestCommand.reset`.
fn reset_tests(&mut self, finder: &Finder) -> R<i32> {
self.clear_game_tests();
let mut count = 0;
for pos in self.finder_positions(finder) {
if self.test_info_at(pos).is_some() {
// resetGameTestInfo: `TestInstanceBlockEntity.resetTest`.
self.reset_test(pos);
count += 1;
}
}
if count == 0 {
return Err(err("commands.test.clear.error.no_tests", &[]));
}
self.feedback(Text::translate(
"commands.test.reset.success",
&[Text::literal(count.to_string())],
));
Ok(count)
}
/// `TestCommand.clear`.
fn clear_tests(&mut self, finder: &Finder) -> R<i32> {
self.clear_game_tests();
let tests = self.finder_positions(finder);
for &pos in &tests {
if let Some(area) = self.test_bounding_box(pos) {
self.clear_test_space(&area);
}
self.remove_test_barriers(pos);
// level.destroyBlock(pos, false)
self.set_test_block_state(
pos,
"minecraft:air",
horizon_world::level::flags::UPDATE_ALL,
);
}
if tests.is_empty() {
return Err(err("commands.test.clear.error.no_tests", &[]));
}
self.feedback(Text::translate(
"commands.test.clear.success",
&[Text::literal(tests.len().to_string())],
));
Ok(tests.len() as i32)
}
/// The `/test` subcommands that run, reset and clear tests.
pub(super) fn cmd_test_runs(&mut self, id: &str, ctx: &Context) -> R<i32> {
let int = |name: &str| match ctx.get(name) {
Some(Arg::Int(v)) => Some(*v),
_ => None,
};
let has = |word: &str| ctx.literals.iter().any(|l| l == word);
let looks = has("runthat") || has("resetthat") || has("clearthat");
if looks {
self.source_player()?;
}
match id {
"test.stop" => {
self.clear_game_tests();
Ok(1)
}
"test.run" => {
let tests = match ctx.get("tests") {
Some(Arg::Str(pattern)) => horizon_client::command::args::select_resources(
self.ui.registries.ids("minecraft:test_instance"),
pattern,
)
.into_iter()
.cloned()
.collect(),
_ => Vec::new(),
};
let copies = if has("runmultiple") {
int("amount").unwrap_or(1)
} else {
1
};
let finder = Finder::Selection { tests, copies };
self.run_tests(
&finder,
int("rotationSteps").unwrap_or(0),
int("testsPerRow").unwrap_or(8),
)
}
"test.run.found" => {
let finder = if has("runclosest") {
Finder::Nearest
} else if has("runthat") {
Finder::LookedAt
} else if has("runfailed") {
let only = matches!(ctx.get("onlyRequiredTests"), Some(Arg::Bool(true)));
Finder::Failed(only)
} else {
Finder::AllNearby
};
self.run_tests(
&finder,
int("rotationSteps").unwrap_or(0),
int("testsPerRow").unwrap_or(8),
)
}
_ => {
let finder = if has("resetclosest") {
Finder::Nearest
} else if has("resetthese") || has("clearthese") {
Finder::AllNearby
} else if looks {
Finder::LookedAt
} else {
// clearall [radius]: clamped to 0..=1024, 250 by default.
Finder::Radius(int("radius").map_or(250, |r| r.clamp(0, 1024)))
};
if id == "test.clear"
&& (has("resetclosest") || has("resetthese") || has("resetthat"))
{
self.reset_tests(&finder)
} else {
self.clear_tests(&finder)
}
}
}
}
}
/// `AABB.clip(from, to).isPresent()`: the segment meets the box.
fn ray_hits_box(from: [f64; 3], to: [f64; 3], min: [f64; 3], max: [f64; 3]) -> bool {
let (mut near, mut far) = (0.0f64, 1.0f64);
for i in 0..3 {
let d = to[i] - from[i];
if d.abs() < 1.0e-7 {
if from[i] < min[i] || from[i] > max[i] {
return false;
}
continue;
}
let (a, b) = ((min[i] - from[i]) / d, (max[i] - from[i]) / d);
let (lo, hi) = if a < b { (a, b) } else { (b, a) };
near = near.max(lo);
far = far.min(hi);
if near > far {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rotation_steps_run_from_zero_to_three() {
assert_eq!(rotation_for_steps(0), Some(Rotation::None));
assert_eq!(rotation_for_steps(1), Some(Rotation::Clockwise90));
assert_eq!(rotation_for_steps(3), Some(Rotation::Counterclockwise90));
assert_eq!(rotation_for_steps(4), None);
assert_eq!(rotation_for_steps(-1), None);
}
#[test]
fn a_ray_meets_a_box_it_crosses_and_misses_one_beside_it() {
let (min, max) = ([0.0, 0.0, 0.0], [2.0, 2.0, 2.0]);
assert!(ray_hits_box([-5.0, 1.0, 1.0], [5.0, 1.0, 1.0], min, max));
assert!(!ray_hits_box([-5.0, 3.0, 1.0], [5.0, 3.0, 1.0], min, max));
assert!(!ray_hits_box([-5.0, 1.0, 1.0], [-1.0, 1.0, 1.0], min, max));
}
}
@@ -3,6 +3,59 @@ use super::{Arg, Context, Game, R, Target, err};
use horizon_client::{hud_overlays::Titles, lan::data::component_nbt, text::Text};
use horizon_protocol::packets::play::{self, Clientbound as C};
/// `EntitySelector.COMPILABLE_CODEC` (`CompilableString.CommandParserHelper.apply`):
/// the `selector` of a selector component and the `entity` of an nbt
/// component are parsed when the component is decoded, and text left after
/// the selector is `command.trailing_data`. Either failure makes the whole
/// component invalid, with vanilla's message. (A score `name` is an `Either`
/// that falls back to a plain name, so it never fails.)
fn invalid_selector(
v: &serde_json::Value,
reg: &horizon_client::command::Registries,
) -> Option<String> {
match v {
serde_json::Value::Array(a) => a.iter().find_map(|x| invalid_selector(x, reg)),
serde_json::Value::Object(o) => {
let source = o.get("selector").and_then(|s| s.as_str()).or_else(|| {
o.contains_key("nbt")
.then(|| o.get("entity").and_then(|s| s.as_str()))
.flatten()
});
if let Some(source) = source
&& let Some(why) = compile_selector(source, reg)
{
return Some(format!("Invalid selector component: {source}: {why}"));
}
o.values().find_map(|x| invalid_selector(x, reg))
}
_ => None,
}
}
/// `EntitySelectorParser.parse` on all of `source`; the error's
/// `getMessage` on failure.
fn compile_selector(source: &str, reg: &horizon_client::command::Registries) -> Option<String> {
let mut reader = horizon_client::command::Reader::new(source);
match horizon_client::command::selector::parse(&mut reader, reg, true) {
Err(e) => Some(match e.context() {
Some(context) => format!("{} at position {}: {context}", e.message.plain(), e.cursor),
None => e.message.plain(),
}),
Ok(_) if reader.can_read() => {
let parsed = &source[..reader.cursor];
let leftovers = &source[reader.cursor..];
Some(
horizon_client::text::Text::tr(
"command.trailing_data",
&[&format!("{parsed}[{leftovers}]")],
)
.plain(),
)
}
Ok(_) => None,
}
}
impl Game<'_> {
/// ComponentArgument with selectors resolved for the source
/// (ComponentUtils.updateForEntity: names joined by ", ").
@@ -12,6 +65,9 @@ impl Game<'_> {
};
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| err("argument.component.invalid", &[&e]))?;
if let Some(message) = invalid_selector(&value, &self.ui.registries) {
return Err(err("argument.component.invalid", &[&message]));
}
// NbtContents.resolve: the path's values in the entity, block or
// storage data, rich-styled (or plain, or interpreted), ", "-joined.
// ScoreContents.resolve: the holder's score in the objective.
@@ -301,3 +357,46 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::invalid_selector;
use horizon_client::command::Registries;
fn check(json: &str) -> Option<String> {
invalid_selector(&serde_json::from_str(json).unwrap(), &Registries::default())
}
#[test]
fn a_selector_component_is_compiled_when_decoded() {
assert_eq!(check(r#"{"selector":"@a"}"#), None);
let bad = check(r#"{"selector":"@a[x"}"#).unwrap();
assert!(
bad.starts_with("Invalid selector component: @a[x: "),
"{bad}"
);
}
#[test]
fn text_after_the_selector_is_trailing_data() {
let bad = check(r#"["x",{"extra":[{"selector":"@s foo"}]}]"#).unwrap();
assert!(
bad.starts_with("Invalid selector component: @s foo: "),
"{bad}"
);
assert!(
bad.contains("command.trailing_data") || bad.contains("Trailing"),
"{bad}"
);
}
#[test]
fn an_nbt_entity_is_compiled_and_a_score_name_is_not() {
assert!(check(r#"{"nbt":"a","entity":"@e[type"}"#).is_some());
assert_eq!(check(r#"{"nbt":"a","storage":"x:y"}"#), None);
assert_eq!(
check(r#"{"score":{"name":"@a foo","objective":"o"}}"#),
None
);
}
}
@@ -240,27 +240,43 @@ impl Game<'_> {
return Ok(0);
};
let current = self.target_rotation(t);
let mut look_at_target = None;
let rot = if let Some(Arg::Coords(c)) = ctx.get("rotation") {
// Relative parts are offsets from the entity's own rotation.
c.rotation(current)
} else {
let mut faced_entity = None;
let to = if ctx.has("entity") {
let faced = self.entities(ctx, "facingEntity", false)?;
let Some(f) = faced.first().copied() else {
return Ok(0);
};
let mut p = self.target_position(f).unwrap_or(pos);
if matches!(ctx.get("facingAnchor"), Some(Arg::Anchor(true))) {
let eyes = matches!(ctx.get("facingAnchor"), Some(Arg::Anchor(true)));
if eyes {
p[1] += self.target_eye_height(f);
}
faced_entity = Some((f, eyes));
p
} else {
self.position_arg(ctx, "facingLocation").unwrap_or(pos)
};
let from = [pos[0], pos[1] + self.target_eye_height(t), pos[2]];
// LookAt.perform: Entity.lookAt from the source's anchor (the
// feet, unless `anchored eyes`).
let anchored_eyes = self.command_source.is_some_and(|s| s.eyes);
let eye = if anchored_eyes {
self.target_eye_height(t)
} else {
0.0
};
let from = [pos[0], pos[1] + eye, pos[2]];
look_at_target = Some((anchored_eyes, to, faced_entity));
look_at(from, to)
};
self.teleport_to(t, pos, Some(rot));
if let Some((eyes, to, entity)) = look_at_target {
self.send_player_look_at(t, eyes, to, entity);
}
if t == Target::Player {
self.pitch = rot[1].clamp(-90.0, 90.0);
}
+13 -2
View File
@@ -348,7 +348,7 @@ impl Game<'_> {
// Text screens consume raw presses; releases follow key binds.
let typing = matches!(
self.screen,
Screen::Chat | Screen::Options | Screen::CommandBlock
Screen::Chat | Screen::Options | Screen::CommandBlock | Screen::BlockEditor
);
let key = if typing && arg(2)? != "up" {
key
@@ -629,6 +629,7 @@ impl Game<'_> {
match self.screen {
Screen::Chat => self.chat_type(text),
Screen::CommandBlock => self.command_block_type(text),
Screen::BlockEditor => self.block_editor_type(text),
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
// The port, or the game rules' search and number boxes.
Screen::WorldOptions if text == "\\b" => self.world_options_text(None),
@@ -819,7 +820,12 @@ impl Game<'_> {
"dragon-hurt" => {
// Test hook: damage the dragon's head as the player would.
let amount: f32 = arg(1)?.parse()?;
self.hit_special(super::dragon::SPECIAL, amount, true, false);
self.hit_special(
super::dragon::SPECIAL,
amount,
true,
horizon_client::mobs::DamageKind::PlayerAttack,
);
ok()
}
"quit" => {
@@ -866,6 +872,10 @@ impl Game<'_> {
self.command_block_key_code(key, None, None);
return Ok(());
}
if self.screen == Screen::BlockEditor {
self.block_editor_key_code(key, None, None);
return Ok(());
}
if self.keys.insert(key) {
self.key_pressed(key)?;
}
@@ -896,6 +906,7 @@ impl Game<'_> {
Screen::ConfirmLink => "confirm-link",
Screen::DialogWarning => "dialog-warning",
Screen::CommandBlock => "command-block",
Screen::BlockEditor => "block-editor",
Screen::Win => "win",
}
}
+163 -25
View File
@@ -269,6 +269,28 @@ impl Game<'_> {
sleeping: self.sleeping.is_some(),
sleep_timer: self.sleep_timer,
passenger: self.riding.is_some(),
vehicle: self.riding.and_then(|id| {
if let Some(b) = self.boats.iter().find(|b| b.id == id) {
// A boat's box: 1.375 wide and 0.5625 high around its position.
let w = 0.6875;
return Some(horizon_client::mobs::Vehicle {
nautilus: false,
bounds: Aabb::new(
Vec3::new(b.position.x - w, b.position.y, b.position.z - w),
Vec3::new(b.position.x + w, b.position.y + 0.5625, b.position.z + w),
),
velocity: b.velocity,
});
}
let m = self.mobs.iter().find(|m| m.id == id)?;
matches!(m.species(), Species::Nautilus | Species::ZombieNautilus).then(|| {
horizon_client::mobs::Vehicle {
nautilus: true,
bounds: m.bounds(),
velocity: m.velocity,
}
})
}),
})
}
/// Biome facts at a position for finalizeSpawn and variant rules.
@@ -529,6 +551,7 @@ impl Game<'_> {
nearest_player: player.as_ref().filter(|p| !p.spectator).map(|p| p.position),
player,
others,
leash_boats: &self.leash_boats,
others_grid: None,
others_index: None,
daylight: self.dim != Dim::Nether && self.sky_darken() < 4,
@@ -670,6 +693,12 @@ impl Game<'_> {
_ => Vec::new(),
};
let mut guest_events = Vec::new();
// The block writes since the last tick: mobs re-path around them.
let path_writes = self
.world
.path_writes
.get_or_insert_with(Vec::new)
.split_off(0);
// The mobs whose bodies go this tick: (id, feet, width, height).
let mut gone = Vec::new();
let level_random;
@@ -683,6 +712,11 @@ impl Game<'_> {
horizon_client::mobs::seen_grid::SeenGrid::new(snapshot.iter().map(|s| s.bounds));
// All but the snapshot is the same for every mob of the tick, so
// build it once: the player view and the teams scan every UUID.
self.leash_boats = self
.boats
.iter()
.map(super::boat_leads::boat_state)
.collect();
let base = self.mob_world_base(&[]);
// Each mob keeps its snapshot slot all tick; the first slot of an
// id answers, as a scan would.
@@ -744,6 +778,7 @@ impl Game<'_> {
continue;
}
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
horizon_client::mobs::blocks_changed(m, &w, &path_writes);
let from = events.len();
let kept = m.tick(&w, &mut scratch, &mut events);
// A killed mob's body is removed: its effects that act on
@@ -915,7 +950,14 @@ impl Game<'_> {
};
if let Some(who) = who {
self.for_player(who, |g| {
g.trigger_player("minecraft:kill_mob_near_sculk_catalyst")
// CatalystListener: trigger(player, killedEntity, damageSource).
let view = g.mob_entity(m);
let blow = m.last_damage.map(|(d, _)| g.damage_view(d));
g.trigger_killed_as(
"minecraft:kill_mob_near_sculk_catalyst",
&view,
blow.as_ref(),
);
});
}
}
@@ -992,7 +1034,7 @@ impl Game<'_> {
}
/// A mob's damage source as advancement predicates see it: its type, the
/// causing entity, and the projectile when one dealt it.
fn damage_view(&self, d: Damage) -> horizon_client::advancements::DamageView {
pub(super) fn damage_view(&self, d: Damage) -> horizon_client::advancements::DamageView {
let source = match d.attacker {
Some(Who::Player) => Some(self.player_entity()),
Some(Who::Mob(id)) => self
@@ -1368,6 +1410,20 @@ impl Game<'_> {
self.mob_hits_player(amount, from, "minecraft:mob_attack");
}
// doHurtTarget: the effect only when hurtServer took damage.
// Bee.doHurtTarget: a sting that hurt leaves the stinger in the
// body, then the poison.
MobEvent::StingPlayer {
amount,
from,
poison_ticks,
} => {
if self.mob_hits_player(amount, from, "minecraft:mob_attack") {
self.combat.stuck.add_stinger();
if poison_ticks > 0 {
self.give_player_effect("minecraft:poison", poison_ticks, 0);
}
}
}
MobEvent::AttackPlayerWithEffect {
amount,
from,
@@ -1467,6 +1523,17 @@ impl Game<'_> {
}
self.combat.explosion_source = source_mob;
self.combat.explosion_source_named = noted;
self.combat.explosion_cause = match species {
Species::Creeper => Some("minecraft:creeper"),
Species::Wither => Some("minecraft:wither"),
Species::SulfurCube => Some("minecraft:sulfur_cube"),
_ => None,
}
.map(|kind| horizon_client::advancements::EntityView {
kind: kind.into(),
position: at,
..Default::default()
});
// Only a creeper or a wither empties the hives it blows up.
// The creeper is already gone from the list, so the event
// carries its kind.
@@ -1477,6 +1544,7 @@ impl Game<'_> {
};
self.combat.explosion_source = None;
self.combat.explosion_source_named = false;
self.combat.explosion_cause = None;
result?
}
MobEvent::AttackMob {
@@ -1725,7 +1793,12 @@ impl Game<'_> {
}
}
MobEvent::LevelEvent { id, pos, data } => self.level_event_at(id, pos, data),
MobEvent::SetBlockName { pos, name, effect } => {
MobEvent::SetBlockName {
pos,
name,
effect,
flags,
} => {
if effect {
let old = self.world.block(pos[0], pos[1], pos[2]);
self.destroy_particles(pos, old);
@@ -1742,7 +1815,7 @@ impl Game<'_> {
);
}
if let Some(state) = self.pack.assets.registry.default_state(name) {
self.world.set(pos[0], pos[1], pos[2], state)?;
self.level_set_with_flags(pos, state, flags)?;
}
}
MobEvent::Infest {
@@ -1760,7 +1833,7 @@ impl Game<'_> {
};
state.name = state.name.replacen(from, to, 1);
if let Ok(id) = registry.resolve(&state) {
self.world.set(pos[0], pos[1], pos[2], id)?;
self.level_set(pos, id)?;
}
}
if let Some(mob) = poof {
@@ -1886,7 +1959,7 @@ impl Game<'_> {
.iter()
.find(|m| m.id == mob)
.map(|m| self.mob_entity(m));
self.trigger_effects_changed(source);
self.trigger_effects_changed_by(source);
}
MobEvent::DolphinTreasure { mob } => {
// ServerLevel.findNearestMapStructure(#dolphin_located, 50).
@@ -1956,6 +2029,11 @@ impl Game<'_> {
// Applied after the fish's tick (`apply_brain_events`).
MobEvent::StartFollowing { .. } | MobEvent::SchoolSize { .. } => {}
MobEvent::LeashBreak { mob, drop, sound } => self.break_leash(mob, drop, sound),
MobEvent::NotifyLeashHolder { holder } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == holder) {
horizon_client::mobs::happy_ghast::notify_leash_holder(m);
}
}
MobEvent::Vibration { from, to, ticks } => {
self.mob_vibration_particle(from, to, ticks)
}
@@ -2194,7 +2272,7 @@ impl Game<'_> {
let mut child = parent.clone();
child.baby = true;
// TurtleBreedGoal.breed triggers it without a child.
let turtle = p.species() == Species::Turtle;
let turtle = matches!(p.species(), Species::Turtle | Species::Frog);
if matches!(
(
p.species(),
@@ -2230,7 +2308,7 @@ impl Game<'_> {
}
// TurtleBreedGoal.breed: the goal gave the mother her egg; only the
// experience is left, and only with mob loot on.
if p.species() == Species::Turtle {
if matches!(p.species(), Species::Turtle | Species::Frog) {
// One `new ExperienceOrb` of 1 to 7 from the mother's random (not
// split up as `ExperienceOrb.award` does).
if self.ui.rules.bool("mob_drops")
@@ -2991,6 +3069,15 @@ impl Game<'_> {
self.flush_lan_explosions();
self.flush_wind_bursts()
}
/// `LivingEntity.hurtServer`'s `setLastHurtByPlayer` for a projectile a
/// player owns (a fireball they turned back): the kill is theirs, so a
/// guest's (the host on its server thread too) reaches their statistics
/// and `player_killed_entity`, as the arrow's hit does.
fn player_hurt_mob(&mut self, p: &Projectile, id: u32) {
if p.owner == Some(Who::Player) {
self.last_hurt_by_player.insert(id, self.lan_acting);
}
}
/// `projectile_hit` for a projectile the guest `conn` shot, run as them;
/// as the host's when they have left.
fn guest_projectile_hit(&mut self, conn: u32, p: &Projectile, hit: Hit) -> Result<()> {
@@ -3358,7 +3445,12 @@ impl Game<'_> {
// it, else turned back (a perched dragon sets it alight first).
let mut arrow = p.clone();
let amount = self.shot_damage(&mut arrow, None);
let outcome = match self.hit_special(id, amount, by_player, true) {
let outcome = match self.hit_special(
id,
amount,
by_player,
horizon_client::mobs::DamageKind::Arrow,
) {
super::dragon::Hurt::Landed { .. } => EntityHit::Hurt,
super::dragon::Hurt::Refused => EntityHit::Refused,
super::dragon::Hurt::Shrugged => {
@@ -3375,7 +3467,13 @@ impl Game<'_> {
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => 6.0,
_ => 0.0,
};
self.hit_special(id, amount, by_player, false);
let kind = match p.kind {
ProjectileKind::SmallFireball | ProjectileKind::LargeFireball { .. } => {
horizon_client::mobs::DamageKind::Fireball
}
_ => horizon_client::mobs::DamageKind::Thrown,
};
self.hit_special(id, amount, by_player, kind);
// ThrowableProjectile.onHit goes on after onHitEntity: a pearl
// still takes its owner to where it broke (into the crystal's
// blast), an egg still hatches, a snowball still breaks.
@@ -3464,11 +3562,19 @@ impl Game<'_> {
}])?;
}
let hurt = match who {
Who::Player => self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:arrow",
),
Who::Player => {
let hurt = self.mob_hits_player(
amount,
[p.previous.x, p.previous.y, p.previous.z],
"minecraft:arrow",
);
// AbstractArrow.onHitEntity: a hit that hurt leaves a
// non-piercing arrow stuck in the body.
if hurt && p.arrow.as_ref().is_some_and(|a| a.pierce == 0) {
self.combat.stuck.add_arrow();
}
hurt
}
Who::Mob(id) => {
let hurt = self
.mobs
@@ -3714,14 +3820,19 @@ impl Game<'_> {
}
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
let hurt = self.mobs.iter_mut().find(|m| m.id == id).is_some_and(|m| {
let remaining = m.fire_ticks;
if !m.kind.flags.fire_immune {
m.ignite_for_ticks(100);
}
if !m.damage(5.0, source) {
let hurt = m.damage(5.0, source);
if !hurt {
m.fire_ticks = remaining;
}
hurt
});
if hurt {
self.player_hurt_mob(p, id);
}
}
},
@@ -3756,8 +3867,13 @@ impl Game<'_> {
);
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.damage(6.0, source);
if self
.mobs
.iter_mut()
.find(|m| m.id == id)
.is_some_and(|m| m.damage(6.0, source))
{
self.player_hurt_mob(p, id);
}
}
}
@@ -5023,7 +5139,11 @@ impl Game<'_> {
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
if mob.invisible && outline.is_none() {
// A player keeps its armour, elytra and held items
// (`LivingEntityRenderer.submit` skips only the body).
let is_player = &*mob.kind.id == "minecraft:player";
let layers_only = mob.invisible && outline.is_none() && is_player;
if mob.invisible && outline.is_none() && !layers_only {
continue;
}
// LevelRenderer: the entity the view looks out of is not drawn
@@ -5088,12 +5208,17 @@ impl Game<'_> {
);
}
// Other players on a server: PlayerRenderer's HumanoidArmorLayer.
let armor = if &*mob.kind.id == "minecraft:player" {
let armor = if is_player {
self.worn_armor_layers(mob)
} else {
Vec::new()
};
horizon_client::mobs::render::render_with(
let render = if layers_only {
horizon_client::mobs::render::render_layers
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
@@ -5103,12 +5228,24 @@ impl Game<'_> {
&armor,
out,
);
if &*mob.kind.id == "minecraft:player" {
if is_player {
// PlayerRenderer's CapeLayer, WingsLayer and SpinAttackEffectLayer.
// Only the cape hides with the body; WingsLayer and
// SpinAttackEffectLayer have no `isInvisible` check.
let chest = mob.extra.chest.as_deref();
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
if !layers_only {
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
}
if let Some((_, name)) = self
.remote_player_names()
.into_iter()
.find(|(id, _)| *id == mob.id)
{
self.player_ears_geometry(mob, &name, camera, alpha as f32, packed, out);
}
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
// CustomHeadLayer (a pumpkin, skull or head item) included.
self.mob_block_geometry(mob, camera, alpha, packed, out);
@@ -5263,7 +5400,7 @@ impl Game<'_> {
a.kind,
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
) {
self.wind_charge_geometry(a, c, packed, out);
self.wind_charge_geometry(a, c, packed, alpha as f32, out);
continue;
}
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
@@ -5770,6 +5907,7 @@ mod pearl_tests {
player: None,
nearest_player: None,
others: &[],
leash_boats: &[],
others_grid: None,
others_index: None,
daylight: true,
+42 -4
View File
@@ -66,8 +66,17 @@ impl horizon_client::cushion::World for PlacementWorld<'_, '_> {
.collect()
}
fn suffocating(&self, p: [i32; 3]) -> bool {
let state = self.0.world.block(p[0], p[1], p[2]);
let facts = self.0.res.facts().get(state);
self.0
.state_suffocating(self.0.world.block(p[0], p[1], p[2]))
}
}
impl Game<'_> {
/// `BlockBehaviour.Properties.isSuffocating` of a state: the block's
/// explicit predicate, else `causes_suffocation` with a full collision
/// shape.
pub(super) fn state_suffocating(&self, state: horizon_world::registry::BlockStateId) -> bool {
let facts = self.res.facts().get(state);
let name = facts.name.as_ref();
// Blocks' explicit predicates, then the default causes_suffocation/full-shape rule.
match name {
@@ -76,7 +85,6 @@ impl horizon_client::cushion::World for PlacementWorld<'_, '_> {
| "minecraft:mud"
| "minecraft:soul_sand" => true,
"minecraft:piston" | "minecraft:sticky_piston" => self
.0
.pack
.assets
.registry
@@ -95,13 +103,43 @@ impl horizon_client::cushion::World for PlacementWorld<'_, '_> {
_ => {
facts.full_collision
&& self
.0
.res
.items()
.block_in_tag("minecraft:causes_suffocation", name)
}
}
}
/// `BlockState.isViewBlocking(level, pos, nearPlaneBox)`: stairs and
/// slabs (`NEAR_PLANE_INTERSECTS_OUTLINE`) block when the near plane
/// touches a box of their shape, snow only at eight layers, anything else
/// when it is suffocating.
pub(super) fn state_view_blocking(
&self,
state: horizon_world::registry::BlockStateId,
pos: [i32; 3],
near_plane: Aabb,
) -> bool {
let name = self.res.facts().get(state).name.as_ref();
if name == "minecraft:snow" {
return self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("layers"))
.is_some_and(|l| l.parse::<i32>().is_ok_and(|l| l >= 8));
}
if (name.ends_with("_stairs") || name.ends_with("_slab"))
&& name != "minecraft:petrified_oak_slab"
{
return self
.outline_boxes(pos)
.iter()
.any(|b| aabb(([b[0], b[1], b[2]], [b[3], b[4], b[5]])).intersects(near_plane));
}
self.state_suffocating(state)
}
}
impl Game<'_> {
+7 -2
View File
@@ -93,7 +93,11 @@ impl Game<'_> {
// releases still reach KeyboardHandler.
let typing = matches!(
self.screen,
Screen::Chat | Screen::Options | Screen::WorldOptions | Screen::CommandBlock
Screen::Chat
| Screen::Options
| Screen::WorldOptions
| Screen::CommandBlock
| Screen::BlockEditor
) || self.sign_edit.is_some()
|| self.book.is_some()
|| (self.screen == Screen::Creative && self.creative_screen.tab().search)
@@ -852,8 +856,9 @@ impl Game<'_> {
self.clipboard.set(text);
}
/// The debug renderers' gizmos for a frame (`DebugRenderer.emitGizmos`).
pub(super) fn debug_gizmos(&self, alpha: f32) -> Gizmos {
pub(super) fn debug_gizmos(&self, alpha: f32, camera: [f64; 3]) -> Gizmos {
let mut g = Gizmos::default();
self.bounding_box_gizmos(camera, &mut g);
if self.show_chunk_borders
&& let Some(p) = &self.player
{
+75 -12
View File
@@ -165,6 +165,29 @@ pub(super) fn player_joined(id: u128, name: &str) {
}
}
/// `server.toml`'s `[anticheat]`, beside server.properties when horizon-server runs the game; off
/// without the file or the section, or (with an error logged) when the section is wrong.
fn load_anticheat() -> horizon_anticheat::Config {
let Ok(text) = std::fs::read_to_string("server.toml") else {
return Default::default();
};
match horizon_anticheat::Config::from_server_toml(&text) {
Ok(config) => {
if config.enabled {
log("INFO", "Anticheat on (server.toml [anticheat])");
}
config
}
Err(e) => {
log(
"ERROR",
&format!("server.toml: {e}; the anticheat stays off"),
);
Default::default()
}
}
}
/// Log4j's console pattern: `[HH:mm:ss] [thread/LEVEL]: message`.
pub(super) fn log(level: &str, message: &str) {
log_from("Server thread", level, message);
@@ -252,6 +275,9 @@ pub(super) fn preflight(options: &mut Options) -> Result<bool> {
for w in &props.warnings {
log("WARN", w);
}
// A dedicated server's tree: /op, /ban, /whitelist... (single player's
// server thread is an integrated one and never gets here).
horizon_client::command::vanilla::set_dedicated_commands(true);
// Horizon's own commands are the owner's choice, off as on vanilla.
if props
.raw
@@ -312,6 +338,8 @@ pub(super) struct Lists {
pub ip_bans: IpBanList,
pub use_whitelist: bool,
pub enforce_whitelist: bool,
/// `op-permission-level`: what `/op` grants (operatorUserPermissions).
pub op_level: i32,
}
impl Lists {
fn load() -> Self {
@@ -322,6 +350,7 @@ impl Lists {
ip_bans: IpBanList::new("banned-ips.json"),
use_whitelist: false,
enforce_whitelist: false,
op_level: 4,
};
// DedicatedPlayerList's constructor order and its LOGGER.warn lines.
let warn = |what: &str, r: std::result::Result<(), String>| {
@@ -660,7 +689,9 @@ impl Game<'_> {
let mut lists = Lists::load();
lists.use_whitelist = props.white_list;
lists.enforce_whitelist = props.enforce_whitelist;
lists.op_level = props.op_permission_level;
let lists = Arc::new(Mutex::new(lists));
self.server_lists = Some(lists.clone());
log("INFO", &format!("Preparing level \"{level_name}\""));
let folder = world_dir.to_string_lossy().into_owned();
self.native_lifecycle_open_started();
@@ -701,6 +732,13 @@ impl Game<'_> {
gate_lists.lock().unwrap().can_login(address, &user)
});
let bypass_lists = lists.clone();
// DedicatedServer's constructor: `enable-code-of-conduct` reads the
// `codeofconduct` folder, and a folder it can't read stops the start.
let code_of_conducts = if props.code_of_conduct {
horizon_client::lan::code_of_conduct::read(std::path::Path::new("codeofconduct"))?
} else {
Vec::new()
};
let favicon = std::fs::read("server-icon.png")
.ok()
.filter(|b| png_size(b) == Some((64, 64)));
@@ -737,6 +775,7 @@ impl Game<'_> {
log_ips: props.log_ips,
}),
resource_pack: props.resource_pack.clone(),
code_of_conducts,
// DedicatedServer.serverLinks: `bug-report-link` as the only link
// (ServerLinks.KnownLinkType.BUG_REPORT is id 0).
levels: self.level_shapes,
@@ -758,8 +797,10 @@ impl Game<'_> {
);
Vec::new()
},
owner_profile: None,
};
let config = HubConfig {
allow_flight: props.allow_flight,
default_game_mode: game_mode,
guest_commands: true,
force_game_mode: props.force_game_mode,
@@ -785,7 +826,9 @@ impl Game<'_> {
return Ok(());
}
self.sync_ops(&lists);
let anticheat = load_anticheat();
if let Some(host) = self.lan.as_mut() {
host.hub.set_anticheat(anticheat);
host.hub.spawn_protection = props.spawn_protection;
host.hub.chat_spam_threshold = props.chat_spam_threshold_seconds.wrapping_mul(20);
host.hub.command_spam_threshold = props.command_spam_threshold_seconds.wrapping_mul(20);
@@ -874,6 +917,13 @@ impl Game<'_> {
{
self.finish_report(recording, state.view, state.max_players);
}
// DedicatedServer.sendLowDiskSpaceWarning: the operators hear of it
// (no window drains the toasts here).
if horizon_client::chunk_io::take().low_disk_space
&& let Some(host) = self.lan.as_ref()
{
host.hub.send_low_disk_space_warning();
}
if idle_timeout > 0 {
self.kick_idle(idle_timeout);
}
@@ -980,7 +1030,7 @@ impl Game<'_> {
}
/// Ops' permission levels for the hub (PlayerList.getProfilePermissions).
fn sync_ops(&mut self, lists: &Arc<Mutex<Lists>>) {
pub(super) fn sync_ops(&mut self, lists: &Arc<Mutex<Lists>>) {
let Some(host) = self.lan.as_mut() else {
return;
};
@@ -996,7 +1046,7 @@ impl Game<'_> {
}
/// A player on the server, by name.
fn online_player(&self, name: &str) -> Option<NameAndId> {
pub(super) fn online_player(&self, name: &str) -> Option<NameAndId> {
let hub = &self.lan.as_ref()?.hub;
let g = hub
.guests
@@ -1012,7 +1062,18 @@ impl Game<'_> {
/// service, else (offline) the offline player. None, told to the console:
/// no such player.
fn resolve(&self, name: &str) -> Option<NameAndId> {
let found = USER_CACHE.lock().unwrap().as_mut().and_then(|cache| {
let found = self.resolve_name(name);
if found.is_none() {
let unknown = Text::translate("argument.player.unknown", &[]);
log("INFO", &format!("System chat: {}", unknown.plain()));
}
found
}
/// `Services.nameToIdCache().get(name)`: the user cache, else the profile
/// service, else (offline) the offline player.
pub(super) fn resolve_name(&self, name: &str) -> Option<NameAndId> {
USER_CACHE.lock().unwrap().as_mut().and_then(|cache| {
cache.get_by_name(name, |lower| {
let found = profiles::find_by_name(&profiles::discovery_url(), lower);
if found.is_none() {
@@ -1020,12 +1081,7 @@ impl Game<'_> {
}
found
})
});
if found.is_none() {
let unknown = Text::translate("argument.player.unknown", &[]);
log("INFO", &format!("System chat: {}", unknown.plain()));
}
found
})
}
/// MinecraftServer.sendSystemMessage: what the console is told; while
@@ -1475,8 +1531,15 @@ impl Game<'_> {
}
}
/// `DedicatedServer.setUsingWhitelist`: the flag, and `white-list` in
/// server.properties.
pub(super) fn set_using_whitelist(&mut self, lists: &Arc<Mutex<Lists>>, on: bool) {
lists.lock().unwrap().use_whitelist = on;
self.store_property("white-list", on.to_string());
}
/// DedicatedServerSettings.update: change one key and store the file.
fn store_property(&mut self, key: &str, value: String) {
pub(super) fn store_property(&mut self, key: &str, value: String) {
let path = PathBuf::from("server.properties");
if let Ok(p) = ServerProperties::from_file(&path, &mut SystemEntropy::default())
&& let Ok(p) = p.update(key, value)
@@ -1488,7 +1551,7 @@ impl Game<'_> {
/// MinecraftServer.kickUnlistedPlayers: with `enforce-whitelist` and the
/// whitelist on, whoever is online and not on the whitelist file is
/// disconnected (operators are not spared here).
fn kick_unlisted_players(&mut self, lists: &Arc<Mutex<Lists>>) {
pub(super) fn kick_unlisted_players(&mut self, lists: &Arc<Mutex<Lists>>) {
let unlisted: Vec<u128> = {
let l = lists.lock().unwrap();
let Some(host) = self.lan.as_ref() else {
@@ -1517,7 +1580,7 @@ impl Game<'_> {
}
}
fn kick_uuid(&mut self, uuid: u128, reason: Tag) {
pub(super) fn kick_uuid(&mut self, uuid: u128, reason: Tag) {
if let Some(h) = self.lan.as_mut() {
let conns: Vec<u32> = h
.hub
@@ -190,6 +190,7 @@ impl Game<'_> {
"players": players,
"levels": self.report_levels(),
"memory": { "rss_mb": horizon_bench::rss_bytes(std::process::id()).map(|b| b >> 20) },
"process": process_limits(),
"cpu_cores_used": cpu,
"config": {
"view_distance": view.0,
@@ -265,9 +266,49 @@ fn summary(r: &Value) -> String {
"{mobs} mobs, {chunks} chunks, {} MB resident, {} cores of CPU",
r["memory"]["rss_mb"], r["cpu_cores_used"]
);
let process = &r["process"];
if let Some(threads) = process["threads"].as_u64() {
out += &format!("\n{threads} threads");
for (key, name) in [
("cgroup_pids_current", "container tasks"),
("cgroup_pids_max", "container task limit"),
("max_processes", "max processes (soft / hard)"),
] {
if let Some(value) = process[key].as_str() {
out += &format!(", {name} {value}");
}
}
}
out
}
/// What the system lets this process start: its threads now, and the limits
/// a container puts on them and on memory. A server out of threads fails to
/// start the ones it needs (EAGAIN). Read on Linux; null elsewhere.
fn process_limits() -> Value {
let read = |path: &str| std::fs::read_to_string(path).ok();
let threads = read("/proc/self/status").and_then(|s| {
s.lines()
.find_map(|l| l.strip_prefix("Threads:"))
.and_then(|v| v.trim().parse::<u64>().ok())
});
// "Max processes <soft> <hard> processes"
let max_processes = read("/proc/self/limits").and_then(|s| {
let line = s.lines().find(|l| l.starts_with("Max processes"))?;
let words: Vec<&str> = line.split_whitespace().skip(2).take(2).collect();
Some(words.join(" / "))
});
let cgroup = |name: &str| read(&format!("/sys/fs/cgroup/{name}")).map(|s| s.trim().to_owned());
json!({
"threads": threads,
"max_processes": max_processes,
"cgroup_pids_current": cgroup("pids.current"),
"cgroup_pids_max": cgroup("pids.max"),
"cgroup_memory_current": cgroup("memory.current"),
"cgroup_memory_max": cgroup("memory.max"),
})
}
fn write_report(dir: &Path, report: &Value, text: &str) -> std::io::Result<PathBuf> {
std::fs::create_dir_all(dir)?;
let stamp = chrono::Utc::now().format("%Y-%m-%d_%H-%M-%SZ");
@@ -329,5 +370,18 @@ mod tests {
text.ends_with("5 mobs, 9 chunks, 300 MB resident, 1.5 cores of CPU"),
"{text}"
);
let text = summary(
&json!({"ticks": ticks, "spans": spans, "worst_ticks": worst,
"players": 3, "levels": [], "memory": {"rss_mb": 300}, "cpu_cores_used": 1.5,
"process": {"threads": 140, "cgroup_pids_current": "160", "cgroup_pids_max": "512",
"max_processes": "unlimited / unlimited"}}),
);
assert!(
text.ends_with(
"140 threads, container tasks 160, container task limit 512, \
max processes (soft / hard) unlimited / unlimited"
),
"{text}"
);
}
}
+174 -23
View File
@@ -9,8 +9,10 @@ use horizon_client::{
clipboard::EditShortcut,
confirm_screen::Confirm,
dialog::{AfterAction, Body, Click, Control, Dialog, InputValue, Kind},
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, waiting_button},
dialog_screen::{BOX, Layout, Part, WARNING_SPRITES, text_height, waiting_button},
font::Font,
hud::{HudBatch, rgba},
multiline_edit::MultiLineEdit,
text::{ClickEvent, Text},
ui,
};
@@ -23,11 +25,54 @@ struct Open {
dialog: Dialog,
values: Vec<InputValue>,
/// The box of each text input.
fields: Vec<Option<ui::EditBox>>,
fields: Vec<Option<Field>>,
focus: Option<usize>,
scroll: ui::Scroll,
/// The number range input whose slider is held.
dragging: Option<usize>,
/// The multiline input the pointer is selecting in.
selecting: Option<usize>,
}
/// A text input's control: `EditBox`, or `MultiLineEditBox` for a multiline one.
enum Field {
Line(ui::EditBox),
Multi(Box<MultiLineEdit>),
}
impl Field {
fn value(&self) -> String {
match self {
Self::Line(edit) => edit.value.clone(),
Self::Multi(edit) => edit.value(),
}
}
fn insert_text(&mut self, font: &Font, text: &str) {
match self {
Self::Line(edit) => edit.insert_text(text),
Self::Multi(edit) => edit.insert_text(font, text),
}
}
fn key(&mut self, font: &Font, key: KeyCode, ctrl: bool, shift: bool) -> bool {
match self {
Self::Line(edit) => edit.key(key, ctrl, shift),
Self::Multi(edit) => edit.key(font, key, ctrl, shift),
}
}
fn shortcut(
&mut self,
font: &Font,
shortcut: EditShortcut,
clipboard: &mut dyn horizon_client::clipboard::Clipboard,
) {
match self {
Self::Line(edit) => {
edit.shortcut(shortcut, clipboard);
}
Self::Multi(edit) => {
edit.shortcut(font, shortcut, clipboard);
}
}
}
}
/// The dialog screens a server can open.
@@ -100,10 +145,35 @@ impl Game<'_> {
dialog.set_list(&entries.unwrap_or_default());
}
let values = dialog.inputs.iter().map(|i| i.control.initial()).collect();
let font = &self.gui.atlas.font;
let fields = dialog
.inputs
.iter()
.map(|i| match &i.control {
Control::Text {
width,
initial,
max_length,
multiline: Some((max_lines, _)),
..
} => {
// TextInputHandler: the `MultiLineEditBox` with its limits.
let height = text_height(Some((*max_lines, None)));
let height = match &i.control {
Control::Text {
multiline: Some((_, Some(h))),
..
} => *h as f32,
_ => height,
};
let mut edit = MultiLineEdit::new(font, 0.0, 0.0, *width as f32, height);
edit.set_character_limit((*max_length).max(0) as usize);
if let Some(lines) = max_lines {
edit.set_line_limit((*lines).max(0) as usize);
}
edit.set_value(font, initial);
Some(Field::Multi(Box::new(edit)))
}
Control::Text {
width,
initial,
@@ -114,7 +184,7 @@ impl Game<'_> {
edit.max_length = (*max_length).max(1) as usize;
edit.set_value(initial);
edit.focused = false;
Some(edit)
Some(Field::Line(edit))
}
_ => None,
})
@@ -126,6 +196,7 @@ impl Game<'_> {
focus: None,
scroll: ui::Scroll::default(),
dragging: None,
selecting: None,
});
self.dialogs.waiting = None;
self.capture(false);
@@ -154,7 +225,7 @@ impl Game<'_> {
fn dialog_values(open: &mut Open) {
for (value, field) in open.values.iter_mut().zip(&open.fields) {
if let Some(f) = field {
*value = InputValue::Text(f.value.clone());
*value = InputValue::Text(f.value());
}
}
}
@@ -205,8 +276,20 @@ impl Game<'_> {
}
// ConfirmLinkScreen over the screen the dialog leaves.
Some(Click::OpenUrl(url)) => self.click_url(url),
// Screen.insertText: the screen after the dialog takes no text.
Some(Click::SuggestCommand(_)) => {}
// defaultHandleClickEvent: `activeScreen.insertText(command, true)`.
// Only the chat screen (opened under the dialog) takes text.
Some(Click::SuggestCommand(command)) => {
if !self.dialog_open() && self.ui.chat_screen.is_some() {
let m = horizon_client::screen_draw::Measure(&self.gui.atlas.font);
let c = horizon_client::chat::Commands {
dispatcher: super::chat::chat_tree(&self.remote),
registries: &self.ui.registries,
};
if let Some(screen) = self.ui.chat_screen.as_mut() {
screen.suggest_command(&m, &c, &command);
}
}
}
Some(other) => log::error!("Don't know how to handle {other:?}"),
None => {}
}
@@ -359,10 +442,19 @@ impl Game<'_> {
batch.text_styled(atlas, &text, x, y, u32::MAX, true);
}
Part::Field { input, rect } => {
if let Some(Some(edit)) = open.fields.get_mut(input) {
[edit.x, edit.y, edit.width, edit.height] = rect;
edit.focused = open.focus == Some(input);
edit.draw(batch, atlas, None);
let focused = open.focus == Some(input);
match open.fields.get_mut(input) {
Some(Some(Field::Line(edit))) => {
[edit.x, edit.y, edit.width, edit.height] = rect;
edit.focused = focused;
edit.draw(batch, atlas, None);
}
Some(Some(Field::Multi(edit))) => {
[edit.x, edit.y, edit.width, edit.height] = rect;
edit.set_focused(focused);
edit.draw(batch, atlas);
}
_ => {}
}
}
Part::Check { input, rect } => {
@@ -555,11 +647,32 @@ impl Game<'_> {
Part::Field {
input,
rect: [x, y, w, h],
} if ui::hovered(cursor, x, y, w, h) => {
} if ui::hovered(
cursor,
x,
y,
// A multiline box answers over its scroll bar too.
w + if matches!(open.fields.get(input), Some(Some(Field::Multi(_)))) {
6.0
} else {
0.0
},
h,
) =>
{
open.focus = Some(input);
if let Some(Some(edit)) = open.fields.get_mut(input) {
edit.focused = true;
edit.click(&self.gui.atlas, cursor.0, false, false);
match open.fields.get_mut(input) {
Some(Some(Field::Line(edit))) => {
edit.focused = true;
edit.click(&self.gui.atlas, cursor.0, false, false);
}
Some(Some(Field::Multi(edit))) => {
[edit.x, edit.y, edit.width, edit.height] = [x, y, w, h];
edit.set_focused(true);
edit.click(&self.gui.atlas.font, cursor, shift, false);
open.selecting = Some(input);
}
_ => {}
}
}
Part::Check {
@@ -669,6 +782,30 @@ impl Game<'_> {
cycle(open, input, if delta > 0.0 { -1 } else { 1 });
return;
}
// AbstractScrollArea.mouseScrolled: a multiline box takes the wheel.
let text = layout
.placed(open.scroll.amount)
.find_map(|(part, _)| match part {
Part::Field { input, rect } => match open.fields.get(input) {
Some(Some(Field::Multi(_)))
if ui::hovered(
cursor,
rect[0],
rect[1],
rect[2] + 6.0,
rect[3],
) =>
{
Some(input)
}
_ => None,
},
_ => None,
});
if let Some(Some(Field::Multi(edit))) = text.and_then(|i| open.fields.get_mut(i)) {
edit.wheel(delta);
return;
}
}
open.scroll.wheel(delta, 10.0, ay, ay + ah, layout.content);
}
@@ -676,6 +813,14 @@ impl Game<'_> {
/// The pointer moved with a slider held (`AbstractSliderButton.onDrag`).
pub(super) fn dialog_drag(&mut self) {
let (cursor, shift) = (self.cursor, self.modifiers.shift_key());
if let Some(open) = self.dialogs.open.as_mut()
&& let Some(Some(Field::Multi(edit))) =
open.selecting.and_then(|i| open.fields.get_mut(i))
{
// MultiLineEditBox.onDrag.
edit.drag(&self.gui.atlas.font, cursor, shift);
}
let Some(input) = self.dialogs.open.as_ref().and_then(|o| o.dragging) else {
return;
};
@@ -697,10 +842,15 @@ impl Game<'_> {
/// The left button released: a held slider lets go and clicks.
pub(super) fn dialog_release(&mut self) {
if let Some(open) = self.dialogs.open.as_mut()
&& open.dragging.take().is_some()
{
self.audio.play_button_click();
if let Some(open) = self.dialogs.open.as_mut() {
if let Some(Some(Field::Multi(edit))) =
open.selecting.take().and_then(|i| open.fields.get_mut(i))
{
edit.release();
}
if open.dragging.take().is_some() {
self.audio.play_button_click();
}
}
}
@@ -765,11 +915,12 @@ impl Game<'_> {
let Some(Some(edit)) = open.focus.and_then(|f| open.fields.get_mut(f)) else {
return Ok(());
};
let font = &self.gui.atlas.font;
if let Some(shortcut) = shortcut {
edit.shortcut(shortcut, &mut self.clipboard);
edit.shortcut(font, shortcut, &mut self.clipboard);
return Ok(());
}
if edit.key(key, ctrl, shift) {
if edit.key(font, key, ctrl, shift) {
return Ok(());
}
let typed: String = text
@@ -778,7 +929,7 @@ impl Game<'_> {
.filter(|c| !c.is_control())
.collect();
if !typed.is_empty() {
edit.insert_text(&typed);
edit.insert_text(font, &typed);
}
Ok(())
}
@@ -796,7 +947,7 @@ impl Game<'_> {
return false;
};
open.focus = focus;
edit.insert_text(text);
edit.insert_text(&self.gui.atlas.font, text);
true
}
@@ -815,7 +966,7 @@ impl Game<'_> {
let mut values = open.values.clone();
for (value, field) in values.iter_mut().zip(&open.fields) {
if let Some(f) = field {
*value = InputValue::Text(f.value.clone());
*value = InputValue::Text(f.value());
}
}
open.dialog.json(&values)
+2 -1
View File
@@ -1545,7 +1545,8 @@ impl Game<'_> {
};
let target = (placed.x, placed.y, placed.z);
let state = self.apply_item_block_state([target.0, target.1, target.2], state, &stack)?;
if let Some(entity) = BlockEntity::for_block(&self.res.facts().get(state).name) {
let name = self.res.facts().get(state).name.clone();
if let Some(entity) = self.new_block_entity(&name) {
self.world.block_entities.insert(target, entity);
self.apply_placed_item([target.0, target.1, target.2], &stack, false);
}
+50 -10
View File
@@ -11,6 +11,7 @@ use horizon_client::{
hud::HudBatch,
hud_layout, hud_overlays,
mesher::Vertex,
mobs::{Damage, DamageKind, Who},
particles::{ParticleOptions, ParticleType},
text::Text,
};
@@ -53,6 +54,10 @@ pub(super) struct Fight {
/// The player behind the crystal blast under way, whose `explosion`
/// damage source names them (`EndCrystal.hurtServer`).
blast_by: Option<FightWho>,
/// The last hit the dragon took (`die(source)`'s source, for the
/// `player_killed_entity` trigger's `killing_blow`): its damage type and
/// whether a player was behind it.
last_blow: Option<(DamageKind, bool)>,
dirty: bool,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -923,7 +928,7 @@ impl Game<'_> {
if let Some(d) = self.dragon.as_mut() {
if d.nearest_crystal == Some(index) {
d.nearest_crystal = None;
self.hurt_dragon(0, 10.0, true, by, false);
self.hurt_dragon(0, 10.0, true, by, DamageKind::Explosion);
} else if let Some(n) = d.nearest_crystal.as_mut()
&& *n > index
{
@@ -976,6 +981,15 @@ impl Game<'_> {
.find(|(s, _)| *s == CRYSTAL_IDS + i as u32)
.map(|(_, b)| b)
}
/// `ServerLevel.dragonParts`: where the part with that entity id is
/// and its box.
pub(super) fn dragon_part(&self, id: u32) -> Option<(Vec3, Aabb)> {
let d = self.dragon.as_ref().filter(|d| d.alive() && d.id != 0)?;
(d.id + 1..=d.id + 8).contains(&id).then(|| {
let i = (id - d.id - 1) as usize;
(d.parts[i].0, d.part_box(i))
})
}
/// Damage to a special id (from projectiles and melee): what the
/// dragon part or crystal made of it. `arrow` for a direct arrow.
pub(super) fn hit_special(
@@ -983,7 +997,7 @@ impl Game<'_> {
id: u32,
amount: f32,
by_player: bool,
arrow: bool,
kind: DamageKind,
) -> Hurt {
if id >= CRYSTAL_IDS {
// EndCrystal.hurtServer: false while invulnerable, else it blows up.
@@ -996,7 +1010,7 @@ impl Game<'_> {
Hurt::Landed { full: true }
} else if id >= SPECIAL {
let by = by_player.then(|| self.acting_player());
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, by, arrow)
self.hurt_dragon((id - SPECIAL) as usize, amount, by_player, by, kind)
} else {
Hurt::Refused
}
@@ -1009,8 +1023,9 @@ impl Game<'_> {
damage: f32,
counts: bool,
by: Option<FightWho>,
arrow: bool,
kind: DamageKind,
) -> Hurt {
let arrow = kind == DamageKind::Arrow;
let Some(d) = self.dragon.as_mut() else {
return Hurt::Refused;
};
@@ -1027,6 +1042,7 @@ impl Game<'_> {
d.hurt_by_player = Some((who, d.ticks));
}
if !was_down && d.down() {
self.fight.last_blow = Some((kind, by.is_some()));
self.credit_dragon_kill();
}
let Some(d) = self.dragon.as_mut() else {
@@ -1058,6 +1074,7 @@ impl Game<'_> {
..Default::default()
};
let id = d.id;
let blow = self.fight.last_blow.take();
let guest = match who {
FightWho::Guest(conn) if self.lan_acting.is_none() => Some(conn),
_ => None,
@@ -1067,21 +1084,43 @@ impl Game<'_> {
return false;
};
let done = self
.as_guest(&mut host, conn, |g| g.award_dragon_kill(id, &view))
.as_guest(&mut host, conn, |g| g.award_dragon_kill(id, &view, blow))
.is_some();
self.lan = Some(host);
done
});
if !credited {
self.award_dragon_kill(id, &view);
self.award_dragon_kill(id, &view, blow);
}
}
/// `ServerPlayer.awardKillScore` for the dragon, on the killer's state.
fn award_dragon_kill(&mut self, id: u32, view: &horizon_client::advancements::EntityView) {
fn award_dragon_kill(
&mut self,
id: u32,
view: &horizon_client::advancements::EntityView,
blow: Option<(DamageKind, bool)>,
) {
self.stats.add("killed", "minecraft:ender_dragon", 1);
self.stats.custom("mob_kills", 1);
self.score_player_killed(super::commands::Target::Mob(id));
self.trigger_killed(view, None);
// KilledTrigger: the damage source `die` was given.
let blow = blow.map(|(kind, by_player)| {
let mut view = self.damage_view(Damage {
kind,
attacker: by_player.then_some(Who::Player),
from: None,
});
// `damageSources().explosion(crystal, player)`: the crystal is the direct entity.
if kind == DamageKind::Explosion {
view.direct = Some(horizon_client::advancements::EntityView {
kind: "minecraft:end_crystal".to_owned(),
..Default::default()
});
view.is_direct = false;
}
view
});
self.trigger_killed(view, blow.as_ref());
}
/// Player melee on the dragon's parts or a crystal under the crosshair.
/// The dragon part under the crosshair within reach: its id and distance.
@@ -1128,7 +1167,7 @@ impl Game<'_> {
.into_iter()
.find(|(part, _)| *part == id)
.map(|(_, b)| b);
let hurt = self.hit_special(id, amount, true, false);
let hurt = self.hit_special(id, amount, true, DamageKind::PlayerAttack);
// The sweep and the sounds read the charge, which the hit then spends.
if let (Some(area), Hurt::Landed { .. }) = (area, hurt) {
self.attack_effects(area, None, super::mace::Spared::Nothing, None);
@@ -2848,7 +2887,7 @@ impl Game<'_> {
});
let by = self.fight.blast_by;
for (part, amount) in hits {
self.hurt_dragon(part, amount, true, by, false);
self.hurt_dragon(part, amount, true, by, DamageKind::Explosion);
}
}
}
@@ -2888,6 +2927,7 @@ impl Game<'_> {
needs_sync: false,
leash: None,
spectator: false,
watching: false,
})
}
}
Loaded 100 of 356 files, more files were not shown because too many files have changed in this diff. Show more