Compare commits

..
Author SHA1 Message Date
Ludovic Marie ebcfa2263a Merge pull request #11637 from horizon-rust/fix/lightning-fire-radius-11376
🤖 Agent: lightning now places fire (and draws its extra-source offsets) only when canSpreadFireAround passes at the bolt, so fire_spread_radius_around_player holds for strikes far from every player.
2026-11-07 07:40:30 +01:00
Ludovic Marie 7127fb8ed1 Merge pull request #10065 from ilguerro/fix/thorns-victim
🤖 Agent: merged main again (#10015's lan_e2e check landed beside this one's); the Thorns change itself is the head the mace harness passed on.
2026-11-07 07:19:55 +01:00
Horizon agent 9d35a0c0ad Merge remote-tracking branch 'origin/main' into n/10065
# Conflicts:
#	tools/lan_e2e.py
2026-11-07 07:18:08 +01:00
Ludovic Marie b6aa962829 Merge pull request #10035 from IsT3RiK/traduction-messages-mods
🤖 Agent: the Mods page's install and port messages go through tr/trf with named arguments, so they translate; lang files merged key by key onto main, i18n tests pass.
2026-11-07 07:05:28 +01:00
Ludovic Marie c1102d9227 Merge pull request #11628 from horizon-rust/fix/absorption-instant-shift-11386
🤖 Agent: a mob's Absorption now refills on every accepted add (AbsorptionMobEffect.onEffectStarted), and heal/harm and effect intervals shift like Java's int << / >> (distance mod 32, wrapping) instead of clamping or panicking at amplifier 32+.
2026-11-07 06:58:08 +01:00
Horizon agent 057869bdad Merge remote-tracking branch 'origin/main' into n/10035
# Conflicts:
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-07 06:43:57 +01:00
Ludovic Marie 84ec8516c6 Merge pull request #11622 from horizon-rust/fix/frame-map-limit-11383
🤖 Agent: an empty frame now refuses a map tracking more than 256 counted markers (ItemFrame.interact's isTrackedCountOverLimit FAIL): the map stays held, no swing; exactly 256 still goes in.
2026-11-07 06:40:06 +01:00
Ludovic Marie 4500b9e72b Merge pull request #10015 from repositorykreml1n/codex/fix-issue-9377
🤖 Agent: /say from a command block reaches every player under the block's name, and a captured guest command no longer echoes it back as feedback (kacpherek's point); lan_e2e command-block-chat not run here.
2026-11-07 06:35:42 +01:00
Horizon agentandClaude Opus 5.5 2710eae7cf Start a bolt's fire only near a player
LightningBolt.spawnFire places fire, and draws its extra sources, only when
ServerLevel.canSpreadFireAround passes at the bolt: the
fire_spread_radius_around_player rule now holds for lightning (#11376).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 06:30:22 +01:00
Ludovic Marie ac40181853 Merge pull request #11613 from horizon-rust/fix/omen-added-sound-11373
🤖 Agent: the omen sound now plays from the player's addEffect for any newly added omen (command, potion, mod) at the feet, as MobEffect.onEffectAdded does; the three special paths moved from the eye to the feet too.
2026-11-07 06:29:33 +01:00
Ludovic Marie 2a4a9a4616 Merge pull request #11601 from horizon-rust/fix/tnt-entity-collisions
🤖 Agent: TNT, falling blocks and XP orbs now collide with boats, shulkers and a held happy ghast (Entity.collide's canBeCollidedWith); sand landing on a boat over air breaks into an item like vanilla's wouldContinueFalling.
2026-11-07 06:29:29 +01:00
Horizon agent bec6e40518 Merge remote-tracking branch 'origin/main' into n/10065
# Conflicts:
#	tools/lan_e2e.py
2026-11-07 06:26:44 +01:00
Horizon agent 702adabb25 Merge remote-tracking branch 'origin/main' into n/10015
# Conflicts:
#	tools/lan_e2e.py
2026-11-07 06:24:27 +01:00
Ludovic Marie eb2d90fb10 Merge pull request #10040 from NikitaMGrimm/shader-api-02-serve
🤖 Agent: 0.2 shader packs get the float HDR frame only under HORIZON_HDR_FRAME=float; the default and 0.1 paths keep their formats (frame_format tests cover both), so the vanilla look is unchanged.
2026-11-07 06:18:07 +01:00
Ludovic Marie 5a83184c7e Merge pull request #10170 from TheMrZZ/fix/furnace-loaded-cooking-total
🤖 Agent: a loaded furnace keeps its saved cooking total until the item finishes, and a hopper or dropper filling the empty input slot restarts the cooking as setItem does (kacpherek's point); furnace_loaded_total and furnace_output_components pass.
2026-11-07 06:15:34 +01:00
Ludovic Marie 1082a55b0f Merge pull request #10013 from MSedgeMC/feat/chat-draft-italic
🤖 Agent: a restored chat draft draws grey italic until edited, as ChatScreen's isDraft does; text a mod opens chat with is not a draft. Merge with main builds and the chat tests pass.
2026-11-07 06:15:30 +01:00
Horizon agentandClaude Opus 5.5 aacb969efa Refuse a framed map that tracks more than 256 markers
ItemFrame.interact returns FAIL when MapItemSavedData.isTrackedCountOverLimit(256):
the map stays in the hand and the frame stays empty (part of #11383).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 06:15:02 +01:00
Horizon agentandClaude Opus 5.5 143f965f6f native_open_chat: mod text is not a draft (open_chat takes is_draft)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 06:12:51 +01:00
Ludovic Marie 318e263959 Merge pull request #10119 from DUzzL/fix/10049-frost-walker-equip
🤖 Agent: Frost Walker's speed modifier now follows a boots change made by a guest too (the guest's armor is swapped in before the comparison); armor_changes copies only changed slots. armor_stand_frost passes on a union with main.
2026-11-07 06:12:42 +01:00
Horizon agent 955cd0b289 Merge remote-tracking branch 'origin/main' into n/10013 2026-11-07 05:59:01 +01:00
Ludovic Marie 29082ca5d1 Merge pull request #10009 from DUzzL/fix/9959-spectator-portals
🤖 Agent: a spectator using a portal block travels to its destination, never builds a Nether exit, and gets spectator.cannot_teleport without one (ServerPlayerGameMode.useItemOn); nether_portal harness passes on a union with main.
2026-11-07 05:55:35 +01:00
Ludovic Marie 3b6d6b542c Merge pull request #9995 from DUzzL/fix/9963-projectile-absorbed
🤖 Agent: damage a mob's absorption takes is credited to the thrower (guest throws settle inside as_guest, kacpherek's point); mace and trident harnesses pass on a union with main.
2026-11-07 05:55:32 +01:00
Ludovic Marie 7a2f824043 Merge pull request #10701 from Detimes/perf-reuse-packet-deflater
🤖 Agent: region saves reuse one deflater per writer instead of building one per chunk; the test checks the compressed bytes are identical to a fresh deflater's. Union check (main + the gate's 9 heads): fmt, clippy -D warnings, 5666 tests green; verify green after the runner's full-disk rerun.
2026-11-07 05:46:01 +01:00
Ludovic Marie cca49a3f34 Merge pull request #11606 from horizon-rust/fix/boat-controller-11541
🤖 Agent: the second rider's PlayerInput/MoveVehicle is now ignored like vanilla's getControllingPassenger check; riders are listed in boarding order so the first one in keeps the tiller.
2026-11-07 05:43:37 +01:00
Ludovic Marie b03633436a Merge pull request #11597 from horizon-rust/fix/swing-echo-9887
🤖 Agent: only SUCCESS_SERVER block uses (beds, light, a charged anchor, signs) swing back to their own player; item and entity uses still echo, as the body says. use_item_on, flat_place and dedicated_server pass locally.
2026-11-07 05:39:22 +01:00
Ludovic Marie f6a188d8cd Merge pull request #11586 from horizon-rust/fix/piston-push-order-11399
🤖 Agent: each moving block's collision view now advances right after its own push, so later pushes in the same tick see it at its new progress; the cost is one lookup per moving piston. piston_push and piston_movement pass locally.
2026-11-07 05:39:19 +01:00
Horizon agentandClaude Opus 5.5 93d50e0318 Refill a mob's Absorption on every add; shift heal and harm like Java
LivingEntity.addEffect runs onEffectStarted for every effect it accepts,
so Absorption given again refills a mob's shield to 4 per level of the
effect given. Horizon refilled it only for a new or stronger effect.
HealOrHarmMobEffect and the periodic effects use Java's shifts, which take
the distance modulo 32 and wrap: amplifier 32 is amplifier 0 again, and a
ticked heal is at least 0. Horizon clamped the distance to 30, and an
amplifier of 32 or more panicked on Regeneration's interval in a debug
build (#11386).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 05:36:50 +01:00
Ludovic Marie 507e0ae6f4 Merge pull request #10180 from lightcodegit/fix/brush-horn-use-vibration
🤖 Agent: horn and brush uses end with stopUsingItem (the interaction vibration), and a mob in front of the brushed block stops the brush out to the block reach; item_use_catalog harness passes on a union with main.
2026-11-07 05:16:14 +01:00
Horizon agentandClaude Opus 5.5 1d0b591927 Play an omen's sound when it is added to an entity
MobEffect.onEffectAdded plays Bad Omen's, Trial Omen's and Raid Omen's
sound (withSoundOnAdded) whenever LivingEntity.addEffect adds one that
is new, at the entity's feet. Horizon played them only on the raid, bottle
and trial spawner paths, at the eye, so /effect give, a potion's custom
contents or a mod adding one was silent (#11373). The generic add now
plays it for players and, from /effect, for mobs; the three special paths
play at the feet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 05:16:06 +01:00
Ludovic Marie 348e296ffa Merge pull request #10039 from MSedgeMC/fix/hud-text-height
🤖 Agent: the item name and the action bar take fixed heights above the hotbar (Hud.extractSelectedItemName 59, extractOverlayMessage 68) instead of following the bars; hud harness passes on a union with main.
2026-11-07 05:15:29 +01:00
Ludovic Marie 28f081a242 Merge pull request #10118 from DUzzL/fix/10050-breeze-potion-deflect
🤖 Agent: a breeze turns a thrown potion back at half speed (Breeze.deflection) once in a row, stopping at the hit point that tick; the REVERSE draw uses the potion's own random.
2026-11-07 05:14:31 +01:00
Ludovic Marie 47672aefc6 Merge pull request #10193 from MSedgeMC/fix/automatic-advancements
🤖 Agent: advancements with no criteria get their rewards on load (checkForAutomaticTriggers) but stay not done, as AdvancementRequirements.test answers false; checked on a union with main.
2026-11-07 05:13:38 +01:00
Ludovic Marie d81408ef0f Merge pull request #10124 from MSedgeMC/feat/stats-tab-keys
🤖 Agent: Ctrl+Tab and Ctrl+1..0 switch the statistics tabs from the raw key, as TabNavigationBar.keyPressed does; checked on a union with main.
2026-11-07 05:12:51 +01:00
Ludovic Marie 0ce9091c37 Merge pull request #10002 from some-du6e/optifine/sky-layers
🤖 Agent: the fade out ending at startFadeIn now runs to the end of the day (since()==0 read as DAY), which was kacpherek's review point; checked on a union with main (fmt, clippy -D warnings, tests).
2026-11-07 05:01:28 +01:00
Ludovic Marie 6c45dc0561 Merge pull request #11596 from horizon-rust/perf/terrain-hdr-override
🤖 Agent: the solid terrain pipelines are built with HDR_LIGHTING off and the lit copy only exists when hdr_output is Some, so HDR players switch per frame by self.hdr; frame-identity label makes CI check HDR off, offscreen HDR and HDR toggled off in a running game. −16 to −22% GPU on M4 Pro, image identical in-process.
2026-11-07 04:57:22 +01:00
Ludovic Marie 7775a14526 Merge pull request #11562 from horizon-rust/perf/11024-parallel-world-convert
🤖 Agent: merging. Empty region files (13 303 of 13 620 in the test world) are no longer rewritten and synced on world open, and region files convert on all cores but one with byte-identical output; datafix harness 6/6.
2026-11-07 04:40:56 +01:00
Horizon agentandClaude Opus 5.5 e3b3159901 Build solid terrain without HDR lighting when HDR output is off
terrain.wgsl's solid shaders carry HDR output's lighting behind a runtime
`hdr_light.z` test. The branch is never taken without HDR, yet on Apple GPUs its
code alone costs ~15% of the solid pass (register pressure). A pipeline
constant `HDR_LIGHTING` now guards it: the solid pipelines (per-section, pulled
and flat) are built without it, and a copy with it only where the display
offers HDR output, picked per frame by whether HDR is on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 04:35:44 +01:00
Ludovic Marie 77936bf4be Merge pull request #10196 from IanWorley/native-api/session-state
🤖 Agent: the session state a client HUD reads (world id, screen kind) comes from fields the window already keeps; merged with #10195 and checked with clippy, the native_client tests and the hook stamp test.
2026-11-07 04:33:57 +01:00
Horizon agent f47ac2cbad Merge remote-tracking branch 'origin/main' into conv-par 2026-11-07 04:24:38 +01:00
Horizon agentandClaude Opus 5.5 7f0778e4da Merge origin/main into #10196
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 04:12:16 +01:00
Ludovic Marie b0a8cbf6fb Merge pull request #11579 from horizon-rust/mods/client-combat-hooks
🤖 Agent: client_hurt reports the packet's causing entity (the shooter, not the arrow) and treats an untracked entity as not a player, so a mod never gets a false PvP signal. Main merged in (test conflict with #11512's open-chat test, both kept); verify green on this head.
2026-11-07 04:12:11 +01:00
Horizon agentandClaude Opus 5.5 6bcbf52250 Merge origin/main into mods/client-combat-hooks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:59:52 +01:00
Horizon agentandClaude Opus 5.5 d2c44db65e Primed TNT, falling blocks and orbs land on boats and shulkers
PrimedTnt, FallingBlockEntity and ExperienceOrb move with Entity.move,
whose Entity.collide includes EntityGetter.getEntityCollisions: a boat,
a living shulker and a happy ghast held still stop them, as they stop a
dropped item. Horizon resolved their moves against blocks and the world
border only (#11518). The ordered tick gathers those boxes once per run
(again after an explosion) and passes the nearby ones to each move.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:54:41 +01:00
Horizon agentandClaude Opus 5.5 2b1731508d Let only a boat's first rider drive it on a server
A boat's passengers went to the clients in the order the players joined
the server, so the player who got in second could hear it sits first and
take the boat over, and the server took MoveVehicle from either seat: with
two players in one boat it kept snapping back (#11541). The passengers now
go in the order they boarded, and handleMoveVehicle ignores a player who
isn't the vehicle's controlling passenger, as vanilla does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:54:07 +01:00
Ludovic Marie 026f014ff6 Merge pull request #11538 from horizon-rust/mods/chat-input-helpers
🤖 Agent: native_set_chat_input filters text to what typing allows (no control chars, DEL or §) and clamps the cursor, so a mod can't inject formatting codes into the chat box.
2026-11-07 03:50:58 +01:00
Ludovic Marie 2aa5716a6a Merge pull request #11528 from horizon-rust/fix/followup-10529
🤖 Agent: Launcher-only follow-up to #10529: the Home tab strip is flex_none on wide windows again (glass::tabbed keeps its notch at 1440 px) and only scrolls when compact; settings rows keep 40 px between name and control and wrap the control only when narrow. No new strings; fmt/clippy/tests green on the union of this wave's heads.
2026-11-07 03:50:53 +01:00
Ludovic Marie 3e96961f1e Merge pull request #11524 from horizon-rust/fix/cg66-followups
🤖 Agent: follow-ups to #11033/#11044/#11043/#11016 that missed the merges: death protection is one prototype lookup per hand per tick (was a full component map for every armed mob), checked against the component map for patched and stripped stacks. Union check: fmt, clippy -D warnings, 5666 tests green.
2026-11-07 03:50:48 +01:00
Ludovic Marie ed38625015 Merge pull request #11036 from ilguerro/perf/9996-interp2
🤖 Agent: the interpolation change is checked bit for bit against the old formula over the test's whole range, so no position moves; one multiply fewer per entity per frame. Union check (main + the gate's 9 heads): fmt, clippy -D warnings, 5666 tests green.
2026-11-07 03:50:43 +01:00
Ludovic Marie 31d0888da7 Merge pull request #10703 from Detimes/perf-region-inflate-in-place
🤖 Agent: inflates each region chunk in place instead of through a second buffer; the differential test decodes the same chunks with the old block decoder and compares every section. Union check (main + the gate's 9 heads): fmt, clippy -D warnings, 5666 tests green.
2026-11-07 03:50:39 +01:00
Ludovic Marie a5ea46ce3e Merge pull request #11049 from DUzzL/fix/11024-parallel-world-upgrade
🤖 Agent: Optimize World now upgrades region files in parallel with one sync per file instead of one per chunk (F_FULLFSYNC on macOS was the cost); the 1-vs-4-thread test checks the counts against the fixture's own layout, and a canceled recreate leaves every file untouched. Union check: fmt, clippy -D warnings, 5666 tests green.
2026-11-07 03:46:43 +01:00
Ludovic Marie 5088011241 Merge pull request #9566 from hildlabs/panel/glass-look
🤖 Agent: the server panel moves its dialogs into the page and follows the Glass look; launcher only, strings through tr().
2026-11-07 03:45:02 +01:00
Ludovic Marie 3be1aae4a1 Merge pull request #10099 from yahya-nusseibeh/fix/rsa-constant-time
🤖 Agent: private-key operations are now constant-time (no branch or table index on secret bits); no change to the key format or the public API.
2026-11-07 03:44:58 +01:00
Ludovic Marie 7f3bf11140 Merge pull request #10169 from NikitaMGrimm/shader-pack-identity-launcher
🤖 Agent: the launcher keeps each native shader's identity and options across the settings screen rewrite; its parent #10168 is merged, and the union with main passes clippy and tests.
2026-11-07 03:44:55 +01:00
Ludovic Marie afcfad914c Merge pull request #10195 from IanWorley/native-api/client-entities
🤖 Agent: client mods read entities and textures through the Host only, no new write path; #10196 conflicts with it in native_client_services.rs and gets main merged after this lands.
2026-11-07 03:44:51 +01:00
Ludovic Marie 6fefc220f9 Merge pull request #9968 from masa-masa-0314/feat/realms-invite-codes
🤖 Agent: the invite-code calls go through one frontend() helper with x-mojangauth, only to the Realms frontend URL; builds and tests clean on a union with current main.
2026-11-07 03:44:47 +01:00
Horizon agentandClaude Opus 5.5 fc8e14548a A pushed entity meets the other moving blocks at their progress then
PistonMovingBlockEntity.tick pushes entities from each block entity's own
tick, and getCollisionShape reads its progress: the moving blocks that
ticked before it have advanced, the ones after it have not. Horizon
pushed every entity against last tick's progress of all of them, so a
TNT duper's TNT was pushed through a block that was about to move
(#11399). Each piston's collision view now advances right after its own
push.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:42:55 +01:00
Horizon agentandClaude Opus 5.5 ae8bfba8c6 Fill the swing test's Joined like main's, rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:40:33 +01:00
Horizon agent 8703ad614c Merge remote-tracking branch 'origin/main' into fix/swing-echo-9887 2026-11-07 03:39:58 +01:00
Ludovic Marie fce784f752 Merge pull request #11575 from horizon-rust/mods/screenshot-helper
🤖 Agent: native_take_screenshot refuses while another screenshot waits for the frame, so a mod's call can't overwrite F2's or the world icon's pending grab.
2026-11-07 03:37:48 +01:00
Ludovic Marie a5b752f7cc Merge pull request #11555 from horizon-rust/fix/sound-harness-runner-defaults
🤖 Agent: parity.py starts harnesses with no arguments; these two required --binary/--work-dir and failed parity-main in 0 s. On the desktop both now reach their servers (the Java sides ran); the full runs there timed out only because the desktop's swap was full.
2026-11-07 03:37:45 +01:00
Horizon agentandClaude Opus 5.5 9834143df1 Fix clippy: is_player needs no mut; keep player_voice_pitch's doc on it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:34:42 +01:00
Ludovic Marie 4774d07dd5 Merge pull request #11513 from horizon-rust/fix/items-fall-through-minecarts-11091
🤖 Agent: removes only the minecart boxes from the item's collision list; boats, shulkers and a held happy ghast still hold items, as AbstractMinecart doesn't override canBeCollidedWith. entity_collision, held_drop, shulker_space, boat_item_hit pass locally.
2026-11-07 03:22:31 +01:00
Horizon agent b9693b5716 Merge remote-tracking branch 'origin/main' into fix/swing-echo-9887 2026-11-07 03:20:04 +01:00
Ludovic Marie 1647c1b466 Merge pull request #11028 from Detimes/hooks-b-all
🤖 Agent: merged after #11019 and main (hook and doc blocks kept whole, quinn kept beside the new optional crates). Without a mod, PlayerSender::send pays one relaxed WATCHED load and sends the bytes as they were, and worldgen, LOD columns and redstone keep vanilla's values.
2026-11-07 03:19:45 +01:00
Horizon agentandClaude Opus 5.5 ac31802b58 Give #11019's guest test a Joined with its configuration
The zero-health test that #11019 added builds a Joined, which this branch
gave a configured field.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 03:01:53 +01:00
Horizon agentandClaude Opus 5.5 c2adc89cc7 Keep the invocation test's binary inside its scratch folder
The harnesses resolve --binary, and /bin is a link to /usr/bin on
Ubuntu runners.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:59:37 +01:00
Horizon agent e82a08ca4d Merge remote-tracking branch 'origin/main' into cg68/hooks-b-all
# Conflicts:
#	crates/horizon-mod-deps/Cargo.toml
#	docs/native-mods.md
2026-11-07 02:55:11 +01:00
Ludovic Marie 41944ed914 Merge pull request #11553 from horizon-rust/pvp-2/legacy-pvp-check
🤖 Agent: the check, not the 1.8 rules, was broken: the switch row sat half under the list's edge so the click missed it; verified 7/7 legacy, 9/3.6 modern on the Mac.
2026-11-07 02:49:45 +01:00
Ludovic Marie 87784fb3df Merge pull request #11543 from horizon-rust/pvp-2/bots-crits
🤖 Agent: the fight bots' swing carried a hand byte 26.3's swing packet doesn't have, so every server kicked them on the first hit; it now sends the same Punch as --attack.
2026-11-07 02:49:41 +01:00
Ludovic Marie 1b4a669936 Merge pull request #11019 from Detimes/hooks-all
🤖 Agent: twelve hook groups land together. Without a mod, the move, teleport and trample hooks are fn pointers over an empty mods() slice, so a player's move costs one indirect call and no allocation; a mod command's permission node falls back to its own level when no provider answers.
2026-11-07 02:49:38 +01:00
Horizon agent 230d414d62 Merge commit '97e3a74179cf3e6137908ea2c8ce36a489605a84' into cg68/hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/lan_actions.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/game/native_net.rs
#	docs/native-mods.md
2026-11-07 02:45:45 +01:00
Ludovic Marie 0ad4f59af2 Merge pull request #11537 from horizon-rust/multiplayer/drag-servers
🤖 Agent: a press on the already-selected server still joins without holding it, and servers.dat is written only on a release after a real move.
2026-11-07 02:37:14 +01:00
Ludovic Marie 3e6e75d8f2 Merge pull request #11511 from horizon-rust/settings/finer-lod-steps
🤖 Agent: only the slider's step table changes; saved values between steps snap to the nearest step (test covers 3000 → 13/23), so existing options.txt files still load.
2026-11-07 02:37:10 +01:00
Ludovic Marie cef80f4fd5 Merge pull request #11510 from horizon-rust/mods/quinn-dep
🤖 Agent: quinn is optional behind the quic feature with default features off (runtime-tokio + rustls-ring only), so no extra TLS backend lands in Cargo.lock beyond the 7 packages.
2026-11-07 02:37:06 +01:00
Ludovic Marie 52a7c28daf Merge pull request #11512 from horizon-rust/mods/open-chat-helper
🤖 Agent: native_open_chat refuses unless the screen is Playing, so a mod can't push the chat over an open container or menu.
2026-11-07 02:37:02 +01:00
Horizon agent 97e3a74179 Merge remote-tracking branch 'origin/main' into cg68/hooks-all 2026-11-07 02:33:34 +01:00
Horizon agentandClaude Opus 5.5 2ba9bcf90d Fix the build: the path goes to mods as a string
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:30:55 +01:00
Horizon agentandClaude Opus 5.5 dd338cd642 Fix the build: FnMut lookup for the attacker
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:29:11 +01:00
Horizon agentandClaude Opus 5.5 17ee8c5f13 lan_e2e legacy-pvp: flip the switch once it is whole in view, and swap across ticks
The check failed on main with 1.8 rules that work: the switch row now sits
under the PvP page's HUD rows, the scroll stopped as soon as the row showed
at the list's lower edge, and the click at its centre landed outside the
list, so the rules never turned on (legacy hit 9, not 7). It now scrolls
until the row stops moving and asserts the click flipped it.

The swap and back went out in one tick: since the client sends a slot
change from its next tick, as vanilla does (#10084), that sends nothing,
and the modern hit kept its full charge. The stick is now held 3 ticks.

Mac mini: legacy (7, 7), modern (9, 3.6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:27:08 +01:00
Ludovic Marie 77db38ec8c Merge pull request #11523 from horizon-rust/platforms/ci-arm64-launcher-version
🤖 Agent: .github only; the by-hand ARM64 job's launcher --version step now runs from bash as launcher.yml does, and passes on the ARM64 runner (run 42320852405), where the pwsh version failed on any code.
2026-11-07 02:23:08 +01:00
Horizon agentandClaude Opus 5.5 6858867f2a Convert older worlds on all cores and skip empty region files
Opening a world saved by an older version runs the converter in
datafix/files.rs (upgrade_world), which rewrote every region file on one
thread. Now:

- upgrade_regions spreads a folder's region files over worker_threads()
  workers (all cores but one). Each file is still read, fixed and written
  whole by one worker through the same staging folder, so the bytes do not
  depend on the thread count. A cancel or an error stops every worker
  before its next file; the error returned is the first file's in order.
- A region file without chunks is left as it is instead of being rewritten
  as an empty 8 KiB file and synced. The game leaves many of them: 13 303
  of the 13 620 overworld files of a 1.21.1 ATM10 test world.

Tests: 4 workers give byte-identical files to 1 and every chunk at the
current version, an empty file stays empty, and a cancel from the first
chunk converts at most one file per worker and leaves the rest old.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:17:31 +01:00
Ludovic Marie 00f6358e7a Merge pull request #11081 from horizon-rust/platforms/docs-intel-arm64
🤖 Agent: one line in docs/hosting.md (Intel Macs have had server builds since the 10-31 Nightly; install.sh already maps Darwin-x86_64); the README half is already on main via bf4657da26.
2026-11-07 02:10:26 +01:00
Ludovic Marie b33917f89b Merge pull request #11080 from horizon-rust/platforms/arm64-emulated-dx12
🤖 Agent: Auto's backend order now reads the machine's arch (IsWow64Process2), so the x64 build on a Snapdragon PC starts on DX12 like the ARM64 one; the ARM64 runner built and started the game on this head. Unverified on real Adreno hardware (asked #7513's reporter).
2026-11-07 02:10:21 +01:00
Ludovic Marie 3fd9db1568 Merge pull request #10501 from arslanarm/shaderpacks/same-size-buffer-declarations
🤖 Agent: merged main in again (only the test module list in pack_post.rs collided, with #11196's sky_disc_blend_tests). The last parity failure on 4fa3bf699 was command_exec's '/advancement grant @s everything' count (116 vs 115), which fails identically without this PR; this head reruns parity. sizes_differ is a few array reads per pass per frame.
2026-11-07 02:10:17 +01:00
Horizon agentandClaude Opus 5.5 f31f4d179a horizon-bots: fight bots swing as 26.3 does, and --crits for PvP benches
The fight behaviour's swing went out as a raw Punch with a hand byte
26.3's ServerboundSwingPacket doesn't have: every server kicked the bots
on their first hit ("packet 46 was larger than expected: 1 extra bytes").
It now sends the same Punch as --attack.

--crits makes fight bots swing at a sword's full strength (13 ticks) and
jump into every other swing, landing it on the way down: a critical hit,
then a sweep from the ground. tools/perf PvP scenes get crit and sweep
particles from real hits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:08:33 +01:00
Horizon agentandClaude Opus 5.5 88a372f808 Let mob_local_sound and ominous_warning_sound run with parity.py's defaults
tools/parity.py starts every harness with no arguments, and both scripts
required --binary and --work-dir, so parity-main failed them in 0 s
(argparse usage error) since #10524 and #10266. Like
hostile_sound_categories, they now build the game through net.sides and
pick a work folder under .local/ when none is given.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:06:56 +01:00
Horizon agentandClaude Opus 5.5 1f7515f5ee Item move comment: a minecart does not stop an item
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:04:23 +01:00
Horizon agentandClaude Opus 5.5 84f77ee9a5 Launcher: keep the Home tab notch and the settings rows on wide windows
Follow-up to #10529 (atsauban's responsive wrapping). The Home tab strip
took the full width at every size, so glass::tabbed drew one plate and
lost its notch even at 1440 px: it is full width and scrolls only when
compact. setting_row went from 40 px to 16 px with justify_between for
every row; it keeps 40 px between name and control and wraps the
control under the name (12 px) when the window is too narrow. The
breakpoints are named constants beside WIDE.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:01:34 +01:00
Horizon agentandClaude Opus 5.5 8ce49872e7 Follow-ups to #11033, #11044, #11043 and #11016
- Death protection: one prototype lookup per hand instead of a component
  map rebuilt from the stack's patch. update_totem_ready runs for both of
  the player's hands and sync_mob_death_protection for every armed mob,
  every tick; only a stack that patches the component takes the old path.
- Remote projectiles: the swept bubble-column cells are deduplicated
  without a HashSet per projectile per tick.
- Ident::read hands a namespaced identifier back without a second String.
- parked_worlds keeps its doc comment (#11016 put parked_border under it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:59:14 +01:00
Horizon agentandClaude Opus 5.5 280ef828d6 A placed block's swing is no longer sent back to the player
handleUseItemOn sends the use's swing to the player's own client only when
the result is not SwingSource.PREDICTED. Horizon sent every swing back, so
on a slow connection the echo of a placement's swing arrived after the first
half of the client's own swing and swung the arm again (#9887). A placement
and a block's own SUCCESS now count as predicted; the beds, light block,
respawn anchor and sign stay SUCCESS_SERVER and are still sent back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:59:07 +01:00
Horizon agentandClaude Opus 5.5 3a6410b63b Location enchantments: copy only the armor slots that changed
The check runs every tick for every player; cloning all four stacks each
time allocated their components for nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:57:52 +01:00
Horizon agentandClaude Opus 5.5 482cc7707c Merge origin/main into #10119
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:57:49 +01:00
Horizon agentandClaude Opus 5.5 e409b37078 Brush: a mob blocks the brush out to the block interaction range
BrushItem.calculateHitResult picks entities with blockInteractionRange, not
the entity range: a mob 3 to 4.5 blocks away in front of the block stops it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:57:43 +01:00
Horizon agentandClaude Opus 5.5 64c68064f6 Merge origin/main into #10180
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:57:37 +01:00
Ludovic Marie 80947472c1 Merge pull request #11087 from horizon-rust/hosting/amp-template
🤖 Agent: the AMP update stage runs the egg's own install script byte for byte (test pins it), so both panels install and SHA-256-check the same way; not imported into a live AMP.
2026-11-07 01:55:30 +01:00
Horizon agent cf93b66d14 Merge remote-tracking branch 'origin/main' into fix/items-fall-through-minecarts-11091 2026-11-07 01:53:29 +01:00
Horizon agentandClaude Opus 5.5 486dd3f058 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:52:03 +01:00
Horizon agentandClaude Opus 5.5 53d0ea1320 Native mods: read and write the open chat box (native_chat_input, native_set_chat_input)
A client mod that converts text as it's typed (Emoji Type's :code: to emoji)
reads the chat box's text and cursor and writes the result back as an edit,
so completions refresh as for typing. Only what typing lets in reaches the
box. Stacked on native_open_chat.

Part of #10107

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:52:03 +01:00
Horizon agentandClaude Opus 5.5 403ecdbadb CI: the by-hand Windows ARM64 job reads the launcher's --version from bash
Its first runs failed at "horizon-launcher --version failed": pwsh read no
output from the GUI-subsystem launcher, while launcher.yml's same check, from
bash on the same runner, passes on every main push.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:51:38 +01:00
Horizon agentandClaude Opus 5.5 5d91256713 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:50:47 +01:00
Horizon agentandClaude Opus 5.5 5a24802d6a rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:50:16 +01:00
Horizon agentandClaude Opus 5.5 ac8a09ef0a rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:49:41 +01:00
Horizon agentandClaude Opus 5.5 0a639d598e Merge main into #10501 (test module list in pack_post.rs)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:49:38 +01:00
rdbt 13f70ad482 Merge pull request #11250 from TheMrZZ/fix/h252-loaded-pig-tags
Normalize Mob Tags when adopting NBT
2026-11-06 22:09:57 +00:00
rdbt 6590656a9a Merge pull request #11393 from ilguerro/fix/10548-villager-memories
Save villager brain memories like vanilla
2026-11-06 22:09:51 +00:00
rdbt f2e7c1865f Merge pull request #11405 from ilguerro/fix/w076
Count collidable entities in auto-jump like vanilla
2026-11-06 22:09:45 +00:00
rdbt 876c817f7d Merge pull request #11397 from ilguerro/fix/w029
Keep wandering trader homes like vanilla
2026-11-06 22:09:40 +00:00
rdbt b465f24309 Merge pull request #11395 from ilguerro/fix/w024
Match vanilla hanging entity coexistence checks
2026-11-06 22:09:04 +00:00
DetimesandClaude Opus 5.5 611dfc244f Merge upstream/main into hooks-all
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 21:38:48 +00:00
DetimesandClaude Opus 5.5 2e01586b5e Merge upstream/main into hooks-b-all
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 21:35:45 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 d36a2c9e21 fix(shaders): give the scripted-clock test its shader API line
main's test predates NativeFrame::new's api argument; it runs a 0.1 frame, as the other tests do.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 22:33:28 +01:00
Nikita Maximilian Grimm 1ade310792 Merge remote-tracking branch 'origin/main' into shader-api-02-serve
# Conflicts:
#	crates/horizon-client/src/game/native_shaders.rs
#	crates/horizon-client/src/game/shader_packs.rs
2026-11-06 22:29:20 +01:00
rdbt 9d11d0c710 Merge pull request #11369 from Skr1zzzz/ride-any-tracked-entity
Multiplayer: the player rides any tracked entity, sitting where vanilla seats it
2026-11-06 21:27:13 +00:00
rdbt c90689fd74 Merge pull request #10168 from NikitaMGrimm/shader-pack-identity
Keep native shader selections and settings across updates
2026-11-06 21:27:07 +00:00
rdbt 22628d794c Merge pull request #10194 from IanWorley/native-api/block-appearance
Native mods: block state facts and the client's resource files, for maps and radars
2026-11-06 21:27:01 +00:00
rdbt a94b615c99 Merge pull request #11014 from senjinthedragon/entity-at-velocity
Shader packs: at_velocity for entities
2026-11-06 21:26:55 +00:00
rdbt 63c8c5bce2 Merge pull request #11181 from Slavik-85/f3-overlay-light
F3 overlay: paint at 20 Hz, no per-frame OS calls, less GPU timing
2026-11-06 21:26:49 +00:00
Nikita Maximilian Grimm 1d1f36972c Merge remote-tracking branch 'origin/main' into shader-api-02-serve
# Conflicts:
#	docs/api/shaders.md
2026-11-06 22:14:42 +01:00
rdbtCVSandClaude Opus 5.5 e70f5347ba Realms: finish the merge of main (frontend helper, tier fields)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:33:13 +00:00
Skr1zzzzandClaude Opus 5.5 ad8a713d95 Multiplayer: the player rides any tracked entity, sitting where vanilla seats it
A server seating the player on a block display (sit mods, NPC plugins) took the vehicle for a minecart; none was
found, so riding was dropped and the player fell into the slab under the seat (y -61.0 where the server keeps
-60.1). The seat is the vehicle's riding position less the player's vehicle attachment (0.6, scaled).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 23:31:46 +03:00
rdbtCVSandClaude Opus 5.5 b25dbdab4e Restore the #[test] the merge of main took from this test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:29:53 +00:00
rdbtCVSandClaude Opus 5.5 dc175fe838 Hoppers restart a furnace's cooking when they fill its empty input
AbstractFurnaceBlockEntity.setItem resets the time; without the early reset a hopper-fed furnace would smelt its first item in one tick. Review feedback on #10170.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:23:55 +00:00
rdbtCVSandClaude Opus 5.5 d447274d79 Merge origin/main into PR #10170
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:22:15 +00:00
rdbtCVSandClaude Opus 5.5 c99811b0a7 /say: no captured copy of the broadcast line
A guest's /execute run say and the dedicated console saw it twice; review feedback on #10015.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:21:48 +00:00
rdbtCVSandClaude Opus 5.5 883d53f647 Merge origin/main into PR #10015
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:21:48 +00:00
rdbtCVSandClaude Opus 5.5 dcc21109ac Say what the automatic grant does for an advancement with no criteria
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:18:41 +00:00
rdbtCVSandClaude Opus 5.5 daf2f4debc Statistics tabs read the raw key, as Screen.keyPressed does
Review feedback on #10124.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:18:16 +00:00
rdbtCVSandClaude Opus 5.5 c612f19826 Merge origin/main into PR #10124
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:17:59 +00:00
rdbtCVSandClaude Opus 5.5 8c820a5994 Chat: clearing a draft runs onEdited, so its old command info goes too
EditBox.setValue fires the responder; review feedback on #10013.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:16:17 +00:00
rdbtCVSandClaude Opus 5.5 377650f3ee Merge origin/main into PR #10013
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:16:16 +00:00
rdbtCVSandClaude Opus 5.5 7fbe88924e Keep AdvancementProgress.isDone false for an advancement with no criteria
AdvancementRequirements.test returns false when its list is empty; review feedback on #10193.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:15:51 +00:00
rdbtCVSandClaude Opus 5.5 42948e3f17 Merge origin/main into PR #10193
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:15:26 +00:00
rdbtCVSandClaude Opus 5.5 8761658a18 Merge origin/main into PR #10180
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:10 +00:00
rdbtCVSandClaude Opus 5.5 06a0f118e2 Merge origin/main into PR #10119
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:07 +00:00
rdbtCVSandClaude Opus 5.5 6218257d19 Merge origin/main into PR #10118
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:06 +00:00
rdbtCVSandClaude Opus 5.5 41d3a0cbb7 Merge origin/main into PR #10009
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:05 +00:00
rdbtCVSandClaude Opus 5.5 5393956e8a Merge origin/main into PR #9995
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:11:04 +00:00
rdbtCVSandClaude Opus 5.5 57a7b16d5a Merge origin/main into PR #9968
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:08:41 +00:00
rdbtCVSandClaude Opus 5.5 76c095290f Custom skies: a fade out ending at the fade in's start ramps down over the rest of the day
Review feedback on #10002.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:07:41 +00:00
rdbtCVSandClaude Opus 5.5 7581d1fbe3 Merge origin/main into PR #10002
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:06:52 +00:00
rdbtCVSandClaude Opus 5.5 c25f5a1f49 Merge origin/main into PR #11081
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 20:05:45 +00:00
rdbtCVS 7b21af9519 Merge origin/main into PR #10196
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/native_client_services.rs
2026-11-06 19:59:43 +00:00
rdbtCVS 67fa3d1bb3 Merge origin/main into PR #10065
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-11-06 19:59:37 +00:00
ed0780a03a Count collidable entities in auto-jump like vanilla
LocalPlayer.updateAutoJump reads Level.getCollisions, which returns the
collidable entities' boxes (EntityGetter.getEntityCollisions, query
inflated by 1e-7) before the block shapes. Horizon's auto-jump only saw
blocks, so walking into a boat, a shulker or a happy ghast never
auto-jumped onto it.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 19:48:41 +01:00
Ian WorleyandClaude Opus 5.5 cd86e8f45d docs(native-mods): entities a client sees
Document native_client_entities with its new fields,
native_client_items, native_client_texture and skins by UUID, which
entities the list leaves out, and an example loop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:15:15 -06:00
Ian WorleyandClaude Opus 5.5 eecfdf2f7b feat(native-mods): list the client's entities and dropped items
Client mods such as radars, name tags and spectator ESP need the
entities the client shows, with what they look like, without reaching
into Game's internals. API 411's native_client_entities had ids,
positions and player profiles, but no type, babyhood or texture, and no
dropped items.

- NativeClientEntity gains kind, yaw, baby, spectator and the texture
  its body draws with (mobs::render::body), plus position_at, the
  renderer's interpolation. The struct is non_exhaustive, so existing
  mods keep building.
- native_client_items lists the dropped items the client shows.
  native_item_entities stays server-only, so server logic never acts on
  a client's copies.
- native_client_texture returns TexturePixels by the name texture and
  NativeClientProfile::skin give: a downloaded skin from memory, else
  native_client_resources().texture(name).

Pending fragment client_entity_list. Tests: position_at between two
ticks, and a client fed the server's play packets reading a zombie, a
calf, a spectating player with its default skin and a dropped item
through Host, plus texture pixels and a refused path out of the packs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:15:15 -06:00
Ian WorleyandClaude Opus 5.5 6d9ed21235 feat(native-api): read the client's resource files and texture pixels
Add the `native_client_resources()` helper. It returns `ClientResources`
over the resource tree the game loaded, which is vanilla's with the
resource packs turned on and the content mods' files over it. A client
mod fetches it on the game thread and can then use it on any thread.

`read(path)` returns a file's bytes for a plain path under `assets/`,
such as `assets/minecraft/textures/entity/cow/cow_temperate.png`.
`texture(name)` decodes a texture by name (`block/stone`,
`entity/cow/cow_temperate`) into `TexturePixels`. Paths outside
`assets/` return none, so Horizon's own prepared tables stay private.

Mods stop reading `options.resources` with `atlas_sources::texture_path`
and `prepared_files::read`. The tests build a folder resource pack over
a fake vanilla tree and check that the pack's texture wins, and that
paths outside `assets/` are refused.

Co-Authored-By: Claude Opus 5.5 <[邮箱]>
2026-11-06 12:15:01 -06:00
Ian WorleyandClaude Opus 5.5 908903a58e feat(native-api): block states' facts and looks for client mods
Add the `native_block_states()` helper. It returns `BlockStates`, a copy
of every block state keyed by its `BlockStateId`. A client mod fetches it
on the game thread and can then use it on any thread.

Each state's `BlockStateFacts` holds the block name, air, invisible,
`FluidState`, waterlogged, light emission, outline boxes, map colour and
whether the state can collide at all. `sprites(id)` returns what the
state's model draws on each side, as `native_block_sprites` does for a
block name. `texture(sprite)` names the texture behind a sprite, the
inverse of `native_texture_sprites`. `mod_api` now also exports
`FluidKind`, the type `FluidState::kind` returns.

The table copies the game's internal facts into stable public types.
Client mods that draw blocks their own way, such as minimaps, world maps
and radars, no longer need `res.facts()`, `res.collision()` or
`block_sprites::Lookup`, which change from commit to commit.

Tests run on real prepared resources (ignored without
HORIZON_TEST_RESOURCES): grass block facts, air and light, slab
outlines, and a grass block's face texture names.

Co-Authored-By: Claude Opus 5.5 <[邮箱]>
2026-11-06 12:15:00 -06:00
TheMrZZandCodex gpt-6.1-sol 686d897f59 Normalize Mob Tags when adopting NBT
Adopt the bounded String input before duplicate removal in the two Mob NBT load paths. Keep the canonical retained state visible to real tag commands and saves, and pin cap, duplicate, scalar and live rewrite behavior against Java.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-06 17:38:11 +01:00
Slavik-85andClaude Opus 5.5 791e721843 F3 overlay: paint at 20 Hz, no per-frame OS calls, less GPU timing
The overlay formatted its lines and laid out its glyphs every frame. It now
paints at most 20 times a second and copies those vertices in between, and
repaints at once for another window size, GUI scale or font epoch.

- The refresh rate is read at most once a second: on Windows, winit calls
  EnumDisplaySettingsExW each time. The Window line uses the surface size.
- Passes are timed only while the overlay lists their times, at most 10 frames
  a second. gpu-passes and HORIZON_SHADER_PACK_TIMING keep every sample.
- The CPU name is read in the background when the window opens: on Windows it
  starts reg.exe, which the first F3 frame waited for.
- DebugEntries::any_enabled and status no longer build a String per entry; the
  game asks every frame, F3 closed or not.
- Closing the overlay stops the server thread computing its lines each tick.
- The Mem, Allocation rate and Allocated lines now show on Windows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 16:28:44 +02:00
60742e9c49 Save villager brain memories like vanilla
A villager's Brain.memories now keep golem_detected_recently, last_woken
and potential_job_site, and each memory's remaining ttl, through a save
and load (Brain.pack, MemoryMap.CODEC, ExpirableValue.codec). The brain
built at the first tick starts from the loaded memories.
last_worked_at_poi stays unsaved: the villager brain does not register it.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:35:37 +01:00
0e0fa342b8 Keep wandering trader homes like vanilla
Use the shared mob home for spawn, movement restrictions and persistence.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:35:37 +01:00
fa76ed4d94 Match vanilla hanging entity coexistence checks
Reject conflicting overlaps between paintings and item frames.

Co-Authored-By: Codex (gpt-6.1-terra) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:35:36 +01:00
Horizon agentandClaude Opus 5.5 34af7009f5 Multiplayer: drag a saved server by its row to move it in the list
Reordering a long server list took many clicks on the icon's small arrows or
Shift+arrow presses. The press that selects a saved server now also holds it:
moving the pointer puts it on the saved server's row underneath, the others
close up, and the release saves the order. A press on the selected server
still joins, as in vanilla, and holds nothing.

Fixes #10060

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:38:53 +01:00
Horizon agentandClaude Opus 5.5 3365d72927 Native mods: client_attack and client_hurt hooks for the local player's combat
A client mod (battle music, a combat HUD) couldn't hear its own player's
fights on a remote server: the attack and damage hooks run where Horizon is
the server. native_client_attack(target, player) fires when the attack packet
leaves; native_client_hurt(attacker, player) when the server's damage event
for this player arrives, with the causing entity it names and whether this
client knows it as a player.

Fixes #10064

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:31:31 +01:00
Horizon agentandClaude Opus 5.5 ee55bb1e75 Dropped items fall through minecarts, as in vanilla
An item's move takes getEntityCollisions: the entities whose
canBeCollidedWith(item) holds. AbstractMinecart overrides only
canCollideWith (for its own moves), so a cart keeps Entity's false and
an item dropped on it falls through; the item list chained the carts in.
Boats, living shulkers and a held happy ghast still hold items (#11091).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:28:51 +01:00
Horizon agentandClaude Opus 5.5 1b221b01da Native mods: native_take_screenshot takes a screenshot as F2 does
A client mod (timed or repeated screenshots, as fabrishot does) asks for the
next frame's screenshot through F2's own path: screenshots/, vanilla's file
name, the chat line. It returns the file it will write; an error without a
window, or while another screenshot waits for the frame, which it would
otherwise replace.

Part of #9606

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:27:06 +01:00
Horizon agentandClaude Opus 5.5 3815013235 Native mods: native_open_chat opens the chat with text typed in
A client mod (a command macro such as CommandKeys) can open the chat screen
with text pre-filled for the player to finish, as T and / open it. Nothing is
sent. An error with no player in a world or while another screen is open.

Part of #10120

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:23:56 +01:00
Horizon agentandClaude Opus 5.5 c960a329ce Detect the machine's architecture in horizon-telemetry
horizon-client-render forbids unsafe code, which the Windows ARM64 build
reported (IsWow64Process2 and sysctlbyname are FFI). The detection lives with
the other system probes in horizon_telemetry::system now.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:20:50 +01:00
Horizon agentandClaude Opus 5.5 594552fec4 Hosting: an AMP template for horizon-server, beside the Pterodactyl egg
packaging/server/amp/ holds a template for CubeCoders AMP's generic module.
Its update stage runs the egg's own install script (the release asset checked
against its SHA-256), the start runs the same horizon-start.sh, and its
settings are the egg's variables with the same defaults. Tests keep both in
step and check the console patterns against the lines the server logs.

Fixes #8238

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:13:17 +01:00
Horizon agentandClaude Opus 5.5 d3d24aca55 Optimize World: cancel within a chunk, one sync per region file
- Each worker looks at the cancel flag before every chunk, not only before
  every file, so Cancel stops within a chunk on large region files. A
  canceled file is never swapped in by recreate_region_files.
- The writes of a region file reach the disk once, when the file is done,
  instead of a sync_data after every chunk. On the ThinkPad (NVMe, ext4) the
  per-chunk sync was half of a 1.21.1 world upgrade (187 s -> 89 s, one
  thread); on macOS sync_data is F_FULLFSYNC and costs far more.
- The calling thread is worker 0 and helpers are named World Upgrader #N;
  a helper that fails to start leaves its files to the others.
- Tag modifiers are no longer cloned per chunk; the recreate folder is made
  once per file.
- Tests: the parallel/one-thread comparison also checks vanilla counts
  computed from the fixture; a new test cancels from the first chunk with 4
  workers and checks at most 4 chunks were looked at and that a canceled
  recreate leaves the world unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:08:06 +01:00
Horizon agent 4f7fadfd9e Merge remote-tracking branch 'origin/main' into pr-11049 2026-11-06 13:06:39 +01:00
Horizon agentandClaude Opus 5.5 11d40ff1b9 Native mods: quinn 0.11 (QUIC) in Cargo.lock through horizon-mod-deps
Mods that speak QUIC (e4mc's relay) couldn't build: their dependencies must
already be in Horizon's lock. quinn joins horizon-mod-deps as an optional
dependency (feature quic: runtime-tokio + rustls-ring, so TLS stays on the
rustls + ring already in the lock). Nothing in the game builds it.

Fixes #9602

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:04:20 +01:00
Horizon agentandClaude Opus 5.5 33c431e3ae Docs: Intel Macs and Windows ARM64 have builds now
The README and the hosting guide still said Intel Macs come "from the next
Nightly" and listed no Windows ARM64 launcher; both ship in every Nightly and
launcher release.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:02:53 +01:00
Horizon agentandClaude Opus 5.5 c19d97699d Windows ARM64: the x64 build emulated there also starts on DirectX 12
Auto already tries DirectX 12 before Vulkan on Windows ARM64, where Vulkan lost
the device (#7513, #6320 on Adreno), but it read the build's architecture: the
x64 game, which the x64 launcher installs on a Snapdragon PC, still started on
Vulkan. The order now follows the machine's architecture (IsWow64Process2), and
crash reports name the emulation ("x86_64 on aarch64"), on Windows and under
Rosetta 2 on macOS (sysctl.proc_translated).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 13:00:51 +01:00
Horizon agentandClaude Opus 5.5 904e1d5597 Video settings: LOD Distance gets fine stops from 16 to 256 chunks
A small render distance (8 chunks on a phone or an integrated GPU) could only
add far terrain out to 64 chunks, the slider's first stop. The slider now stops
at 16, 24, 32, 48, 64, 96, 128, 192 and 256 before doubling as before.

Fixes #9694

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 12:55:06 +01:00
DUzzL 1fc4fa2529 Upgrade region files in parallel in Optimize World 2026-11-06 12:21:55 +01:00
DetimesandClaude Opus 5.5 964454e1f8 Test that a mod's health 0 kills a guest, in and out of their turn
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:18:32 +00:00
DetimesandClaude Opus 5.5 270d067afa Keep the feedback of a captured vanilla command
A capturing server source is not silent: it takes every sendSuccess and
sendFailure line and informs no operator, so run it that way.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:13:31 +00:00
DetimesandClaude Opus 5.5 962d7c6a22 Native helpers: per-player feedback, SNBT and map helpers move out of the hook points
They sat in the hook-point section, so the catalog test took them for hooks and hook_points_stay_hookable saw world_items!; now in the helpers' impl block of the same file (targets unchanged), the macro written out.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:57:17 +00:00
DetimesandClaude Opus 5.5 304340fa9f native_boss_bar: check the bar's ranges without a negated call in the hook points
hook_points_stay_hookable reads `!(` as a macro.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:50:36 +00:00
DetimesandClaude Opus 5.5 830ee7c7f7 Catalog test: chunk_generate and lod_column live in horizon_world::worldgen::native_generate
The test maps each free hook to its module; the two world generation hooks are free functions in horizon-world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:50:36 +00:00
SenjinandClaude Opus 5.5 d8160a3ded Shader packs: at_velocity for entities
Each entity's vertices are matched with last frame's by entity (Frame::entity_motion, now with
the GPU-posed start at each mark): last frame's pack buffer stays bound at set 1, binding 2, and
binding 3 gives each vertex its index there. at_velocity is this frame's view position less last
frame's (gbufferPreviousModelView joins the block when a pack reads it); a vertex with no match
reads zero, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:26:13 +01:00
Detimes fce1fb23d6 Merge remote-tracking branch 'upstream/main' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/raids.rs
2026-11-06 10:26:00 +00:00
Detimes ef66a2db57 Merge remote-tracking branch 'upstream/main' into hooks-b-all 2026-11-06 10:26:00 +00:00
DetimesandClaude Opus 5.5 4198a4de7f Tests: dispatch-test stubs for the new hook types (EntityDataValue, NativeGenerate, NativeProtoChunk, NativeLodColumn, TickSpan)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:21:12 +00:00
Detimes d1288fc34b Catalog test: log_line's target is horizon_client_lib::native_client_hooks
The test maps each free hook to its module; log_line (hooks-10461-a) lives in native_client_hooks.rs.
2026-11-06 10:18:21 +00:00
DetimesandClaude Opus 5.5 7750a91768 Tests: the respawn guest test from main fills Joined.configured (player_channels)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:14:00 +00:00
DetimesandClaude Opus 5.5 0580048c9d Clippy: then_some for the closing native store's slots (menu_input)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:14:00 +00:00
DetimesandClaude Opus 5.5 82f9fbf1ad Clippy: no explicit deref and sort_by_key in the generation hooks (chunk_generate, lod_column)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:13:59 +00:00
Horizon agentandClaude Opus 5.5 4fa3bf699b Merge main into #10501 (main builds again after #10544)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 11:09:00 +01:00
Detimes 624ac54cdc Dispatch tests: stub horizon_client_core::text and horizon_protocol::packets::login
player_pre_login and player_profile name those paths in their hook signatures; the generated
dispatch code compiled in the test crate needs them, as the existing horizon_client stubs.
2026-11-06 09:56:23 +00:00
aba1b1eca4 Interpolate whole-cell volumes four columns at a time; skip air runs at once
Part of #9996.

A chunk's noise volume is made of whole cells, and InterpolatedSampler
filled it one cell at a time: per cell eight corner loads and four Z
lerps, per column two X lerps and a bounds-checked index, then a scalar
loop of stores and additions (fill_cell alone was 6.4% of the Overworld
proto phase's instructions). For a volume aligned to its cells,
fill_whole_cells now walks the four columns of one Z row of a cell from
the bottom of the volume to the top. The X lerps at a level of corners
are computed once (they are the top of one cell and the bottom of the
next: the same lerps of the same samples), the four columns' repeated
additions run as four lanes, and four Ys of the four columns are stored
as four vectors. Each value comes from the same operations on the same
operands in the same order as vanilla's InterpolatedFunction fillCell;
only the order between columns changes. The top cell may be cut short
(the 16x114x16 volumes); a volume that is not aligned, or whose top
corners would clamp, keeps the general path.

fill_noise_inner spent about ten instructions on each air block above
the aquifer's skip height. A downward run of non-positive densities that
stays above it is now skipped in one step: nothing was written for those
blocks and note_skipped only clears a flag, so one call covers the run.

Exact: chunkgen_bench --snapshot files (terrain and decorated) are
byte-identical to main in 16 cases: the Overworld at seeds 12345, -1,
987654321 and -7, the Nether at seeds 12345 and 42, the End at seeds
12345 and 7, sizes 3 to 12. A new test compares sample_volume and the
new path with a transcription of vanilla's sampleWithBlockStep and
fillCell, on an input with NaNs, infinities, signed zeros and
subnormals, for 4x8, 8x4, 4x4 and 8x8 cells, and checks which volumes
the new path takes.

Proto-phase instructions (callgrind, 64 chunks, one thread):
Overworld 5.970 G -> 5.615 G (-5.9%), the Nether 2.868 G -> 2.853 G
(-0.5%).

A first draft by GLM-5.3 and DeepSeek V4.1 Flash, via pi, reviewed and
reworked (the air-run skip).

Co-Authored-By: pi coding agent (DeepSeek-V4.1-Flash) <[email protected]>
Co-Authored-By: pi coding agent (GLM-5.3) <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:56:01 +01:00
Detimes bce9527c43 player_client, server_player_state fragments: mod_api.rs is not a comment path
The stamp tool fills mod_api.rs's markers itself and refuses it in "comments".
2026-11-06 09:55:22 +00:00
Detimes 402534f462 run_command_as: run_guest_command_told takes the hub with ? (clippy::question_mark) 2026-11-06 09:55:15 +00:00
Detimes fb9ce454ed Build fix: one more entity_nbt test call of mob_to_nbt gets uuid_of
The same #10304 argument as in the earlier build fix; this call sits in a test only pr_ready's test run compiles.
2026-11-06 09:55:15 +00:00
Detimes f830672067 Merge remote-tracking branch 'upstream/main' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game/raids.rs
2026-11-06 09:50:14 +00:00
Detimes 0df8f65eda Merge remote-tracking branch 'upstream/main' into perf-region-inflate-in-place
# Conflicts:
#	crates/horizon-world/src/compression.rs
2026-11-06 09:49:51 +00:00
Nikita Maximilian GrimmandGPT 6.1 Sol c667277fcd chore: merge the updated shader identity core
Preserve the launcher layer while carrying main and the replacement shader screen from the game PR.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-06 10:49:49 +01:00
makscoder b7dbeda41a A brush stops on a mob in the crosshair
BrushItem.onUseTick aims with getHitResultOnViewVector, so a mob between the
player and the block is the nearer hit and the use releases without a stroke.
tick_brush used the block-only pick and brushed through the mob.
2026-11-06 12:45:11 +03:00
makscoder c1de12ab86 A brush or goat horn that uses itself up sends the interaction vibration
LivingEntity.stopUsingItem, reached when a use runs out or when the brush hits
no block, causes the ITEM_INTERACT_FINISH vibration. The brush and instrument
paths cleared the use directly instead, so only letting go of the button made a
sculk sensor hear them.
2026-11-06 12:42:48 +03:00
Nikita Maximilian GrimmandGPT 6.1 Sol 1295602deb fix(shaders): integrate the new settings screen
Merge main at 0873f24d2, retain its replacement shader screen, and route native selections and options through stable manifest IDs.

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-06 10:40:49 +01:00
DetimesandClaude Opus 5.5 f416696038 cargo fmt: native_npcs before native_packets in game.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:38:41 +00:00
DetimesandClaude Opus 5.5 fd2ec97a17 Native hooks: chunk_generate and lod_column reach mods through a callback table
horizon-world cannot call horizon-client's dispatch, so the two generation hooks
call a NativeGenerateHooks table that entry.rs installs (as native_load_hooks
does for the loot hooks). mod_api re-exports NativeGenerate, NativeProtoChunk
and NativeLodColumn for the generated events. Without the table, and without
mods, both hooks do nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:16:06 +00:00
DetimesandClaude Opus 5.5 9b08e99433 Native hooks: mod_api contracts for the 16 new hook fragments, dispatch calls filled
Since #6253 the mod API generator needs a mod_api entry for every pending hook
fragment. Cancel flags get {cancel, out: cancel}, menu_input_closed collects its
slots, every other hook listens. The placeholder bodies are filled by
`cargo run -p xtask -- mod-api`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:16:06 +00:00
Detimes a93e07da89 Merge branch 'b-bundle-18' into hooks-b-all 2026-11-06 09:12:35 +00:00
Detimes 37dfe7d880 Merge branch 'b-bundle-17' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/mod_api.rs
#	docs/native-mods.md
2026-11-06 09:12:34 +00:00
Detimes 58ba651c7b Merge branch 'b-bundle-16' into hooks-b-all
# Conflicts:
#	docs/native-mods.md
2026-11-06 09:12:15 +00:00
Detimes 950cd1058f Merge branch 'b-bundle-15' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 09:12:08 +00:00
Detimes 93ff841913 Merge branch 'b-bundle-14' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game/commands/world_cmds.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 09:11:56 +00:00
Arslan ArmanulyandClaude Opus 5.5 6199a21cbe Merge origin/main into shaderpacks/same-size-buffer-declarations
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 14:11:26 +05:00
Detimes 4cd44b1bfa Merge branch 'b-bundle-13' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	docs/native-mods.md
2026-11-06 09:11:11 +00:00
Detimes 3dc1eac9a1 Merge branch 'b-bundle-12' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/lan/hub.rs
#	docs/native-mods.md
2026-11-06 09:10:59 +00:00
Detimes 1065005412 Merge branch 'b-bundle-11' into hooks-b-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 09:10:36 +00:00
Detimes 71e2f6b0f5 Merge branch 'b-bundle-10' into hooks-b-all 2026-11-06 09:10:07 +00:00
Detimes 702055471b Build fix: mob_to_nbt's uuid_of argument in raids.rs and three entity_nbt tests
#10304 added the argument while these calls were merged; upstream/main does not compile without it.
2026-11-06 09:10:01 +00:00
Detimes bf7e616ebf hooks-all: integration fixes for the merged hook branches
- NativeEffect (player_effects) and NativeInventoryKind (set_inventory) are pub and re-exported in
  mod_api: the generated mod API refused both fragments.
- native_teleport_player's cross-level teleports fill LevelTeleport's new cause (hooks-move) with
  "unknown"; player_teleport's docs name that case.
- lan_vehicles: the vehicle_moved_v2 call closure is FnMut.
- cargo fmt (game.rs mod lines in order).
2026-11-06 09:00:30 +00:00
Detimes 8887870ec3 Build fix: mob_to_nbt's uuid_of argument in raids.rs and three entity_nbt tests
#10304 added the argument while these calls were merged; upstream/main does not compile without it.
2026-11-06 08:58:30 +00:00
Detimes 21d7853cc9 Merge branch 'hooks-pread' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/native_server.rs
#	docs/native-mods.md
2026-11-06 08:51:10 +00:00
Detimes b2ec69a247 Merge branch 'hooks-vehicle' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 08:51:09 +00:00
Detimes 23bb236cfc Merge branch 'hooks-pstate' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	docs/native-mods.md
2026-11-06 08:51:08 +00:00
Detimes af12d6d443 Merge branch 'hooks-login' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game.rs
#	docs/native-mods.md
2026-11-06 08:51:07 +00:00
Detimes b74f04855d Merge branch 'hooks-inv' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 08:51:06 +00:00
Detimes 010504d207 Merge branch 'hooks-move' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 08:51:06 +00:00
Detimes 24876b536b Merge branch 'hooks-cmds' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/lan.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/game/native_server.rs
#	docs/native-mods.md
2026-11-06 08:51:04 +00:00
Detimes 5699b862d6 Merge branch 'hooks-perm' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 08:49:52 +00:00
Detimes 90a7a7d47b Native mods: a compound mods keep on a player (player_persistent_data)
native_player_persistent_data(uuid) reads and
native_set_player_persistent_data(uuid, data) replaces the compound mods
keep on a player, as NeoForge's Entity.getPersistentData() and Bukkit's
PersistentDataContainer. It is saved in the player's file as
NeoForgeData, beside the keys Horizon keeps without modelling them, and
kept over a death and a level change; an empty compound takes the key
away. Nothing calls them without a mod.

Unproven: not yet compiled or probed live (build queue); the integration branch tests it.
2026-11-06 08:49:26 +00:00
Detimes 66b3059251 Merge branch 'hooks-10461-d' into hooks-all
# Conflicts:
#	docs/native-mods.md
2026-11-06 08:49:13 +00:00
Detimes 341896762f Merge branch 'hooks-10461-c' into hooks-all
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-06 08:49:11 +00:00
Detimes 513b1fe43d Merge branch 'hooks-10461-b' into hooks-all
# Conflicts:
#	crates/horizon-client/src/entry.rs
#	crates/horizon-client/src/game/mods.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/menu.rs
#	crates/horizon-client/src/native_client_hooks.rs
#	docs/native-mods.md
2026-11-06 08:48:54 +00:00
Detimes b99b9e13f7 Native hooks: item_dropped_v2 can cancel a throw (unproven on a server)
native_item_dropped_v2(player, stack, cancel) is now cancellable, as
NeoForge's ItemTossEvent is: a canceled throw puts the stack back into
the player's inventory (Inventory.add, the held slot first); only what
does not fit is thrown, unheard, and native_item_dropped hears nothing.
The drop statistics and the swing still happen, as in NeoForge. Wanted
by the combat log, freeze and inventory-lock ports.

Unproven: this commit was neither compiled nor run (builds were stopped for
the integration test); `cargo run -p xtask -- mod-api` filled the dispatch.
The listen-only v2 drop hook before it was proven on a server.

Part of #10461.
2026-11-06 08:48:11 +00:00
Detimes c2ec210fea Merge branch 'hooks-10461-a' into hooks-all 2026-11-06 08:47:51 +00:00
Detimes 9edbea0a6d Native hooks: thrown projectiles at the world border (projectile_world_border)
native_projectile_world_border(dimension, entity, kind, pos, velocity,
collide) is heard each tick a thrown projectile (pearl, snowball, egg,
potion...) inside the world border would end its next step outside it,
before 26.3 stops it on the border like a wall (MC-310476). A mod may
change its position and velocity, or set `collide` false: that tick's ray
is then clipped without the border, so it flies through as before 26.3.
Arrows and tridents, which Horizon does not stop at the border, are not
heard. Static (no self), called from tick_projectiles.

Unproven: not compiled or run on a server on this branch (handed to the
hooks-all integration build).

Part of #10461.
2026-11-06 08:47:25 +00:00
Detimes c6ed7f75a0 Native hooks: farmland trampling, cancellable (trample)
native_trample(dimension, entity, kind, pos, fall_distance, cancel) is
heard when a player or mob that fell onto farmland is about to turn it to
dirt, once FarmlandBlock.fallOn's chance and checks have let it (NeoForge's
FarmlandTrampleEvent, Bukkit's PHYSICAL PlayerInteractEvent and
EntityInteractEvent). Canceling keeps the farmland and its crop; the fall
damage is taken either way. The landing rules ask through a new
BlockLandingWorld::trample_allowed (true by default), which the player's
and the mobs' landing worlds answer from a function the game sets once
(block_contact::TRAMPLE); there is no self.

Unproven: not compiled or run on a server on this branch (handed to the
hooks-all integration build).

Part of #10461.
2026-11-06 08:47:25 +00:00
Detimes 5caff4ecb4 Native hooks: where a dead player respawns (player_respawn_position)
native_player_respawn_position(player, name, dimension, pos, yaw, pitch)
is heard once per respawn, after the Respawn click and before the player
is placed (NeoForge's PlayerRespawnPositionEvent, Bukkit's
PlayerRespawnEvent.setRespawnLocation), with their respawn point: a bed
or anchor block's top, a forced point, else the world spawn. Left alone,
the point is resolved as before. A changed dimension or position places
the player exactly there, with no bed or anchor used and no free column
looked for; yaw and pitch set their look, which Hub::respawn now takes
instead of always 0, 0. An unknown dimension or a non-finite value
leaves that part unchanged.

Unproven: not compiled or run on a server on this branch (handed to the
hooks-all integration build).

Part of #10461.
2026-11-06 08:47:25 +00:00
Detimes 893eb8d636 Native hooks: a player's teleport before it reaches their client (player_teleport)
native_player_teleport(player, name, cause, from_dimension, from,
to_dimension, to, relative, cancel) is heard before a server player's
teleport is sent (Bukkit's PlayerTeleportEvent, NeoForge's
EntityTeleportEvent): commands (/tp within a level and into another),
ender pearls, chorus fruit, the spectator teleport menu, a dismount and
other server moves of the player. A mod may move `to`, cancel (the
player and their body stay where they are), or set the look and motion
bits of the position packet's relative flags, so a pearl can keep the
player's speed (0x1f8, what 26.2 sent); the position bits are ignored.

The call sites name the cause in a thread-local (lan::hub::teleport_cause)
that Hub::teleport and Hub::teleport_keeping_motion read; a teleport
without a named cause (respawns, portals, the End gateway, vehicles
carrying riders, waking up, refused moves) is not heard. Command and
spectator teleports into another level carry their cause in
LevelTeleport. The hub reaches the hook through a function the game sets
once (lan::hub::PLAYER_TELEPORT); there is no self.

Unproven: not compiled or run on a server on this branch (handed to the
hooks-all integration build).

Part of #10461.
2026-11-06 08:47:25 +00:00
Detimes 8e83a2c898 Native hooks: a player's move before it is applied (player_move)
native_player_move(player, name, from, to, yaw, pitch, on_ground,
cancel) is heard for each position or look packet that changed either
and passed the server's checks (moved too quickly, moved wrongly, the
anticheat), before Hub::position_change applies it (Bukkit's
PlayerMoveEvent). Canceling sends the player back to `from`, as a
refused move does; a changed `to` puts them there and their client gets
a teleport. Riders, sleepers and moves while a teleport waits are not
heard. The hub reaches the hook through a function the game sets once
(lan::hub::PLAYER_MOVE), as the damage hooks do; there is no self.

Unproven: not compiled or run on a server on this branch (handed to the
hooks-all integration build).

Part of #10461.
2026-11-06 08:47:25 +00:00
Detimes 0c8fb31992 Native mods: respawn a dead server player without the Respawn click (respawn_player)
The helper native_respawn_player(uuid) respawns a dead server player as
their client's PERFORM_RESPAWN does (Spigot's player.spigot().respawn()),
after the players' packets of the server tick, through the same
guest_respawn. Nothing calls it without a mod.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:13 +00:00
Detimes d117673fe9 Native mods: let a server player fly in any game mode (set_flight)
The helper native_set_flight(uuid, allow, flying) lets a server player fly
in any game mode or not and sets whether they fly now, as Bukkit's
Player.setAllowFlight and setFlying: the guest keeps the mod's mayfly
(allow_flight) until their game mode changes or they respawn, their
abilities packet and the server's body carry it, and the flying check does
not kick them. Without a mod allow_flight stays false and every check is
the game mode's, as before.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:13 +00:00
Detimes 2efd4b8346 Native mods: seat an entity on a vehicle, take it off, empty a vehicle (mount)
The helpers native_mount(entity, vehicle), native_dismount(entity) and
native_eject_passengers(vehicle) seat a server player or mob on a boat,
minecart or mob, take it off, or take every rider off, as /ride does with
its checks and native_entity_mount (Bukkit's addPassenger, leaveVehicle,
eject). /ride's mount checks move into ride_mount, which both use; the
command's answers are unchanged. Nothing calls the helpers without a mod.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:12 +00:00
Detimes a8c67d77aa Native hooks: a ridden vehicle's move, cancellable and with its id, mounts included (vehicle_moved_v2)
native_vehicle_moved_v2(player, dimension, vehicle, kind, from, to, cancel)
is heard right after native_vehicle_moved, with the same state swapped in,
and also for a mob a server player rides. A canceled move puts the vehicle
back at from with its riders, a changed to puts it there
(native_teleport_vehicle), as Bukkit's VehicleMoveEvent with a teleport.
v1 keeps its signature, firing point and vehicles. Without mods nothing
moves the vehicle.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:12 +00:00
Detimes 052443e2a5 Native mods: teleport a vehicle with its riders (teleport_vehicle)
The helper native_teleport_vehicle(dimension, id, pos) teleports a boat,
minecart or ridden mob of the level in place with its riders, as /tp does
a vehicle (Bukkit's Vehicle.teleport): the riders stay seated and a server
player riding it is told, as their client drives it. Only where nobody's
state is swapped in. Nothing calls it without a mod.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:12 +00:00
Detimes 38a9866a1f Native mods: teleport a server player from any server hook (teleport_player)
The helper native_teleport_player(uuid, dimension, pos, yaw, pitch)
teleports a server player as /tp does (Bukkit's Player.teleport): at once
when they are the acting player or nobody's state is swapped in, else
after the players' packets of the server tick, so a port no longer runs
execute in .. run tp from the console, which waits for the next tick and
does nothing inside a player's hook. Into another level it is a command's
level teleport. Nothing calls it without a mod.

Unproven: not yet compiled or run on a server (builds stopped for the hooks-all integration).
2026-11-06 08:47:12 +00:00
Detimes aa8f5fc12a Native mods: send a player's command tree again (resend_commands)
The helper native_resend_commands(player) sends a player their command
tree again, with native_player_commands_sent, its v2 and the permission
nodes of the mods' commands asked anew, as Bukkit's Player.updateCommands:
after a permissions mod changed what they may use. The tree goes out in
the next server tick, with no player's state swapped in, so the helper
works from any server hook, the player's own included. Nothing calls it
without a mod.

Unproven: not yet built or run (handed to the hooks-all integration branch for the build and proof).
2026-11-06 08:47:05 +00:00
Detimes 329acc6c53 Native mods: the level one typed command runs at (player_command_level)
native_player_command_level(player, name, command, level) is heard in
Game::guest_command after native_player_command, before the server checks
the command: level comes in as the player's own and becomes the level this
one command runs at (GuestCommand.permission), so a permissions mod can
grant /gamemode to a player of level 0 or refuse a command to an operator,
as Paper's VanillaCommandWrapper does with minecraft.command nodes. A mod
command with a permission node falls back to this level. Unchanged, the
command runs at the player's level, as before.

Unproven: not yet built or run (handed to the hooks-all integration branch for the build and proof).
2026-11-06 08:47:05 +00:00
Detimes 0bd927b7c1 Native mods: a permission node on a mod's command (NativeCommand::permission)
NativeCommand::new(usage).permission(node) gives a mod's command a node, as
Bukkit's plugin.yml permission: does. The server then asks
native_has_permission(player, node, level), the command's level as the
default, both before it puts the command in a player's command tree and
before a player's typed command reaches native_perform_command, so a
permissions mod can grant it below that level or refuse it to an operator.
Without a permissions mod the level decides, as for a command without a
node; a dedicated server's console runs every registered command as before.

Unproven: not yet built or run (handed to the hooks-all integration branch for the build and proof).
2026-11-06 08:47:05 +00:00
Detimes 3cc7e3c822 Native mods: put a command the level hides back into a player's tree (player_commands_sent_v2)
native_player_commands_sent_v2(player, name, names, names_hidden) is heard
right after native_player_commands_sent, at the same times, with the root
commands the player's level hides (those a level 4 operator would get) in
names_hidden. A root a mod moves into names is sent with its subtree, as
for an operator of its level, so a command a permissions mod grants
completes and highlights in the player's chat box (Paper's
PlayerCommandSendEvent after a passed permission check). v1 keeps its
signature and firing point. Without mods nothing is granted and the tree
is unchanged.

Unproven: not yet built or run (handed to the hooks-all integration branch for the build and proof).
2026-11-06 08:47:04 +00:00
Detimes 25ac7a9841 Native mods: permission nodes, answered by a permissions mod (permission_check)
The hook native_permission_check(player, node, default_level, result) lets a
permissions mod (a LuckPerms port) say whether a player has a permission
node; the first mod to set result decides. The helper
native_has_permission(player, node, default_level) asks it, for every mod
and for the server, and falls back to the player's operator level against
default_level when no mod answers, as Bukkit's Permissible.hasPermission and
NeoForge's PermissionAPI.getPermission do. Without a permissions mod every
answer is the operator level, so nothing changes for unmodded servers.

Unproven: not yet built or run (handed to the hooks-all integration branch for the build and proof).
2026-11-06 08:47:04 +00:00
DUzzL 379850ac5c Never build an exit portal for a spectator using a nether portal 2026-11-06 09:41:40 +01:00
DUzzL 99b969686e Stop a deflected potion at the breeze and use its own random 2026-11-06 09:41:05 +01:00
Detimes fe7635e545 Cargo.lock: the 12 packages of the new horizon-mod-deps entries (unbuilt)
Written by hand from a scratch resolution seeded with this lock: rusqlite,
libsqlite3-sys, fallible-iterator, fallible-streaming-iterator, hashlink,
vcpkg, yaml-rust2, arraydeque, gif, weezl, color_quant, snap. Existing
entries keep their versions; chrono gains serde and mysql_common gains chrono.
To confirm: cargo metadata --locked must not want to change it.
2026-11-06 08:38:14 +00:00
Detimes 71b36cca26 Native mods: a store menu the hub closes on its own gives the cursor back too (removed) (unbuilt) 2026-11-06 08:37:33 +00:00
DUzzL ac4045d83f Keep the worn-armor snapshot per LAN guest 2026-11-06 09:37:03 +01:00
Detimes ca623aae34 Native mods: another player's inventory in a server menu with vanilla click rules, live or on their file (native_open_inventory_menu) (unbuilt) 2026-11-06 08:35:30 +00:00
Detimes 8c7807e832 Native helpers: the name over a player's head (set_name_tag) (unbuilt) 2026-11-06 08:34:22 +00:00
DUzzL 98e1e2d67f Credit trident damage_dealt_absorbed to the throwing LAN guest 2026-11-06 09:33:17 +01:00
Detimes 296d765d78 Native helpers: a player's styled name in chat (set_display_name) (unbuilt) 2026-11-06 08:33:08 +00:00
Detimes ca660e8d1f Native mods: server menus with input slots, their contents, the click's cursor and the slots on close (native_open_menu_with_input, native_menu_input_closed) (unbuilt) 2026-11-06 08:32:15 +00:00
Detimes 450b6c8548 Native hooks: leave mod_api to the stamp step; cancel flags named cancel (unbuilt)
The bundle's fragments carry no mod_api entry: session A adds them in one
step (add_mod_api.py, which takes a cancel: &mut bool parameter for the
cancel contract). So interact_entity_v2's claimed and
right_click_block_v2's canceled are named cancel; they act as before.

Not built yet.
2026-11-06 08:32:11 +00:00
Detimes 4cf75b20d9 Native helpers: a player's styled Tab list name (set_list_name) (unbuilt) 2026-11-06 08:32:07 +00:00
Detimes 873480bddc Native hooks: damage with the players' UUIDs and the attacker (living_incoming_damage_v3) (unbuilt)
native_living_incoming_damage_v3(victim, victim_uuid, kind, attacker,
attacker_uuid, direct, damage_type, amount, cancel) fires everywhere v1
fires (health::HURT_SERVER), right after it with the amount v1 left and
the same cancel flag. It adds a player victim's UUID, the causing entity
where v1 leaves 0 (a mob's attacker, a player's melee, the synchronous
attack in progress as living_knockback_v2 falls back to), a player
attacker's UUID and the direct entity. Players are noted by entity id as
each guest's turn starts (game/native_damage.rs, only while a native mod
is loaded), since the hit carries ids only and no Game.

Not done here, as the plan asked: a &mut self host (the victim is
borrowed out of the game inside Mob/Player/Dragon hurtServer, behind the
HURT_SERVER fn pointer) and Bukkit's modifier breakdown (the reductions
run after the hook in horizon-world's living_hurt and Mob::hurt_server).

Sentinel (attacker matched within 500 ms), Wanted, HuskSync and
CombatLog (an id-to-player map each tick) need the names.

Not built yet.
2026-11-06 08:31:35 +00:00
Detimes 0072925aef Native mods: kick a player with a whole text as the reason (kick) (unbuilt)
native_kick(player, reason) disconnects a player with a Text reason in its
colours and translations (Adventure's Player.kick(Component)), through
Hub::kick as /kick does, so the log line and native_player_kicked follow.
It is queued and runs at the tick's end with the mod messages
(flush_native_net), so it works from any server hook, the kicked player's
own included; today a port sends a raw Disconnect and then runs /kick with
one plain line.

Part of #10461.
2026-11-06 08:31:11 +00:00
Detimes 342a45b57a Native helpers: entities shown to one player's client alone (client_entities) (unbuilt) 2026-11-06 08:31:04 +00:00
Detimes ca9437b79f Native hooks: resource pack answers with the pack's id, configuration included (resource_pack_status_v2) (unbuilt)
native_resource_pack_status_v2(player, name, pack, status) is an additive
version of native_resource_pack_status with the answered pack's id
(Bukkit's PlayerResourcePackStatusEvent.getID), so a mod's own pack's
answers are told apart from the server's. In play it is heard just before
v1, which keeps its signature and firing point. The answers to the
server's own pack given during configuration, which the connection thread
handled alone, are kept (lan::Configured::pack_answers) and heard once the
player has logged in, right after native_player_logged_in. The connection
answers the packets exactly as before.

Part of #10461.
2026-11-06 08:30:06 +00:00
Detimes 0c10886ec0 Native mods: horizon-mod-deps adds rusqlite, yaml-rust2, gif, snap and mysql/chrono (unbuilt)
rusqlite 0.41 (bundled + cache, no default features) reads SQLite files such
as a Bukkit plugin's .db; yaml-rust2 0.13 (no default features, safe Rust)
reads and writes config.yml; gif 0.14 and snap 1 are small and pure Rust;
mysql gains chrono for DATETIME columns. All optional, nothing builds them.
Cargo.lock not yet updated.
2026-11-06 08:29:39 +00:00
Detimes d7c1e71d45 Native mods: each server tick's time and spans, a shared span switch and mob costs a mod samples (native_server_tick_timed, native_spans, native_mob_cost_sampling, native_take_mob_costs) (unbuilt)
tick_server_spans calls native_server_tick_timed with the pass's wall and CPU time and merged spans (TickSpan, re-exported from spans::Span) before handing them to the report. Spans are switched through tick_profile::want_spans by who wants them (HORIZON_SPIKES, horizon-report, a mod), so a mod and a report no longer turn each other's spans off. tick_profile keeps a mod's mob cost sums apart from the report's (two bits, two maps), so both can sample at once; calibration stays the report's. Fragment server_tick_timed.
2026-11-06 08:29:28 +00:00
Detimes 36323e05ae Native mods: each player's play packets once a tick (native_server_packets, native_count_packets) (unbuilt)
While a mod turns counting on, the network threads keep each framed and read play packet's id and body size in the connection's Outbox (io_pool Conn::frame and Conn::read, behind one relaxed load of a static switch, capped at 65536 each way); the server tick's end hands them to native_server_packets per player and way. Off (the default) nothing is kept and the hook never fires. Fragment server_packets.
2026-11-06 08:29:28 +00:00
Detimes 8abf5298fc Native mods: hurt a mob or a player from an attacker (native_hurt_entity) (unbuilt)
native_hurt_entity(dimension, target, amount, damage_type, attacker)
hurts a mob or a player of the level as LivingEntity.hurtServer with
damageSources().mobAttack(attacker) does, the way /damage <target>
<amount> <type> by <attacker> goes: cmd_damage's hurt is moved unchanged
into damage_target, which both call. Returns whether it hurt; false for
another dimension, a target that is neither a mob nor a player of the
level, a missing attacker or a non-finite amount.

Sentinel's own damage (damage set, workaround damage, lightning weapon)
today runs a damage command by UUID.

Not built yet.
2026-11-06 08:29:25 +00:00
Detimes 7fbd43b6a9 Native mods: offer and take away a mod's own resource packs (resource_packs) (unbuilt)
native_send_resource_pack(player, id, url, sha1, prompt, required) sends
ClientboundResourcePackPushPacket with the mod's own pack id (Bukkit's
Player.addResourcePack), checked against the packet's limits (hash at most
40, URL at most 32767 characters); native_pop_resource_pack(player, id)
sends ClientboundResourcePackPopPacket for one pack or, with None, all
(removeResourcePack). Both go through native_send. A decline still kicks
only when the server's own pack is required, as vanilla's
handleResourcePackResponse does.

Part of #10461.
2026-11-06 08:29:23 +00:00
Detimes e5acbdde15 Native helpers: hide an entity from one player's client (hide_entity_from) (unbuilt) 2026-11-06 08:28:56 +00:00
Detimes b842df0899 Native hooks: a mob's target changing, cancellable (living_change_target) (unbuilt) 2026-11-06 08:28:56 +00:00
Detimes 7f91f765b2 Native hooks: the plugin channels a player registers, in configuration and play (player_channels) (unbuilt)
native_player_register_channels(player, name, channels, register) hears
the names a client registers (minecraft:register) or unregisters
(minecraft:unregister), NUL-separated as Bukkit's and Fabric's clients send
them; vanilla reads neither (Bukkit's PlayerRegisterChannelEvent). What a
client registered during configuration, which the connection thread
dropped, is kept (lan::Configured on Joined) and heard once right after
native_player_logged_in; a message in play is heard as it is handled, with
the player's state swapped in, and then goes on as before. The helper
native_player_channels(player) lists a guest's channels
(Player.getListeningPluginChannels). At most 128 names of at most 256
bytes are kept per client.

Part of #10461.
2026-11-06 08:28:35 +00:00
Detimes 33e0f27a8d Native hooks: a button's, lever's or plate's signal change (block_redstone) (unbuilt)
native_block_redstone(dimension, pos, old, new) is asked, as Bukkit's
BlockRedstoneEvent, when a button is pressed (hand or arrow) or let go by
its release tick, a lever is pulled, or a pressure plate's weight check
changes its signal. A mod may change new; one equal to old keeps the
component as it is, and a held button or plate is asked again at its
next scheduled check. The seam is LevelAccess::redstone_change in
horizon-world (default: leaves the signal), which GameLevel forwards on
an authoritative level. Wire, torches, diodes, observers and the other
sources are not heard yet.

CoreSMPEssentials part B holds its power rods' component on without the
2-tick re-set that leaks a 1-tick off pulse. Test (not run):
a_level_can_keep_a_lever_as_it_is.

Not built yet.
2026-11-06 08:28:19 +00:00
Detimes ce01d5ffeb Native mods: native_set_compass_target, kept over respawns and level changes (unbuilt)
Bukkit's Player.setCompassTarget: Hub.compass_targets by UUID while the player is on; default_spawn sends the target in place of the world spawn wherever it is sent (join, respawn, level change). Without a target the packet is unchanged. Helper-only fragment compass_target.
2026-11-06 08:28:07 +00:00
Detimes 1ffc88650d Native mods: native_boss_bar, boss bars for one player (unbuilt)
Bukkit's BossBar.addPlayer/removePlayer: a NativeBossBar (mod_api) by player UUID in BossState, offered to that viewer by boss_events_for, so publish_boss_bars sends Add/Update/Remove as for the world's bars. Unmodded servers keep an empty map. Helper-only fragment boss_bar.
2026-11-06 08:28:07 +00:00
Detimes d66f3e02ea Native mods: native_title, a title and subtitle for one player (unbuilt)
Adventure's showTitle and /title: SetTitlesAnimation, SetSubtitleText, SetTitleText through native_send. Helper-only fragment title.
2026-11-06 08:28:06 +00:00
Detimes ebb19e976e Native mods: native_send_particles_to, particles for one player alone (unbuilt)
Vanilla's ServerLevel.sendParticles(player, ...) and Bukkit's Player.spawnParticle: within 32 blocks, 512 with force (overrideLimiter). Replaces the hand-built LevelParticles (dust id hard-coded) of plugin ports; same options and checks as GameLevel::native_send_particles. Helper-only fragment send_particles_to.
2026-11-06 08:28:06 +00:00
Detimes 6ad344bec9 Native mods: native_play_sound_to, a sound for one player alone (unbuilt)
Bukkit's Player.playSound for plugin ports, which sent a raw ClientboundSoundPacket through native_send. Queued in game/native_player_fx.rs and sent at the tick's end with the hub back, so it works from any server hook; helper-only fragment play_sound_to.
2026-11-06 08:27:51 +00:00
Detimes fe2ef8529c Native mods: native_mob_attack, a mob's own attack on a mob or player (unbuilt)
Sentinel's native attacks (its default) call Mob.doHurtTarget for the
body. Horizon's do_hurt_target needs the goal's world, and its player is
whichever guest the mob sees, so the port injected a goal and wrote
LanHost.mob_players to bind the body to its target. native_mob_attack
runs do_hurt_target from any server hook with the target guest's view as
the mob's player (a new lan_guest_view, the single-guest form of
lan_guest_views), then hands the hits on that player to the guest and the
rest to handle_mob_events, as a mob's tick does. Helper only: unmodded
servers never call it. Part of #10461.
2026-11-06 08:27:49 +00:00
Detimes 21da267e5b Native mods: native_remove_entity takes projectiles and fishing bobbers (remove_projectiles) (unbuilt)
native_remove_entity(dimension, id) also removes projectiles (arrows,
tridents, snowballs, eggs, pearls, fireballs, wind charges, llama spit,
shulker bullets, firework rockets) as /kill does (kill_target), and a
server player's fishing bobber, as Entity.discard: the bobber of the
player in place, or outside a guest's turn any player's of the level
(remove_server_hook in fishing.rs). Displays, markers and interactions go
as before. Sentinel's arrow cleanup and CoreSMPEssentials part B's power
rods need it.

Not built yet.
2026-11-06 08:26:32 +00:00
Detimes 182345dc5e Native mods: mob navigation and look helpers for NPC ports (unbuilt)
Citizens' Navigator and Sentinel walk their bodies to points and
entities, stop them, ask whether they still walk and aim their heads.
Horizon offered this only to a goal (Mob::nav_move_to with the goal's
world), so ports injected a goal that read intents from a global map, or
moved bodies in straight /tp steps through walls. native_mob_navigate,
native_mob_follow, native_mob_stop, native_mob_navigating and
native_mob_look_at call the mob's own navigation and look control
(PathNavigation.moveTo, stop, isInProgress, LookControl.setLookAt) from
any server hook, for a mob of any loaded level, with the world its goals
see. Helpers only: unmodded servers never call them. Part of #10461.
2026-11-06 08:26:27 +00:00
Detimes a59c7468d1 Native mods: maps a server mod draws, kept by the world (native_new_map, native_paint_map) (unbuilt) 2026-11-06 08:26:00 +00:00
Detimes 07acadab21 Native mods: native_set_entity_rotation for mobs and mannequins (unbuilt)
native_move_entity turns only the entities Horizon keeps without
simulating them, and native_teleport_mob takes no rotation, so NPC ports
turned their bodies with execute .. rotate or tp .. facing every few
ticks. The helper is /rotate (Entity.forceSetRotation) for an entity of
any loaded level, with an optional head yaw (setYHeadRot) for a mob.
Helper only: unmodded servers never call it. Part of #10461.
2026-11-06 08:25:28 +00:00
Detimes 4855ac0767 Native hooks: the entity data one viewer is sent, changeable for them alone (entity_data_to) (unbuilt)
native_entity_data_to(viewer, entity, values) is a typed shortcut of
native_server_packet for ClientboundSetEntityDataPacket: the entries one
guest is about to get for one entity, changeable for that viewer alone, so
a mod can show one player glowing to one viewer without touching the
server's own flags (TAB's and Essentials' per-viewer glow, which resend
their own packets on a timer today). Heard just before native_server_packet
for the same packet. Switched on by native_watch_entity_data, which decodes
that packet only. EntityDataValue is horizon_protocol's entity_data::Value.

Part of #10461.
2026-11-06 08:25:27 +00:00
Detimes aad1954499 Native hooks: a server player's bobber on a block (fishing_hook_landed) (unbuilt)
native_fishing_hook_landed(player, hook, dimension, block, face) hears a
server player's flying fishing bobber meeting a block on its way, and
each tick it lies on one (on the ground as the tick began or ends: the
block under its centre, its top), as Bukkit's ProjectileHitEvent for a
FishHook. It is heard at the end of FishingHook.tick with the bobber back
in place, so a mod can remove it there. hook_block_hit keeps its result;
a sibling hook_block_hit_at also gives the block and face.

CoreSMPEssentials part B starts its power rods from the plate, button or
lever the bobber lands on; native_projectile_impact leaves bobbers out.

Not built yet.
2026-11-06 08:25:18 +00:00
Detimes e55c423833 Native mods: what each open server level holds (native_level_stats, native_level_entities, native_level_block_entities) (unbuilt)
NativeLevelStats counts a level's loaded and forced chunks, block entities and ticking ones (BlockEntities::ticking_len), and its entities (players included) and block entities by type. The entity list reuses the selector walk (level_candidates, now pub(in crate::game)) with a parked level swapped in as /execute in does; block entity types come from the block (lan::chunks::block_entity_type), which the enum's variants cannot tell apart, so this replaces the planned BlockEntity::type_id(). Fragment level_stats.
2026-11-06 08:25:07 +00:00
Detimes db26ac1679 Native mods: native_entity_by_uuid and native_merge_entity_nbt for any loaded level (unbuilt)
A stage plugin keeps its actors by UUID and moves, changes or removes them
in whatever level they are in; Horizon's entity handles reach the level in
place only, so ports ran tp/data merge entity by UUID on the console.
native_entity_by_uuid finds the entity (players included) in any loaded
level and returns its dimension, id and type; native_merge_entity_nbt is
native_entity_merge_nbt for a named loaded level. Helpers only: unmodded
servers never call them. Part of #10461.
2026-11-06 08:24:56 +00:00
Detimes 9bf6fb8323 Native mods: an item stack from SNBT (native_item_from_snbt, ItemStack::from_nbt, mod_api::parse_snbt) (unbuilt) 2026-11-06 08:24:47 +00:00
Detimes 4b1319096e Native hooks: a mod's terrain in the far terrain (lod_column) (unbuilt)
native_lod_column(dimension, column, seed, flat, column_out) is asked
for each column GeneratorWorker::lod_grid, surface_sample and
surface_column sample. A mod whose native_chunk_generate builds that
chunk answers the column's blocks (and its biome) with
column_out.replace, and the far terrain shows them, with the top, the
water over it and the spans derived as vanilla's sample derives them,
instead of vanilla's noise until the chunk generates.

Vanilla's far sample is computed first and only replaced when a mod
answers: without a mod, one empty call per column and the same columns
(#105).
2026-11-06 08:24:15 +00:00
Detimes 6c3dd1247f Native hooks: a player's fishing rod with its hand (player_fish_v2) (unbuilt)
native_player_fish_v2(player, name, state, hand, cancel) is
native_player_fish with the rod's hand: the hand the cast or reel-in uses
(Bukkit's PlayerFishEvent.getHand), and for a bite the hand holding the
rod, main hand first as FishingHook.shouldStopFishing looks. Asked right
after the unchanged v1 hook with the same cancel flag. CoreSMPEssentials
part B sees off-hand stasis and power rod casts.

Not built yet.
2026-11-06 08:24:02 +00:00
Detimes f4ccc08720 Native hooks: every play packet a player is sent, changeable and cancellable (server_packet) (unbuilt)
native_server_packet(player, packet, cancel) hears each play packet the
server is about to send one guest, decoded, a broadcast once for each
player, where PlayerSender::send hands it over (ProtocolLib's and
PacketEvents' outgoing listeners; NeoForge and Bukkit have no event).
A changed packet is encoded again, a canceled one is not sent. Nothing is
decoded or copied until a mod calls the helper native_watch_server_packets:
until then each packet costs one relaxed atomic load. The hook is a free
function the game sets once (lan::conn::SERVER_PACKET), as player_move is.

Part of #10461.
2026-11-06 08:23:51 +00:00
Detimes 91a7c31b12 Native mods: native_spawn_mob and native_summon reach any loaded level (unbuilt)
Both helpers were refused for any level but the one in place, so a mod
spawning from native_server_tick (where no level is defined) or into
the Nether from the Overworld's tick could not, and Stage-style ports ran
`execute in <dimension> run summon` instead. The named level is now put
in place for the call and put back afterwards, through the same
enter_command_level that `execute in` uses (npc_in_level in the new
native_npcs.rs). A level not loaded is still refused. Unmodded servers
never call these helpers, so nothing changes for them. Part of #10461.
2026-11-06 08:23:46 +00:00
Detimes 14e39641c2 Native hooks: a right click on a block with the hand (right_click_block_v2) (unbuilt)
native_right_click_block_v2(pos, face, hand, canceled) is
native_right_click_block with the hand that clicks (NeoForge's
PlayerInteractEvent.RightClickBlock.getHand), asked right after the
unchanged v1 hook with the same flag, once per hand tried. A cancel does
what a v1 cancel does. CoreWG's guard refuses an off-hand flint and steel
or bone meal; today it sees the main hand only.

Not built yet.
2026-11-06 08:23:26 +00:00
Horizon agentandClaude Opus 5.5 0a1a3c6ffe Merge #10502 into #10501 (both edit pack_post.rs)
Keeps both test modules (mixed_size_tests, undrawn_terrain_tests) and
has_terrain with #10502's doc and undrawn flag after sizes_differ.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 09:23:24 +01:00
Detimes ff07b57b27 Native mods: the server's tick times and ticks a second (native_tick_times, native_tps) (unbuilt)
native_tick_times() returns the last 100 tick durations at vanilla's getTickTimesNanos indices (tick n at n % 100); native_tps() the ticks a second over one, five and fifteen minutes (Bukkit's getTPS), measured between recorded tick ends in FixedStep::record. Helpers only, fragment tick_times.
2026-11-06 08:22:55 +00:00
Detimes 9553a05bb8 Native hooks: a right click on an entity with the hand (interact_entity_v2) (unbuilt)
native_interact_entity_v2(entity, kind, hand, stack, claimed) is
native_interact_entity with the hand that clicks (NeoForge's
PlayerInteractEvent.EntityInteract.getHand), asked right after the
unchanged v1 hook with the same claimed flag: a click v1 claimed is not
asked again. Once per hand vanilla tries, so a mod claiming the main hand
hears one event per click (Citizens' click events and commandtrait
hands, CoreWG, CoreSMPEssentials part B).

Not built yet.
2026-11-06 08:22:47 +00:00
Detimes 6847ec0b7b Native hooks: a chunk's terrain from a mod (chunk_generate) (unbuilt)
native_chunk_generate(dimension, chunk, seed, flat, chunk_out,
generate) is asked on the generation workers for every chunk, after its
biomes and structure starts and before the noise (Bukkit's
ChunkGenerator.generateNoise). A mod sets the chunk's blocks through
NativeProtoChunk::set_block and answers NativeGenerate::Replaced with
the shouldGenerate* flags: vanilla then runs only the surface rule, the
carvers (on a superflat level too), the structures and the features it
asks for. set_biomes and clear_structures work with either answer.

Left at Vanilla, nothing changes: the hook is one empty call per chunk
and every vanilla step runs as before. Replaces the cancellable HEAD on
VanillaTerrain::proto that generated the vanilla chunk and overwrote it,
and the coregen_glue module (#104).
2026-11-06 08:22:18 +00:00
Detimes a58cb67f76 Native world seed read helper (native_world_seed) (unbuilt)
The world's seed as /seed prints it (Bukkit's World.getSeed), for a
generator mod's preview that must draw the terrain the chunks get.
None off a server, where only a hash of the seed is known. No
behaviour change. Part of the world generation bundle (#98).
2026-11-06 08:19:15 +00:00
Detimes 304276d8dc Region chunks: inflate zlib straight into the chunk's buffer
A saved chunk's zlib stream was inflated by a new Decompress, 8 KiB at a
time into a stack block, each block then copied onto the output, which
grew by doubling from empty. Now each thread keeps one inflater, reset
for every chunk, and a whole stream that fits is inflated straight into
the output, reserved at about eight times the stream (a chunk's usual
ratio) and never past one byte over the limit. Any stream that fails
(damaged, cut short, over the limit) is decoded again the old way, so the
error and what the output keeps are exactly as before. A new test
compares both on whole, cut and damaged streams around every limit.
2026-11-06 08:03:25 +00:00
Detimes 3dd74dcc64 Native read-only chunk snapshots (native_chunk_snapshot)
A NativeChunkSnapshot of a loaded chunk of an open level (block states,
WORLD_SURFACE heights, noise biomes, the fuzzy biome zoom's cell) that
shares the chunk's sections copy-on-write and can be read on another
thread, with native_block_map_color, native_biome_name and
native_biome_tint to draw it. Replaces the engine internals a web map
reads today. No behaviour change. Part of #10461.
2026-11-06 07:57:06 +00:00
Detimes 3a20f23ee6 Native mods: read and set a player's attributes (player_attributes)
native_attribute(uuid, attribute) gives an attribute's base value and its
value with every modifier (Bukkit's AttributeInstance.getBaseValue and
getValue); native_set_attribute_base sets the base as
/attribute .. base set does; native_set_attributes loads a saved
attributes list onto the player as vanilla's AttributeMap.load reads a
player's file. A lower max_health caps the health, and the client gets
UpdateAttributes from the tick's sync. Nothing calls them without a mod.

Unproven: not yet compiled or probed live (build queue); the integration branch tests it.
2026-11-06 07:31:33 +00:00
Detimes 865e30ecb6 Native mods: read, give, take and replace a player's effects (player_effects)
native_effects(uuid) reads a player's effects as NativeEffect { id,
amplifier, duration, ambient, particles }; native_add_effect gives one as
vanilla's LivingEntity.addEffect (Bukkit's addPotionEffect), so a weaker
or shorter one waits under it; native_remove_effect takes one off as
/effect clear does; native_set_effects replaces them all, what a mod
restoring a saved player needs instead of writing PlayerEffects into the
hub's guests. The client gets UpdateMobEffect with the ambient and
particle flags from the tick's sync. Horizon keeps no hidden-icon flag on
an effect, so none is offered. Nothing calls them without a mod.

Unproven: not yet compiled or probed live (build queue); the integration branch tests it.
2026-11-06 07:31:33 +00:00
Detimes 3e6a1990ce Native mods: read a player's food, set health, food and experience (player_status)
native_player_food(uuid) reads a player's food level, saturation and
exhaustion; native_set_player_status(uuid, health, food, xp) sets each
that is given, as Bukkit's setHealth, setFoodLevel, setSaturation,
setExhaustion, setLevel, setExp and setTotalExperience. The write goes
through the player's turn as /kill and /effect reach a player
(for_player), so their client gets SetHealth and SetExperience with the
tick's sync. Health is capped at the maximum; 0 or less kills as /kill
does, with "<player> died" (Bukkit's setHealth(0), LivingEntity.die with
the generic source). The mod's food is not heard as the player's own
change (native_food_level_changed) outside their turn. Nothing calls it
without a mod.

Unproven: not yet compiled or probed live (build queue); the integration branch tests it.
2026-11-06 07:31:32 +00:00
Detimes 56b7dcbe62 Native mods: a player's compound as the server saves it (player_compound)
The helper native_player_compound(uuid) gives a player's compound as the
server would save it now (players/data/<uuid>.dat, playerdata::player_tag),
vanilla's ServerPlayer.saveWithoutId: what a mod keeping players in step
across servers (a HuskSync port) stores, instead of calling player_tag on
the hub's guests itself. None inside a player's turn, where their live
state is out of their record, and for a player not on. The new module
native_player_state.rs holds this group's helpers. Nothing calls it
without a mod.

Unproven: not yet compiled or probed live (build queue); the integration branch tests it.
2026-11-06 07:31:32 +00:00
Detimes 29a0f2dc72 Native mods: players the server knows, by name or id (profile_cache, #10461)
native_profile_by_name(name) gives the id and name, and
native_profile_by_uuid(uuid) the name, of a player who is on, else one
in a dedicated server's profile cache (usercache.json): vanilla's
GameProfileCache, Bukkit's getOfflinePlayer without its network
lookup. They ask no profile service and make up no offline player;
UserCache::find_by_name reads without changing the cache. native_player_head
names a player who is off from the cache too. Ports parse
usercache.json or ask Mojang themselves today.
2026-11-06 07:25:24 +00:00
Detimes bb4f25c694 Native mods: any player's held stack and equipment from any server hook (player_equipment)
Unproven: not compiled or run yet (build queue stopped for the integration build); see proof/hooks-pread/PROOF_PLAN.md.
2026-11-06 07:25:15 +00:00
Detimes 2a86196e83 Native mods: the server's player limit, folder and log file (server_files)
Unproven: not compiled or run yet (build queue stopped for the integration build); see proof/hooks-pread/PROOF_PLAN.md.
2026-11-06 07:25:15 +00:00
Detimes ea6382f2a1 Native mods: a player's statistics and advancements, read and replaced (player_records)
Unproven: not compiled or run yet (build queue stopped for the integration build); see proof/hooks-pread/PROOF_PLAN.md.
2026-11-06 07:25:15 +00:00
Detimes 2f4294aba1 Native mods: what a player's client told the server (player_client)
Unproven: not compiled or run yet (build queue stopped for the integration build); see proof/hooks-pread/PROOF_PLAN.md.
2026-11-06 07:25:15 +00:00
Detimes eacaa4db69 Native mods: more of a player on NativeServerPlayer (server_player_state)
Unproven: not compiled or run yet (build queue stopped for the integration build); see proof/hooks-pread/PROOF_PLAN.md.
2026-11-06 07:25:14 +00:00
Detimes ca41bb4780 Packets: one zlib compressor per thread, reset for each packet
frame::contents made a new ZlibEncoder for every packet above the
compression threshold, which allocates and clears zlib's window and hash
tables (some 300 KiB) each time. A thread-local Compress, reset before each
packet and driven with the encoder's own calls (the input unflushed, then
finish), writes the same bytes. A new test checks them against a fresh
encoder for bodies from 256 bytes to 300 KiB, in both orders.
2026-11-06 07:19:47 +00:00
Detimes f1586af649 Native hooks: set one checked inventory slot, take exact stacks (helpers)
native_set_inventory_slot(uuid, kind, slot, expected, new) puts a stack
in any slot of a player's inventory or ender chest, armour and off hand
included, if it still holds exactly `expected`, and returns what was
there (Bukkit's setItem). native_take_stacks(uuid, slots) takes exact
stacks out of the given slots, all or none. Both are checked like
native_move_stack and work live or on the player's file. Wanted by the
InvSee, AdminKit and Wanted ports (armour slots, splitting, stakes);
Wanted edits LanHost guests directly for this today.

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes a7c6eff569 Native hooks: replace a player's whole inventory (set_inventory helper)
native_set_inventory(uuid, kind, slots, selected) replaces a player's 41
inventory slots or 27 ender chest slots (Bukkit's
PlayerInventory.setContents), live or in their file like
native_inventory. For the inventory it sets the held hotbar slot, empties
the cursor and the 2x2 crafting grid, and sends a player on the server the
whole inventory at once. The HuskSync port writes Guest fields directly
for this today.

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes 8e61d452e0 Native hooks: a guest's block placement before it happens (block_place)
native_block_place(player, name, stack, pos, state, cancel) is heard
where BlockItem.place has found the position and the state and checked
survival, before anything is set (Bukkit's BlockPlaceEvent, NeoForge's
BlockEvent.EntityPlaceEvent). A canceled placement is a failed one: no
block, no use, no swing, and the client gets its blocks and inventory
back. The position and state come from the existing dry run
(Behaviours::placement_state). Ports cancel placements today by claiming
native_use_item_on, which also stops doors, chests and the item's other
uses.

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes 531a026a14 Native hooks: a guest's drag over slots, cancellable (inventory_drag)
native_inventory_drag(player, name, window, kind, slots, cancel) hears a
guest's quick craft in their own inventory or a vanilla menu once, at its
end, with the kind and the window slots the client dragged over (Bukkit's
InventoryDragEvent). A canceled drag's end is handled like a canceled
click: the drag ends empty and the client's prediction is answered with
the server's slots and cursor.

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes 96d477b41b Native hooks: a guest's inventory and menu clicks, cancellable (inventory_click)
native_inventory_click(player, name, window, slot, button, mode, cancel)
hears a guest's click in their own inventory or a vanilla menu they have
open before any handler applies it (Bukkit's InventoryClickEvent). A
canceled click becomes a drag start of no type, which every menu takes as
resetQuickCraft and nothing more, so nothing moves and the client's
predicted slots and cursor are answered with the server's, as for any
click the server refused. Drags and a mod's own menus are not heard.
Wanted by the HuskSync and Essentials ports (data lock, freeze).

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes 2e5d156644 Native hooks: a guest's throw, cancellable (player_launch_projectile_v2)
native_player_launch_projectile_v2(player, name, stack, cancel) is heard
where ProjectileItem.use creates a snowball, egg, ender pearl or a mod's
thrown item, before the projectile exists (Bukkit's ProjectileLaunchEvent
for a player). A canceled throw is a failed use: nothing is thrown, used
or put on cooldown, v1 hears nothing, and the guest's client, which took
the item at once, gets its inventory back. Wanted by the HuskSync port's
data lock (no ender pearls while locked).

Unproven: not yet built or run on a server (handed to the hooks-all integration test).

Part of #10461.
2026-11-06 07:12:26 +00:00
Detimes 157208790d Native mods: an online player's textures and a head stack of a player (player_textures, #10461)
native_player_textures(uuid) gives the textures property (value and
signature) of a player who is on, as their player list entry carries
it (Paper's Player.getPlayerProfile). native_player_head(uuid) gives a
minecraft:player_head whose profile holds the player's id, and their
name and skin when they are on (Bukkit's SkullMeta.setOwningPlayer).
Ports look the current skin up at the session server today, or build
heads with a name-only profile.
2026-11-06 07:03:55 +00:00
Detimes 16750b8c90 Native hooks: a player logging in, held for the mods' answers (player_pre_login, #10461)
native_player_pre_login(player, name, address, defer) runs on the
connection's own thread once the session service or the proxy has named
the player and before the server's own checks (Bukkit's
AsyncPlayerPreLoginEvent). A mod that needs time, a permissions or
inventory load, or may refuse, pushes a Receiver<Result<(), Text>> onto
defer and answers from any thread: Ok lets the player on, Err refuses
them with the reason. The login waits for every answer, 20 s at most in
all; a missing or dropped answer refuses with a plain Disconnected.
Today such loads start in native_player_login, where a database failure
cannot refuse the player.
2026-11-06 07:02:40 +00:00
Detimes 6564832337 Native mods: set a profile property and show the player again (profile_refresh, #10461)
native_set_profile_property(player, name, value) sets or removes a
property of an online player's profile (Paper's Player.setPlayerProfile);
native_refresh_player(player) shows them again at the end of their
level's pass: everyone who sees them gets their list entry anew and
their entity again, and the player is respawned in place into the same
level with all data kept (Paper's hidePlayer/showPlayer plus the
respawn), so their own client draws a new skin too. Both work from any
server hook, native_server_tick included. They replace the raw HEAD
injection on Game::flush_native_net that edits Guest.properties and
sends the player info itself.
2026-11-06 07:01:16 +00:00
Detimes 413c2b1593 Native mods: command arguments that ask the server for completions, and the request split into label and arguments (tab_complete_v2) (unproven) 2026-11-06 07:00:51 +00:00
Detimes 78f3b6fca9 Native mods: look up a server command by name, aliases resolved (command_info) (unproven) 2026-11-06 07:00:50 +00:00
Detimes 7495f4c56b Native mods: run a console command that reaches the mods' commands (run_mod_command) (unproven) 2026-11-06 07:00:50 +00:00
Detimes 90a091b024 Native mods: run a command as a player, at their level, with its answer (run_command_as) (unproven) 2026-11-06 07:00:50 +00:00
Detimes 41413878d0 Native mods: run a console command and keep its result and answer (run_command_captured) (unproven) 2026-11-06 07:00:50 +00:00
Detimes 9b670c7cfc Native hooks: a joining player's profile on the connection thread (player_profile, #10461)
native_player_profile(player, name, properties) runs on the
connection's own thread once the server has let a player in and before
it sends LoginFinished. A mod may change, add or remove the profile's
properties (a skin's textures): the player's own client and every
player list get the new ones (Velocity's GameProfileRequestEvent,
Paper's PlayerProfile at AsyncPlayerPreLoginEvent). It replaces the raw
HEAD injections on lan::conn::login_and_configure and
Game::lan_guest_joined that skin and Floodgate ports use today.
2026-11-06 07:00:34 +00:00
Detimes 0da0001f38 Native mods: a player's permission level, also at login (player_permission, #10461)
The helper native_player_permission(player) gives the permission level of a
player on the server or joining it (0 to 4, ops.json's for an operator,
PlayerList.getProfilePermissions). It works inside native_player_login for the
player that hook hears, whose level the hub set on admitting them, so a login
gate that lets operators through needs no ops.json of its own. The hook's
contract is unchanged.
2026-11-06 06:51:55 +00:00
Detimes a4a64c560d Native mods: send a plugin channel message to a player (send_custom_payload_to_player, #10461)
The helper native_send_custom_payload_to_player(player, channel, data) queues
a ClientboundCustomPayloadPacket on any namespace:path channel outside
horizon: (at most ClientboundCustomPayloadPacket's 1 MiB) for one player's
client, vanilla or Horizon, sent at the tick's end with the mod messages, so
it works from any server hook. Bukkit's Player.sendPluginMessage.
2026-11-06 06:51:55 +00:00
Detimes 27200f64cd Native hooks: a player's plugin channel message (custom_payload_from_client, #10461)
native_custom_payload_from_client(player, channel, data) hears a guest's
ServerboundCustomPayloadPacket in play on any channel that isn't Horizon's own
(horizon:mod_net, horizon:quiet_commands, horizon:lod), which the server drops
today. Like mod messages it waits for the tick's end, with the hub back, so
every helper works; it is heard from a dead player too, as vanilla's
handleCustomPayload doesn't look at health. Horizon's own channels are handled
as before. The client side is #6774.
2026-11-06 06:51:55 +00:00
Detimes 01fec61d2d Handshake: a host of up to 32767 characters behind a proxy (#10461)
With proxy forwarding on (forwarding.secret, offline mode, not the integrated
server's own player) the handshake's host is read with Short.MAX_VALUE as its
limit, as Paper does behind a proxy, so a proxy's data after the host (a
Floodgate player's, often longer than 255 characters) no longer drops the
login. Without forwarding the limit stays vanilla's 255; it is the only string
read with that limit.
2026-11-06 06:51:55 +00:00
Detimes ac6ff90c34 Native hooks: a client's handshake on the connection thread (handshake, #10461)
native_handshake(address, host, intent, cancel, reason) is a free function in
horizon_client_lib::lan::conn, called on the connection's own thread for each
network connection's handshake, before the server acts on the intent. A mod may
rewrite the host (a proxy's data after it, Floodgate's, included) or refuse the
connection: a login or transfer is disconnected with the reason, a status ping
is closed unanswered. Wired through NativeClientHooks like player_chat. Nothing
changes without a mod.
2026-11-06 06:51:55 +00:00
Detimes c87a60d013 Native hooks: creative slots with the whole stack (creative_slot_v2)
native_creative_slot_v2(player, name, slot, stack, cancel) is an
additive version of native_creative_slot with the whole ItemStack put in
the slot (none for an emptied one), components included. It is heard
just before v1, which keeps its signature and firing point and hears
nothing of a packet canceled here.

Part of #10461.
2026-11-06 06:30:14 +00:00
Detimes 960634a9ad Native hooks: throws with the whole stack (item_dropped_v2)
native_item_dropped_v2(player, stack) is an additive version of
native_item_dropped with the whole ItemStack thrown, components included
(enchantments, unbreakable, attribute modifiers...), heard just before
v1, which keeps its signature and firing point.

Part of #10461.
2026-11-06 06:30:13 +00:00
Detimes a425fe4152 Native hooks: pickups with the whole stack, thrower and position (item_picked_up_v2)
native_item_picked_up_v2(player, stack, thrower, position, canceled) is
an additive version of native_item_picked_up: the whole ItemStack with
its components, the item entity's thrower UUID (none for block and mob
drops) and its position. It is heard just before v1, which keeps its
signature and firing point and hears nothing of a pickup canceled here.

Part of #10461.
2026-11-06 06:30:11 +00:00
Detimes 192898e730 Native hooks: where a joining player is placed (player_spawn_location)
native_player_spawn_location(player, name, dimension, position,
rotation) is heard once per join after the saved data is loaded (or a
newcomer's spawn found) and before the level is chosen and the world is
sent (Spigot's PlayerSpawnLocationEvent). A mod that changes them places
the player there, in that level, with no teleport afterwards. An unknown
dimension or a non-finite value leaves that part unchanged.

Part of #10461.
2026-11-06 06:30:10 +00:00
Detimes 14af676a89 Native hooks: player-versus-player damage with both players (player_hurt_by_player)
native_player_hurt_by_player(attacker, attacker_entity, victim,
victim_entity, source, damage_type, amount, canceled) names both players
by UUID and network entity id, and how the hit came: melee, sweep, or the
projectile's entity type (arrow, spectral arrow, snowball, egg, a mod's
thrown type). It is heard where native_living_incoming_damage is, at the
victim's hurtServer past its guards (pvp rule and teams allow it, victim
not invulnerable or dead), and just before it: changing the amount
reaches v1, canceling skips the hurt and v1 alike. The PvP call sites
name the hit in a thread-local scope that hurt_server reads.

A snowball or egg does zero damage, where vanilla's Player.hurtServer
stops: it is heard there with amount 0 and nothing the mods set applies.
Thrown tridents hurt no players in Horizon yet and are not heard.

Part of #10461.
2026-11-06 06:30:09 +00:00
Detimes 3515e99feb Native hooks: a player logging out while still present (player_logging_out)
native_player_logging_out(player, name) is heard when a placed player's
connection is gone (lost, kicked, quit) but before their menu closes and
they are saved and removed (Bukkit's PlayerQuitEvent): they are still in
their level and listed in native_server_players. No state is swapped in;
the hub is back in self.lan for the call, so the UUID helpers and
native_run_command reach them. A death a mod causes there (`kill <name>`)
is died at once (death message, drops, native_player_died), since the
leaving player gets no further tick to die in; they are then saved dead.
Not heard when the server stops, where native_player_logged_out already
tells with them still listed. Unmodded servers are unchanged: the death
step only runs when the hook took the player from alive to zero health.

Part of #10461.
2026-11-06 06:30:08 +00:00
Detimes da8c2cfc55 Native hooks: each line written to the log (log_line)
native_log_line(level, thread, message, line) hears each message
written to logs/latest.log, on the log's writer thread after the file
has it, so the thread that logged never waits on a mod and a Discord
console bridge needs no tailing of latest.log. line is the text as the
file got it. The logger calls it through logging::set_line_hook,
installed before the log opens; trait mods are reached through
NativeClientHooks. A callback that panics is not called again, so its
own panic line can't loop.

Refs #10461
2026-11-06 05:42:47 +00:00
Detimes 2f2eecf40d Native hooks: every console and RCON line before it runs (console_line)
native_console_line(source, line, cancel, replies) hears each line the
dedicated server's console is about to run, before vanilla's commands,
the dedicated-only ones and mods' registered ones: source is console
(typed on the console, or sent by horizon-server as the console) or
rcon. Set cancel and it does not run; replies are logged as the
console's feedback, so they also reach the RCON or horizon-server
caller. Until now native_perform_console_command only heard commands
a mod registered, so an audit mod could not see ordinary console
commands (Bukkit's ServerCommandEvent / RemoteServerCommandEvent).

Refs #10461
2026-11-06 05:42:46 +00:00
Detimes 6e4fbfa79f Native per-player view distance (native_set_player_view_distance)
A mod limits one player's view distance below the server's: only that
player is told its radius (SetChunkCacheRadius) and is sent and tracks
chunks within it; native_player_view_distance reads it. The guest's
distance is ChunkMap.getPlayerViewDistance under the limit; with no limit
(every unmodded server) it is exactly as before. Part of #10461.
2026-11-06 05:40:48 +00:00
Detimes 9c23922432 Native world border read helper (native_world_border)
A level's world border bounds as the server enforces it, for a web map's
border line and a pre-generator's border selection. Vanilla 26.3 keeps a
border per level (ServerLevel.getWorldBorder, each level's
data/minecraft/world_border.dat); Horizon keeps one for every level, so
the helper answers the same bounds for each level until that is fixed.
No behaviour change. Part of #10461.
2026-11-06 05:40:07 +00:00
Detimes 2d2936b9df Native chunk tickets in a level nobody is in (native_force_chunk_in)
native_force_chunk only reaches the level in place, so a mod could not hold
a chunk of the Nether or the End from a server-wide hook, and a level not
open yet never ticked: a pre-generator for those levels never advanced.
native_force_chunk_in takes the dimension; a level held this way opens at
the next server tick and ticks while held. Unmodded servers hold no mod
tickets, so nothing changes for them. Part of #10461.
2026-11-06 05:39:58 +00:00
Arslan ArmanulyandClaude Opus 5.5 b1574b9721 Shader packs: a program writing buffers sized relative and in pixels draws while they match
A composite program writing two colortex buffers was skipped whenever their
`size.buffer` lines differed, even when one relative size and one pixel size
came to the same size (`0.25 0.25` and `320 180` at 1280x720). Iris 1.11.7
draws it (a probe pack of ours: texel (100, 50) of both reads 100.5, 50.5 and
texcoord 0.314, 0.281).

Sizes of one kind that differ still skip the program as the pack loads. A mix
of the two kinds is kept, and the pass is skipped only at render sizes where
the buffers it writes resolve to different sizes.

Fixes #10454

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-06 10:26:01 +05:00
repositorykreml1nandCodex f4b9bc1471 Merge upstream main to resolve PR #10015 conflicts
Keep the command-block /say LAN regression and upstream legacy PvP checks,
with both registrations intact. Preserve upstream function bodies and the
original say regression unchanged.

Validation: Python syntax/AST and registrations checked; public-tree check
passed; Python unittest suite passed (41 skipped). pr_ready could not start
Rust checks because Cargo is unavailable in this restored environment.

Co-Authored-By: Codex <[email protected]>
2026-11-06 07:37:01 +03:00
masa-masa-0314andZCode f221ae33ef Realms: the invite-code list and its edit screen
The Players tab's second button now opens RealmsInviteCodesScreen: the count in the
title, the subtitle, a row per code with its expiration (or Never), Copy / Edit / Delete
per row, and Create. RealmsEditInviteCodeScreen edits one code: its active flag, the
seven InviteCodeExpiration choices (a click on the slider track picks the nearest — this
screen has no drag event yet), Copy to the clipboard (a new Outcome::Copy the title
turns into Action::CopyText), Delete, Done and Back.

RealmsClient gains inviteCodes (GET api/v1.0/invite/codes?realmId={id}),
createInviteCode (POST .../invite/codes), updateInviteCode (POST .../update) and
deleteInviteCode (DELETE .../delete/{code}), all on the invite-code service with the
token in x-mojangauth, with InviteCode's DTO. The existing redeemInviteCode now goes
through the same helper. An existing expiration reads back as the closest of the seven
choices (InviteCodeExpiration.closestTo).

Co-Authored-By: ZCode <[email protected]>
2026-11-06 07:16:04 +08:00
Ian WorleyandClaude Opus 5.5 dfa637e389 fix(native-mods): a world is known once logged in, and a pack reload covers it
A connection reported its world while still connecting, before a failed
login could drop it, and a resource-pack reload's loading screen still
counted as playing.

Co-Authored-By: Claude Opus 5.5 <[邮箱]>
2026-11-05 16:14:45 -06:00
Ian WorleyandClaude Opus 5.5 897f17114a fix(native-mods): keep a friend's relay in its world identity
Round-two review finding: a long invite's identity kept only the host's
addresses when it had any. Two friends on other networks behind the
same private address and the fixed direct port (25580), each reached
through a relay, shared one NativeWorldId. The relay's address and
nameplate now stay beside the addresses; neither secret takes part.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:14:45 -06:00
Ian WorleyandClaude Opus 5.5 8f75db7d5c fix(native-mods): portal loading covers the world; friends' worlds stay apart
Review findings on the session state helpers:

- NativeClientState.screen read remote_loading(), which leaves out the
  portal loading screens because they draw no panorama blur, so a
  minimap drew over them. A connection's screen is now one predicate,
  remote_screen_up(), which remote_overlay draws by too.
- Every long friend invite showed as the same "Friend's world", so two
  friends' worlds shared one NativeWorldId and a mod's per-world data.
  A friend's world is now friend: and the invite's public part (the
  host's addresses, else its relay nameplate), never its secret.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:14:45 -06:00
Ian WorleyandClaude Opus 5.5 6975556e3d feat(native-mods): session state a client HUD reads
A minimap or waypoint mod can now read what it needs through stable
helpers instead of Game's fields, on a server's client and in single
player alike:

- native_client_state() gains screen (NativeScreenKind: playing, chat,
  death or another screen), debug_screen (F3), world (NativeWorldId:
  single player's save folder or the server's address, for per-world
  data) and sleeping_pos (the bed, LivingEntity.getSleepingPos).
- native_biome(pos): the biome at a block as Level.getBiome picks it,
  through BiomeManager's zoom.
- native_data_dir(mod_id) joins the helper catalog.

The window's connection remembers the save it joined through memory,
its own server thread's or P1's for a splitscreen seat, so the world
identity needs no mixin on open_integrated. A friend's world is named
as players see it, never by its invite secret.

Helper-only API fragment session_state.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 16:14:45 -06:00
MSedgeMC a266ac1c43 test: an empty-criteria advancement is done and is the one auto-granted
`Progress::done` on an advancement parsed from `{"criteria": {}}` (true, no
requirement groups to wait for) against one with a criterion (false), and
`Tree::automatic` over a tree holding both: only the empty one is listed, which
is what the load pass grants the rewards of.

Without the `done` change the first assertion fails at once
(`assertion failed: Progress::default().done(&empty)`), and `Tree::automatic`
does not exist to compile against.
2026-11-05 19:25:02 +00:00
MSedgeMC 349ec13a9b fix: an advancement with no criteria is done and pays out
`AdvancementProgress.isDone` loops over the requirement groups, so with no
groups at all it answers true: an advancement whose `criteria` map is empty is
finished from the moment the player's progress loads. `PlayerAdvancements.
checkForAutomaticTriggers` walks every advancement, and for those with no
criteria calls `award(holder, "")` - which finds no `""` criterion to grant, so
it neither toasts nor announces - and then `AdvancementRewards.grant`s it. The
rewards are granted again on the next load, because there is no criterion to
record them under.

Horizon required `!requirements.is_empty()`, so such an advancement was never
done and never paid out. `Progress::done` now keeps vanilla's shape (every group
satisfied, vacuously true with no groups), `Tree::automatic` lists the
advancements nothing can grant, and the progress load grants their rewards
through the new `grant_rewards`, which is the `AdvancementRewards.grant` half of
`award` split out so the toast and announcement stay with a real completion.

Scope, plainly: **no vanilla advancement has an empty criteria map** - all 1224
in the shipped data have at least one criterion - so nothing in the game's own
data changes. What changes is a datapack's: before this, an advancement it
declared with `"criteria": {}` could never be obtained and its rewards never
arrived, where vanilla hands it over on load.
2026-11-05 19:24:58 +00:00
Florian ERNSTandCodex 7c171a543c Preserve loaded furnace cooking totals until completion
Co-Authored-By: Codex <[email protected]>
2026-11-05 19:53:51 +01:00
Nikita Maximilian GrimmandGPT 6.1 Sol e1e307cbb5 fix(launcher): preserve native shader selection and options
Fixes #10123

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-05 19:48:31 +01:00
Nikita Maximilian GrimmandGPT 6.1 Sol 64297b8e8b fix(shaders): preserve native pack identity across updates
Part of #10123

Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-05 19:45:17 +01:00
MSedgeMC 3ef1d5a7aa port_map: StatsScreen.keyPressed is the statistics tab bar's keys 2026-11-05 18:04:22 +00:00
MSedgeMC fadcfcd6d0 test: the statistics tab bar takes Ctrl+Tab, Ctrl+Shift+Tab and Ctrl+number
`StatsUi::key` as `Game::stats_key` calls it: the cycle wraps both ways, a
number picks its tab and is clamped to the last one, a key without Control is
left alone, an empty Items or Mobs tab is skipped (and not switched to by its
number), and a switch scrolls the new list back to the top.
2026-11-05 18:04:22 +00:00
MSedgeMC c679dff99e feat: Ctrl+Tab switches the statistics screen's tabs
`StatsScreen.keyPressed` runs `MenuTabBar.keyPressed` first: with Control
held, `TabNavigationBar` cycles the tabs on Tab and Shift+Tab (skipping a tab
that cannot be chosen) and picks one on the number keys, where
`InputConstants.Key.getNumericKeyValue` reads the main row's digits and the
keypad's. Horizon's statistics screen had no keyboard handling at all, so only
the mouse could change tabs.

`StatsUi::key` is that tab-bar key press: it reports whether the bar took the
key, moves to the chosen tab when that tab has rows, and starts that list at
the top as a tab press does. `tab_key` ports `getNextTabIndex` (a digit is
`floorMod(digit - 1, 10)` clamped to the last tab; Tab walks to the next tab
that can be chosen, one full turn at most) and `numeric_key_value` ports
`getNumericKeyValue`. `Game::stats_key` gives it the tabs that have rows
(`stats_tab_active`) and plays the tab's click when the tab changes, and
`Game::key_pressed` hands the statistics screen its keys.
2026-11-05 18:04:22 +00:00
MSedgeMC d0a71973e8 The health HUD state's doc drops the removed heights
native_health_hud_state described left_height and right_height as the ones
that place the selected item's name and the action bar. They are gone, and
the text no longer reads them.
2026-11-05 18:04:12 +00:00
MSedgeMC 15a6831a33 The HUD probe fixture drops the bars' height with the argument
The two calls in hud_fixture/kinds.rs still passed the 39 the old signatures
took. The probe compares against the vanilla client, which reads neither, so
nothing else changes.

cargo clippy --examples -p horizon-client-lib -p horizon-client -- -D
warnings is clean.
2026-11-05 18:03:21 +00:00
Nikita Maximilian GrimmandGPT 6.1 Sol ffd9b8f475 chore: remove agent markers from shader code comments
Co-Authored-By: GPT 6.1 Sol <[email protected]>
2026-11-05 19:00:20 +01:00
Nikita Maximilian Grimm ceeb7841e2 Merge remote-tracking branch 'origin/main' into shader-api-02-serve 2026-11-05 18:59:42 +01:00
DUzzL 9cf77dcdd7 Run location effects of newly worn armor at once 2026-11-05 18:54:19 +01:00
DUzzL 7858858b94 Make thrown potions bounce off a breeze 2026-11-05 18:54:16 +01:00
Nikita Maximilian Grimm 276919beae Merge remote-tracking branch 'fork/shader-api-02' into shader-api-02-serve 2026-11-05 18:47:17 +01:00
yahya-nusseibehandClaude Opus 5.5 e0b19571dd RSA: constant-time private-key operations
The dedicated server and a LAN host decrypt each client's
ServerboundKey with the server's private exponent. That used
square-and-multiply, which multiplies only on the exponent's set bits,
with a data-dependent final subtraction in each Montgomery product, and
no blinding: a client controls the ciphertext and can time the reply,
the classic extra-reduction channel. The key is regenerated every start
(as in vanilla) and a recovered one still needs an on-path position,
but vanilla's JCE blinds this operation.

- Mont::mul subtracts n through a mask chosen from the overflow and
  the borrow of t − n, never a branch (Big::sub_with_borrow,
  Big::select). Every product takes the same steps.
- Mont::pow_secret: a square and a multiply for every bit of the
  exponent's limbs, the product kept by a mask. RsaPublicKey::
  apply_secret uses it; RsaPrivateKey::decrypt and ::sign call that.
  Public-key verification and encryption stay on the faster pow.
- Big::from_be_bytes no longer skips zero bytes: it parses the private
  exponent on every call, and the result is the same.
- Test: pow and pow_secret agree with a reference modular power, for
  moduli near a limb boundary (the overflow case) and exponents with
  leading zero limbs. The 11 existing RSA tests (OpenSSL signature,
  known-key decryption, generated keys) pass unchanged.

No wire or parity change. Multiplicative blinding would be the more
complete fix once a modular inverse exists.

Found in yahya-nusseibeh's review of Horizon (L1).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01SJ3stK8iXVDXqoi7midcqd
2026-11-05 19:40:43 +02:00
MSedgeMC 51a4b98992 port_map: the item name and the action bar rows name their layout fns
Hud.extractSelectedItemName is hud_overlays::ToolHighlight::draw and
Hud.extractOverlayMessage is hud_overlays::Titles::overlay_message; neither
row existed. map-check: 151 rows, no problems.
2026-11-05 16:54:42 +00:00
MSedgeMC 0e97601d25 test: the item name and the action bar sit at vanilla's heights
text_position_tests asks a ToolHighlight holding a stack and a Titles
holding an action bar for their draw calls at 320x240: the name's text top
is 240 - 59 (and 240 - 45 in a mode that cannot hurt the player), the action
bar translates to 240 - 68 with its text four pixels under that, and a
zeroed timer draws nothing. The font is built from no resources, as
chat_tests does, so the test needs no game assets.
2026-11-05 16:54:42 +00:00
MSedgeMC d48f37c2a4 fix: the selected item name and the action bar keep vanilla's height
Hud.extractSelectedItemName draws the name at guiHeight - 59, plus 14 in a
mode that cannot hurt the player, and Hud.extractOverlayMessage puts the
action bar at guiHeight - 68: both constants, which the health, food, air and
mount rows above the hotbar never move.

Horizon moved both up by max(leftHeight, rightHeight). That is 59 for a
survival player with armour, so the common case agreed by accident, but it is
69 with a second row of hearts and 81 for a player underwater on a mount, and
the two texts floated ten or twenty pixels above where vanilla draws them.

Both use the constants now, and HealthHud no longer keeps the heights: 26.3's
Hud has no such fields, only the locals of extractItemHotbar that the
contextual-info bar reads.
2026-11-05 16:54:42 +00:00
Riccardo GuerrieroandClaude Opus 5.5 a88fab01a5 A mob's Thorns hurts the player who hits it
EnchantmentHelper.doPostAttackEffectsWithItemSource runs the victim's
equipment before the attacker's weapon: the player's melee hit and sweep,
arrow, trident and wind charge now meet the mob's own post_attack
effects. Thorns rolls from the level's random, hurts the player from
the wearer by 1-5 and wears the piece by 2 (Unbreaking, breaking with
its entity event); the player's own Thorns doesn't answer a thorns hit,
and a thorns hit that lands whole plays enchant.thorns.hit.

The mace harness gets armored targets with Thorns and Unbreaking; new
lan_e2e check mob-thorns.

Part of #9954

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:49:52 +01:00
IsT3RiK 44b980def2 Merge remote-tracking branch 'upstream/main' into traduction-messages-mods
# Conflicts:
#	launcher/lang/af.json
#	launcher/lang/ar.json
#	launcher/lang/az.json
#	launcher/lang/ba.json
#	launcher/lang/be.json
#	launcher/lang/bg.json
#	launcher/lang/br.json
#	launcher/lang/bs.json
#	launcher/lang/ca.json
#	launcher/lang/cs.json
#	launcher/lang/cv.json
#	launcher/lang/cy.json
#	launcher/lang/da.json
#	launcher/lang/de.json
#	launcher/lang/el.json
#	launcher/lang/eo.json
#	launcher/lang/es.json
#	launcher/lang/et.json
#	launcher/lang/eu.json
#	launcher/lang/fa.json
#	launcher/lang/fi.json
#	launcher/lang/fil.json
#	launcher/lang/fo.json
#	launcher/lang/fr.json
#	launcher/lang/fur.json
#	launcher/lang/fy.json
#	launcher/lang/ga.json
#	launcher/lang/gd.json
#	launcher/lang/gl.json
#	launcher/lang/haw.json
#	launcher/lang/he.json
#	launcher/lang/hi.json
#	launcher/lang/hr.json
#	launcher/lang/hu.json
#	launcher/lang/hy.json
#	launcher/lang/id.json
#	launcher/lang/ig.json
#	launcher/lang/is.json
#	launcher/lang/it.json
#	launcher/lang/ja.json
#	launcher/lang/ka.json
#	launcher/lang/kk.json
#	launcher/lang/kn.json
#	launcher/lang/ko.json
#	launcher/lang/ky.json
#	launcher/lang/la.json
#	launcher/lang/lb.json
#	launcher/lang/li.json
#	launcher/lang/lmo.json
#	launcher/lang/lo.json
#	launcher/lang/lt.json
#	launcher/lang/lv.json
#	launcher/lang/mk.json
#	launcher/lang/mn.json
#	launcher/lang/ms.json
#	launcher/lang/mt.json
#	launcher/lang/nl.json
#	launcher/lang/no.json
#	launcher/lang/oc.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/pt.json
#	launcher/lang/ro.json
#	launcher/lang/ru.json
#	launcher/lang/sah.json
#	launcher/lang/se.json
#	launcher/lang/sk.json
#	launcher/lang/sl.json
#	launcher/lang/so.json
#	launcher/lang/sq.json
#	launcher/lang/sr.json
#	launcher/lang/sv.json
#	launcher/lang/szl.json
#	launcher/lang/ta.json
#	launcher/lang/th.json
#	launcher/lang/tr.json
#	launcher/lang/tt.json
#	launcher/lang/uk.json
#	launcher/lang/ur.json
#	launcher/lang/uz.json
#	launcher/lang/vec.json
#	launcher/lang/vi.json
#	launcher/lang/yi.json
#	launcher/lang/yo.json
#	launcher/lang/zh.json
#	launcher/lang/zu.json
2026-11-05 17:46:05 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 12daf9d13f Golden Hour 0.2: 0.1's look in the grade, Horizon's tone curve
The port ran 0.1's grade on linear values, which darkened midtones by about
nine 8-bit steps. The soft shoulder and the saturation are a look written for
display values, so the grade now runs them on the encoded picture, while the
golden tint, the glow and the vignette stay on light. The pack drops its own
[tonemap]: Horizon's curve clips at white in SDR as 0.1's clamp did, and light
above white survives the grade for HDR. The captures now match 0.1 within
about 0.3 steps on average; what's left is the glow around light sources.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 17:44:38 +01:00
MSedgeMC dd6a0b938d port_map: ChatScreen.formatChat is the input's draft formatter 2026-11-05 16:16:26 +00:00
MSedgeMC 4077c5e01c test: a restored chat draft draws grey italic and one Backspace clears it
Through `ChatScreen::new` with `is_draft`, `key` and `draw` - the calls
`Game::open_chat` and `Game::chat_key_code` make: the draft's drawn text is
grey and italic, an edited draft and a box opened on ordinary text are not,
and the first Backspace on an untouched draft empties the box where an
ordinary box loses one character.
2026-11-05 16:16:26 +00:00
MSedgeMC 6a51ac58a7 feat: a restored chat draft draws grey italic and clears on the first Backspace
`ChatScreen` keeps the `isDraft` its constructor argument sets: `formatChat`
draws the text grey italic until `onEdited` runs, and `keyPressed` clears the
whole box on the first Backspace while it is still set. Horizon tracked the
draft (`Game::chat_draft`, `chat_draft_shown`) but opened the box as plain
text: `open_chat_method` knew whether the text was the draft and did not pass
it.

`ChatScreen::new` takes `is_draft` (`ChatScreen.init`'s `isDraft`), `edited`
(`onEdited`) clears it, `key` (`keyPressed`) clears the input on a Backspace
while it is set, `format` applies the draft formatter (`formatChat`, which
`ChatScreen.init` registers before `CommandSuggestions.formatText`) and
`resize` keeps it (`ChatScreen.resize` keeps the field).

`Game::open_chat` threads the flag from `open_chat_method`
(`ChatComponent.createScreen`); the in-bed wake-up
(`InBedChatScreen.onPlayerWokeUp`) and `Gui.openChatAndAddText` open plain
text, as vanilla does.
2026-11-05 16:16:26 +00:00
repositorykreml1nandCodex 6d9e1191a2 fix: broadcast command-block say announcements to connected players
Broadcast unsigned say chat independently of feedback capture and keep the
command source display name. Add a two-client LAN regression for both
TrackOutput states and conditional-chain success.

Co-Authored-By: Codex <[email protected]>
2026-11-05 19:16:22 +03:00
DUzzL fb828fda35 Teleport spectators that use a nether portal, end portal or end gateway 2026-11-05 17:08:32 +01:00
some-du6eandClaude Opus 5.5 a1a052c35c OptiFine custom skies: read the sky layers of optifine/sky
Parse optifine/sky/world<N>/sky<M>.properties into layers (source, fade
times, blend, rotation, days, weather, biomes, heights, transition), the
sun and moon_phases replacements, and the 3x2 skybox grid's faces.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 09:59:41 -06:00
DUzzL 37fed7a88e Credit damage_dealt_absorbed for a player's projectiles 2026-11-05 16:47:46 +01:00
IsT3RiK f3e8afcb03 Launcher: add Glass, Blur and the server modes' notes to every table
These labels reach tr through a variable (the Launcher settings' slider rows, MODE_NOTES in
the server dialog), so the scan for tr("...") never asked for them and no table had them: they
showed in English in every language. Added to every table, in English, and translated in
fr.json.
2026-11-05 16:05:40 +01:00
IsT3RiK 66beeb539d Launcher: the Mods page's install messages go through tr
The messages the Mods page shows after an install (a mod, a native mod, a data pack, a
modpack, a resource pack) and its "isn't set up" errors were built with format!, so they
stayed in English in every language: "Installed JEI for Horizon 0.1.0 into Horizon. Build to
play with it." on a French launcher. They now use trf with named holes. The 11 strings are
added to every table, in English, and translated in fr.json.
2026-11-05 15:47:58 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 2bc92b6348 Serve shader API 0.2 behind HORIZON_HDR_FRAME=float
With the switch, the game draws 0.2 packs ([frame] blend = "linear" is
still refused). A renderer made with a 0.2 pack picked draws a float frame
where the device can, else the 8-bit one; a pack picked or dropped later
runs on the frame there is, logged once. The renderer's material, sky,
weather, vertex and shadow entries decode a 0.2 stage's colours and encode
its results; scene_color, opaque_color and the water mirror are decoded
copies, shadow_color is float, and Frame's sky and fog colours are
decoded. The post chain ends in a window-size picture, and the pack's
[tonemap] (or Horizon's clip) draws it onto the frame, encoded at alpha 1.
With HDR on, HDR output lifts nothing of a 0.2 pack's world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 15:37:14 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 36021e2559 Golden Hour's shader API 0.2 port, as a test pack of the SDK
The example pack written for 0.2: its sky and post passes in linear light,
with the 0.1 pack's gamma constants decoded, and a [tonemap] stage of its
own that eases colours above three quarters of the headroom toward it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-05 14:59:12 +01:00
Claude 82f601072b Server panel: the launcher's Glass look, a page for each part, in-page dialogs
The panel takes the Glass look from docs/design-system.md: the sunset with pixel ridges, frosted
plates, Geist and Bricolage Grotesque Heading, the theme's Hugeicons. What it already did gets a page
each in a sidebar (overview, players, console, backups, crash reports), and confirm()/prompt() become
in-page dialogs. The supervisor serves the theme's fonts and the mark; the CSP gains font-src 'self'.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Jh5A2PzdusBChqE38VZ3zD
2026-11-04 23:29:16 +00:00
451 changed files with 26915 additions and 2010 deletions

No files matched your search

+8 -7
View File
@@ -234,14 +234,15 @@ jobs:
run: python ../tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
- name: Check launcher DLL imports and command-line startup
working-directory: launcher
shell: pwsh
# bash, as launcher.yml's check on the same runner: the launcher is a GUI-subsystem program, and a pwsh
# `$output = & launcher --version` read no output from it (run 42217014588).
shell: bash
run: |
$ErrorActionPreference = 'Stop'
$launcher = 'target/aarch64-pc-windows-msvc/release/horizon-launcher.exe'
python ../tools/check_imports.py $launcher
$output = & $launcher --version 2>&1
if ($LASTEXITCODE -ne 0 -or -not $output) { throw 'horizon-launcher --version failed' }
Write-Output ($output -join "`n")
launcher=target/aarch64-pc-windows-msvc/release/horizon-launcher.exe
python ../tools/check_imports.py "$launcher"
out=$("$launcher" --version)
echo "$out"
[[ "$out" == "horizon-launcher "* ]] || { echo "::error::horizon-launcher --version printed '$out'"; exit 1; }
- name: Build a native-mod game with the launcher's managed toolchain
working-directory: launcher
shell: pwsh
Generated
+199 -3
View File
@@ -153,6 +153,12 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d"
[[package]]
name = "arraydeque"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayref"
version = "0.3.9"
@@ -485,6 +491,7 @@ checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
"serde",
"windows-link",
]
@@ -540,6 +547,12 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "combine"
version = "4.6.8"
@@ -946,6 +959,18 @@ version = "3.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -1105,8 +1130,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
@@ -1135,6 +1162,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "gilrs"
version = "0.11.2"
@@ -1264,6 +1301,15 @@ dependencies = [
"serde_core",
]
[[package]]
name = "hashlink"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "heck"
version = "0.5.0"
@@ -1660,10 +1706,15 @@ dependencies = [
name = "horizon-mod-deps"
version = "0.1.0"
dependencies = [
"gif",
"mysql",
"quinn",
"redis",
"rusqlite",
"rusty-opus",
"snap",
"tungstenite",
"yaml-rust2",
]
[[package]]
@@ -2268,6 +2319,17 @@ dependencies = [
"redox_syscall 0.9.4",
]
[[package]]
name = "libsqlite3-sys"
version = "0.39.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14a5f01b87b92705d07b433c06f96cbc764637d81a0bffb5734ce17cf0f9a2b3"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
@@ -2323,6 +2385,12 @@ version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
[[package]]
name = "lru-slab"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
[[package]]
name = "lz4_flex"
version = "0.14.0"
@@ -2450,6 +2518,7 @@ dependencies = [
"btoi",
"byteorder",
"bytes",
"chrono",
"crc32fast",
"flate2",
"getrandom 0.3.4",
@@ -2484,7 +2553,7 @@ dependencies = [
"num-traits",
"once_cell",
"pp-rs",
"rustc-hash",
"rustc-hash 1.1.0",
"spirv",
"thiserror 2.0.21",
"unicode-ident",
@@ -2498,7 +2567,7 @@ checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
dependencies = [
"hashbrown 0.17.1",
"indexmap",
"rustc-hash",
"rustc-hash 1.1.0",
"thiserror 2.0.21",
]
@@ -3354,6 +3423,62 @@ dependencies = [
"memchr",
]
[[package]]
name = "quinn"
version = "0.11.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
dependencies = [
"bytes",
"cfg_aliases",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash 2.1.3",
"rustls",
"socket2 0.6.5",
"thiserror 2.0.21",
"tokio",
"tracing",
"web-time",
]
[[package]]
name = "quinn-proto"
version = "0.11.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
dependencies = [
"bytes",
"getrandom 0.4.3",
"lru-slab",
"rand",
"rand_pcg",
"ring",
"rustc-hash 2.1.3",
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.21",
"tinyvec",
"tracing",
"web-time",
]
[[package]]
name = "quinn-udp"
version = "0.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.5",
"tracing",
"windows-sys 0.61.2",
]
[[package]]
name = "quote"
version = "1.0.47"
@@ -3401,6 +3526,15 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
[[package]]
name = "rand_pcg"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
dependencies = [
"rand_core 0.10.1",
]
[[package]]
name = "range-alloc"
version = "0.1.5"
@@ -3539,12 +3673,32 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rusqlite"
version = "0.41.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da9bba6f3506135e120a6586df05be18b48d18896600646a4bec8f993d91a594"
dependencies = [
"bitflags 2.13.2",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
[[package]]
name = "rustc_version"
version = "0.4.1"
@@ -3619,6 +3773,7 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
dependencies = [
"web-time",
"zeroize",
]
@@ -3883,6 +4038,12 @@ dependencies = [
"serde",
]
[[package]]
name = "snap"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886"
[[package]]
name = "socket2"
version = "0.5.10"
@@ -4198,13 +4359,23 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2 0.6.5",
"windows-sys 0.61.2",
]
[[package]]
@@ -4274,6 +4445,9 @@ name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "tree_magic_mini"
@@ -4412,6 +4586,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "vec_map"
version = "0.8.2"
@@ -4675,6 +4855,12 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "wgpu"
version = "30.0.1"
@@ -4728,7 +4914,7 @@ dependencies = [
"portable-atomic",
"profiling",
"raw-window-handle",
"rustc-hash",
"rustc-hash 1.1.0",
"smallvec",
"thiserror 2.0.21",
"wgpu-core-deps-apple",
@@ -5471,6 +5657,16 @@ version = "0.8.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
[[package]]
name = "yaml-rust2"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57e5b818a27a4cd30884ea380857a5e56f7ec3ba24a3990a3cc0b95af3238e18"
dependencies = [
"arraydeque",
"hashlink",
]
[[package]]
name = "yasna"
version = "0.6.0"
+136 -12
View File
@@ -90,6 +90,9 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--seed N default 12345
--explore-speed B blocks a second an explore bot flies; default 22 (as fast as chunks come)
--pvp B fight bots also attack each other (true or false); default true
--crits fight bots swing at a sword's full strength (every 13 ticks) and jump into every
other swing, landing it while falling: a critical hit, then a sweep from the
ground (give them swords); for PvP benches (crit and sweep particles)
--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
@@ -165,6 +168,8 @@ struct Options {
attack_after: f64,
attack_rounds: u32,
no_swing: bool,
/// --crits: fight bots alternate jumped critical hits and sweeps at a sword's cooldown.
crits: bool,
flood: usize,
no_read: bool,
no_loaded: bool,
@@ -202,6 +207,7 @@ fn options() -> Result<Options, String> {
attack_after: 5.0,
attack_rounds: u32::MAX,
no_swing: false,
crits: false,
flood: 100,
no_read: false,
no_loaded: false,
@@ -230,6 +236,10 @@ fn options() -> Result<Options, String> {
o.no_swing = true;
continue;
}
if arg == "--crits" {
o.crits = true;
continue;
}
if arg == "--no-loaded" {
o.no_loaded = true;
continue;
@@ -532,11 +542,84 @@ fn distance(a: [f64; 3], b: [f64; 3]) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
/// A sword's attack speed is 1.6 a second: full strength 20 / 1.6 = 12.5 ticks after a swing.
const SWORD_COOLDOWN_TICKS: u32 = 13;
/// `LivingEntity.getJumpPower`: a jump's first upward motion, in blocks a tick.
const JUMP_POWER: f64 = 0.42;
/// `--crits`: a fight bot that swings at a sword's full strength and jumps into every other swing, so
/// it lands while falling (`Player.attack`'s critical hit: falling, not on the ground, full strength),
/// and the next one from the ground (a sweep: on the ground, full strength, a sword).
#[derive(Default)]
struct Crits {
/// Vertical motion while in the air (`LivingEntity.travel`: minus 0.08 a tick, times 0.98).
jump: Option<f64>,
ground: f64,
/// The next swing is the jumped one.
crit_next: bool,
}
impl Crits {
fn airborne(&self) -> bool {
self.jump.is_some()
}
/// One tick: walk up to the target and, with a full cooldown, strike; jump ahead of a crit's swing.
fn tick(
&mut self,
pos: &mut [f64; 3],
target: (i32, [f64; 3]),
since: &mut u32,
) -> Option<i32> {
if let Some(v) = self.jump {
pos[1] += v;
if pos[1] <= self.ground {
pos[1] = self.ground;
self.jump = None;
} else {
self.jump = Some((v - 0.08) * 0.98);
}
} else {
self.ground = pos[1];
// A jump takes 6 ticks to its top: start it so the cooldown ends on the way down.
if self.crit_next && *since + 8 >= SWORD_COOLDOWN_TICKS {
self.jump = Some(JUMP_POWER);
}
}
let falling = self.jump.is_some_and(|v| v < 0.0);
if self.crit_next && !falling {
*since = since.saturating_add(1);
fight_step(pos, target.1);
return None;
}
let hit = fight_tick(pos, target, since, SWORD_COOLDOWN_TICKS);
if hit.is_some() {
self.crit_next = !self.crit_next;
}
hit
}
}
/// One tick of a fighting bot with a target: walk up to it, and strike once the cooldown is over.
/// Returns the entity to attack. `since_attack` counts ticks since the last swing.
fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u32) -> Option<i32> {
fn fight_tick(
pos: &mut [f64; 3],
target: (i32, [f64; 3]),
since_attack: &mut u32,
cooldown: u32,
) -> Option<i32> {
*since_attack = since_attack.saturating_add(1);
let (id, at) = target;
fight_step(pos, at);
if *since_attack >= cooldown && distance(*pos, at) <= ATTACK_REACH {
*since_attack = 0;
return Some(id);
}
None
}
/// Walk toward `at`, stopping `CLOSE_IN` short of it.
fn fight_step(pos: &mut [f64; 3], at: [f64; 3]) {
let (dx, dz) = (at[0] - pos[0], at[2] - pos[2]);
let horizontal = dx.hypot(dz);
if horizontal > CLOSE_IN {
@@ -544,11 +627,6 @@ fn fight_tick(pos: &mut [f64; 3], target: (i32, [f64; 3]), since_attack: &mut u3
pos[0] += dx / horizontal * step;
pos[2] += dz / horizontal * step;
}
if *since_attack >= ATTACK_COOLDOWN_TICKS && distance(*pos, at) <= ATTACK_REACH {
*since_attack = 0;
return Some(id);
}
None
}
/// One tick of walking to random points around the spawn, at sprint speed (5.6 blocks a second).
@@ -1124,6 +1202,7 @@ fn run_bot(
spawn[1] + o.height
};
let mut since_attack = ATTACK_COOLDOWN_TICKS;
let mut crits = Crits::default();
let heading = random.unit() * std::f64::consts::TAU;
let mut target = pos;
let mut next = Instant::now();
@@ -1181,11 +1260,16 @@ fn run_bot(
Behaviour::Fight => {
let near = lock(&bot.entities).nearest(pos, o.pvp, &bots);
if let Some(near) = near {
if let Some(victim) = fight_tick(&mut pos, near, &mut since_attack) {
let hit = if o.crits {
crits.tick(&mut pos, near, &mut since_attack)
} else {
fight_tick(&mut pos, near, &mut since_attack, ATTACK_COOLDOWN_TICKS)
};
if let Some(victim) = hit {
// The client sends the attack, then swings the arm.
let sent = send(
play::Serverbound::Attack(play::Attack { id: victim }).encode(),
) && send(raw(sb::PUNCH, |w| w.varint(0)));
) && send(play::Serverbound::Punch(Empty).encode());
if sent {
bot.attacks.fetch_add(1, Ordering::Relaxed);
}
@@ -1300,9 +1384,10 @@ fn run_bot(
x: pos[0],
y: pos[1],
z: pos[2],
// Fight bots stand on the ground; the others fly.
// Fight bots stand on the ground (but in a --crits jump); the others fly.
flags: u8::from(
matches!(behaviour, Behaviour::Fight | Behaviour::Stand) || o.ground,
(matches!(behaviour, Behaviour::Fight | Behaviour::Stand) && !crits.airborne())
|| o.ground,
),
})
.encode(),
@@ -1600,7 +1685,9 @@ mod tests {
let target = (3, [1.0, 64.0, 0.0]);
let mut since = ATTACK_COOLDOWN_TICKS;
let hits: Vec<u32> = (0..40)
.filter_map(|tick| fight_tick(&mut pos, target, &mut since).map(|_| tick))
.filter_map(|tick| {
fight_tick(&mut pos, target, &mut since, ATTACK_COOLDOWN_TICKS).map(|_| tick)
})
.collect();
assert_eq!(hits[0], 0, "a bot in reach strikes at once");
assert!(
@@ -1617,7 +1704,7 @@ mod tests {
let mut since = ATTACK_COOLDOWN_TICKS;
let mut first = None;
for tick in 0..100 {
if fight_tick(&mut pos, target, &mut since).is_some() {
if fight_tick(&mut pos, target, &mut since, ATTACK_COOLDOWN_TICKS).is_some() {
first = Some(tick);
break;
}
@@ -1627,6 +1714,43 @@ mod tests {
assert!(pos[0] < 10.0 - CLOSE_IN + 1e-9);
}
#[test]
fn crits_alternate_a_falling_swing_and_a_grounded_one() {
let mut pos = [0.0, 64.0, 0.0];
let target = (3, [1.0, 64.0, 0.0]);
let (mut since, mut crits) = (SWORD_COOLDOWN_TICKS, Crits::default());
let mut last_y = pos[1];
let mut hits = Vec::new();
for tick in 0..120u32 {
let hit = crits.tick(&mut pos, target, &mut since);
if hit.is_some() {
hits.push((tick, crits_state(pos[1], last_y)));
}
last_y = pos[1];
}
assert!(hits.len() >= 6, "{hits:?}");
for (i, w) in hits.windows(2).enumerate() {
assert!(
w[1].0 - w[0].0 >= SWORD_COOLDOWN_TICKS,
"full strength each time: {hits:?}"
);
assert_ne!(w[0].1, w[1].1, "swing {i} and the next alternate: {hits:?}");
}
// A crit lands in the air, on the way down; a sweep from the ground.
assert!(hits.iter().any(|h| h.1 == "falling") && hits.iter().any(|h| h.1 == "ground"));
assert!(pos[1] >= 64.0);
}
fn crits_state(y: f64, before: f64) -> &'static str {
if y == 64.0 {
"ground"
} else if y < before {
"falling"
} else {
"rising"
}
}
#[test]
fn percentiles_use_nearest_rank() {
assert_eq!(percentile(&[], 50), 0);
@@ -66,6 +66,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
false,
true,
false,
))?;
assert!(!renderer.pack_entity_shadows(), "no selected pack");
renderer.set_shader_pack(0, None, &[]).unwrap();
@@ -224,6 +224,7 @@ fn replay(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
settings.translucent_batching,
false,
false,
false,
))?;
drop(pixels);
render.pixels.pack();
@@ -126,6 +126,7 @@ fn fixture(window: Arc<Window>) -> Result<(), Box<dyn std::error::Error>> {
false,
false,
true,
false,
))?;
renderer.resize(320, 180);
assert!(
@@ -368,6 +369,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_motion_posed: &[],
entity_spans: &[],
skin: None,
glowing: &[],
@@ -538,6 +538,11 @@ impl BlockEntities {
});
Arc::clone(&self.tickers)
}
/// How many block entities tick (`Level.blockEntityTickers`, the slots still live), as of
/// the last [`Self::tickers`]: what a diagnostics mod counts.
pub fn ticking_len(&self) -> usize {
self.live.len()
}
/// Whether a slot from `tickers` still ticks.
pub fn is_live(&self, (pos, slot): (Pos, u32)) -> bool {
self.live.get(&pos) == Some(&slot) && self.map.contains_key(&pos)
@@ -0,0 +1,249 @@
//! Every block state's facts and looks by its id, for a client mod drawing blocks or a map off the main thread
//! (`native_block_states`; docs/native-mods.md): what the game knows of each state, what its model draws on each side
//! and the texture behind each of those sprites. A copy, so the game's own tables can change shape under it.
use crate::{
block_sprites::{BlockSprites, Lookup, SpriteRef},
streaming::WorkerContext,
};
use horizon_items::blocks::{BlockFacts, MAP_COLORS, RenderShape};
use horizon_world::{
block_collision::BlockCollisionRegistry, fluid::FluidState, registry::BlockStateId,
};
use std::{collections::HashMap, sync::Arc};
/// What one block state is, as a renderer or a map reads it.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct BlockStateFacts {
/// Its block, without properties (`minecraft:grass_block`).
pub name: Arc<str>,
/// `BlockState.isAir`: air, cave air or void air.
pub air: bool,
/// No model draws it (render shape `INVISIBLE`): air, barriers, light, and water and lava, drawn as fluids.
pub invisible: bool,
/// `BlockState.getFluidState`: the fluid in it, a waterlogged block's and kelp's included; empty for none.
pub fluid: FluidState,
/// Its `waterlogged` property is true.
pub waterlogged: bool,
/// The light it gives, 0 to 15.
pub emission: u8,
/// `BlockState.getShape`, the outline the cursor picks, as block-local boxes
/// `[min_x, min_y, min_z, max_x, max_y, max_z]`: `[0, 0, 0, 1, 1, 1]` for a whole block, none for air. Without
/// the random offset of flowers and grass.
pub outline: Arc<[[f64; 6]]>,
/// `MapColor.col` (`0xRRGGBB`), its colour on a vanilla map; none for `MapColor.NONE`, which maps leave out.
pub map_color: Option<u32>,
/// It can collide at all: false for a state whose collision shape is always empty (air, flowers, water).
pub collides: bool,
}
// A mod builds its tables from these on a worker thread.
const _: () = {
const fn shareable<T: Send + Sync + 'static>() {}
shareable::<BlockStates>()
};
/// Every block state by its id: its facts, and what its model draws with the resources loaded when it was made.
#[derive(Clone)]
pub struct BlockStates {
states: Arc<[BlockStateFacts]>,
context: Arc<WorkerContext>,
/// Texture names by sprite layer, across every array.
textures: Arc<HashMap<u32, String>>,
split: u32,
}
impl BlockStates {
/// The states of `context`'s registry, with their `facts` and `collision`, and the sprites of `context`'s
/// resources at `split` layers to an array (`Renderer::sprite_split`).
pub fn new(
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
context: Arc<WorkerContext>,
split: u32,
) -> Self {
let registry = context.assets.registry;
let states = facts
.states
.iter()
.enumerate()
.map(|(i, f)| {
let id = BlockStateId(i as u32);
BlockStateFacts {
name: f.name.clone(),
air: facts.is_air(id),
invisible: f.render_shape == RenderShape::Invisible,
fluid: registry
.state(id)
.and_then(|state| FluidState::from_block(state).ok())
.unwrap_or(FluidState::EMPTY),
waterlogged: f.waterlogged,
emission: f.emission,
outline: f.outline.clone(),
map_color: MAP_COLORS
.get(usize::from(f.map_color))
.copied()
.filter(|_| f.map_color != 0),
collides: collision.may_collide(id),
}
})
.collect();
// Vanilla's names go without their namespace, as `native_draw_cube` takes them.
let textures = context
.render
.sprite_layers
.iter()
.map(|(name, layer)| (*layer, name.trim_start_matches("minecraft:").to_owned()))
.collect();
Self {
states,
context,
textures: Arc::new(textures),
split: split.max(1),
}
}
/// How many states there are: their ids run from 0 to one less.
pub fn len(&self) -> usize {
self.states.len()
}
pub fn is_empty(&self) -> bool {
self.states.is_empty()
}
/// `state`'s facts; none for an id past the last state.
pub fn get(&self, state: BlockStateId) -> Option<&BlockStateFacts> {
self.states.get(state.0 as usize)
}
/// Every state with its id, in id order.
pub fn iter(&self) -> impl Iterator<Item = (BlockStateId, &BlockStateFacts)> {
self.states
.iter()
.enumerate()
.map(|(i, facts)| (BlockStateId(i as u32), facts))
}
/// What `state` draws on each side, as `native_block_sprites` says for a block by name; none for an id past the
/// last state.
pub fn sprites(&self, state: BlockStateId) -> Option<BlockSprites> {
self.get(state)?;
Some(Lookup::new(&self.context, self.split).state(state))
}
/// The texture `sprite` shows, by the name `native_texture_sprites` takes (`block/grass_block_top`; a mod's with
/// its namespace, `mymod:block/gear`).
pub fn texture(&self, sprite: SpriteRef) -> Option<&str> {
let layer = sprite
.array
.checked_mul(self.split)?
.checked_add(sprite.layer)?;
self.textures.get(&layer).map(String::as_str)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
assets::Assets, biome::Biomes, block_sprites::parse_state, light::LightTables,
mesher::MeshTables,
};
use horizon_assets::model::Direction;
use horizon_world::fluid::FluidKind;
use std::{path::PathBuf, sync::OnceLock};
/// Far fewer layers to an array than a GPU takes, so most sprites lie past array 0.
const SMALL_SPLIT: u32 = 64;
/// The game's own states from real prepared resources, loaded once for every test here.
fn states() -> &'static BlockStates {
static STATES: OnceLock<BlockStates> = OnceLock::new();
STATES.get_or_init(|| {
let root = PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES"),
);
let assets = Arc::new(Assets::load(&root).unwrap());
let render = Arc::new(assets.reload(&root).unwrap());
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let context = Arc::new(WorkerContext {
light: Arc::new(LightTables::load(&root, assets.registry, &facts).unwrap()),
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility).unwrap()),
assets: assets.clone(),
render,
biomes: Arc::new(Biomes::load(&root).unwrap()),
});
BlockStates::new(&facts, &collision, context, SMALL_SPLIT)
})
}
fn facts(text: &str) -> &'static BlockStateFacts {
let states = states();
let id = parse_state(states.context.assets.registry, text).expect(text);
states.get(id).unwrap()
}
#[test]
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
fn a_grass_block_is_a_whole_solid_grass_coloured_block() {
let grass = facts("grass_block");
assert_eq!(&*grass.name, "minecraft:grass_block");
assert!(!grass.air && !grass.invisible && !grass.waterlogged);
assert_eq!(grass.fluid.kind(), FluidKind::Empty);
assert_eq!(grass.emission, 0);
assert_eq!(&*grass.outline, [[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]]);
assert_eq!(grass.map_color, Some(0x7F_B238), "MapColor.GRASS");
assert!(grass.collides);
}
#[test]
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
fn air_and_light_are_not_drawn() {
let air = facts("air");
assert!(air.air && air.invisible && !air.collides);
assert!(air.outline.is_empty());
assert_eq!(air.map_color, None);
let light = facts("light[level=15]");
assert!(!light.air && light.invisible);
assert_eq!(light.emission, 15);
}
#[test]
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
fn a_slabs_outline_is_its_half() {
assert_eq!(
&*facts("stone_slab[type=bottom]").outline,
[[0.0, 0.0, 0.0, 1.0, 0.5, 1.0]]
);
assert_eq!(
&*facts("stone_slab[type=top]").outline,
[[0.0, 0.5, 0.0, 1.0, 1.0, 1.0]]
);
let wet = facts("stone_slab[type=bottom,waterlogged=true]");
assert!(wet.waterlogged && wet.collides);
assert_eq!(wet.fluid.kind(), FluidKind::Water);
assert!(wet.fluid.is_source());
}
#[test]
#[ignore = "requires prepared resources in HORIZON_TEST_RESOURCES"]
fn a_face_names_its_texture() {
let states = states();
let id = parse_state(states.context.assets.registry, "grass_block").unwrap();
let grass = states.sprites(id).unwrap();
let top = grass.sides[Direction::Up as usize][0].sprite;
assert_eq!(states.texture(top), Some("block/grass_block_top"));
let north = &grass.sides[Direction::North as usize];
assert_eq!(
states.texture(north[0].sprite),
Some("block/grass_block_side")
);
assert_eq!(
states.texture(north[1].sprite),
Some("block/grass_block_side_overlay")
);
}
}
+36 -4
View File
@@ -1193,6 +1193,10 @@ impl SuggestLayout {
/// the suggestion list, Tab completion, history).
pub struct ChatScreen {
pub input: TextField,
/// `ChatScreen.isDraft`: the box opened with the draft saved last time and
/// has not been edited since. A draft draws grey italic, and the first
/// Backspace clears the whole box instead of one character.
is_draft: bool,
width: i32,
height: i32,
history_buffer: String,
@@ -1300,11 +1304,13 @@ fn truncate_utf16(s: &str, max_units: usize) -> String {
s.to_owned()
}
impl ChatScreen {
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`.
/// `ChatScreen.init`: the input at the bottom, focused, holding `initial`,
/// which is a draft when `is_draft` (`ChatComponent.createScreen`).
pub fn new(
m: &Measure,
c: &Commands,
initial: &str,
is_draft: bool,
history_len: usize,
width: i32,
height: i32,
@@ -1317,6 +1323,7 @@ impl ChatScreen {
input.set_value(m, initial);
let mut s = Self {
input,
is_draft,
width,
height,
history_buffer: String::new(),
@@ -1353,6 +1360,7 @@ impl ChatScreen {
input.set_value(m, command);
let mut s = Self {
input,
is_draft: false,
width,
height,
history_buffer: String::new(),
@@ -1382,7 +1390,8 @@ impl ChatScreen {
self.input.set_value(m, command);
self.edited(m, c);
}
/// `ChatScreen.resize`: a new screen with the current text.
/// `ChatScreen.resize`: a new screen with the current text, still a draft
/// while it has not been edited (`ChatScreen.resize` keeps the field).
pub fn resize(&mut self, m: &Measure, c: &Commands, width: i32, height: i32) {
if (width, height) != (self.width, self.height) {
let value = self.input.value.clone();
@@ -1390,7 +1399,16 @@ impl ChatScreen {
let abilities = self.abilities;
let message = std::mem::take(&mut self.input.message);
*self = if self.layout == SuggestLayout::CHAT {
Self::new(m, c, &value, self.history_pos, width, height, abilities)
Self::new(
m,
c,
&value,
self.is_draft,
self.history_pos,
width,
height,
abilities,
)
} else {
Self::command_block(m, c, &value, width, height)
};
@@ -1402,6 +1420,7 @@ impl ChatScreen {
fn edited(&mut self, m: &Measure, c: &Commands) {
self.allow_suggestions = true;
self.update_command_info(m, c);
self.is_draft = false;
}
/// Ctrl+A/C/V/X in the input box.
pub fn shortcut(
@@ -1729,6 +1748,14 @@ impl ChatScreen {
if self.suggestions_key(m, c, key, shift) {
return ChatAction::None;
}
// ChatScreen.keyPressed: while the box holds an unedited draft, the
// first Backspace clears all of it rather than one character.
if self.is_draft && key == K::Backspace {
self.input.set_value(m, "");
self.is_draft = false;
self.edited(m, c);
return ChatAction::None;
}
match key {
K::Escape => return ChatAction::Close,
K::Enter | K::NumpadEnter => {
@@ -1862,8 +1889,13 @@ impl ChatScreen {
l.offset = ((l.offset as f64 - amount.clamp(-1.0, 1.0)) as i32).clamp(0, max) as usize;
true
}
/// `CommandSuggestions.formatText`: argument colours over the input.
/// `CommandSuggestions.formatText`: argument colours over the input. The
/// draft formatter (`ChatScreen.formatChat`) is registered before this one
/// and wins while the draft is unedited: the whole text is grey italic.
fn format(&self, value: &str, offset: usize) -> Text {
if self.is_draft {
return Text::literal(value).with_color(text::GRAY).italic();
}
let gray = Style::color(text::GRAY);
let Some(parse) = &self.parse else {
return Text::literal(value);
+104 -7
View File
@@ -201,7 +201,7 @@ fn locate_coordinates_fill_chat_with_teleport_without_submitting() {
assert_eq!(link.component()["click_event"]["command"], expected);
let mut chat = Chat::default();
add(&mut chat, &font, Text::literal("Found at ").append(link));
let mut screen = ChatScreen::new(&m, &commands, "draft", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &commands, "draft", false, 0, 640, 360, full());
let x = 4.0 + f64::from(font.width("Found at "));
assert!(!screen.click(&m, &commands, x, 315.0));
let Some(ClickEvent::SuggestCommand(command)) = chat.click_at(&font, 360, x, 315.0) else {
@@ -242,6 +242,7 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
&m,
&commands,
"unfinished draft",
false,
history.len(),
640,
360,
@@ -263,6 +264,93 @@ fn history_arrows_recall_messages_and_restore_the_draft() {
}
}
/// `ChatScreen.isDraft`: the box opens on the draft kept from last time
/// (`ChatComponent.createScreen` passes `true`, which `ChatScreen.init`
/// stores), so the text draws grey italic through `ChatScreen.formatChat`
/// and the first Backspace clears the whole draft instead of one character.
/// Editing the draft (`onEdited`) ends both.
#[test]
fn a_restored_draft_is_grey_italic_and_one_backspace_clears_it() {
use winit::keyboard::KeyCode::Backspace;
let font = font();
let m = Measure(&font);
let registries = Registries::default();
let c = Commands {
dispatcher: crate::command::vanilla::game_dispatcher(),
registries: &registries,
};
// The input's own text as the game draws it: x 4, y height - 12.
let input_text = |screen: &mut ChatScreen| -> Text {
screen
.draw(&m, (0, 0), 0, i32::MIN)
.into_iter()
.find_map(|draw| match draw {
Draw::Text {
text, x: 4, y: 348, ..
} => Some(text),
_ => None,
})
.expect("the input draws its text")
};
let draft_style = |t: &Text| {
t.spans
.iter()
.all(|s| s.style.color == Some(text::GRAY) && s.style.is_italic())
};
let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full());
assert_eq!(screen.value(), "half-written");
assert!(
draft_style(&input_text(&mut screen)),
"the restored draft draws grey italic"
);
// Typing into the draft (`onEdited`) ends the draft style, and Backspace
// eats one character again.
screen.type_text(&m, &c, "!");
assert_eq!(screen.value(), "half-written!");
let typed = input_text(&mut screen);
assert!(
!typed.spans.iter().any(|s| s.style.is_italic()),
"an edited draft is not grey italic"
);
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "half-written");
// A draft left alone clears whole on its first Backspace.
let mut screen = ChatScreen::new(&m, &c, "half-written", true, 0, 640, 360, full());
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "", "the first Backspace clears the draft");
// The flag is gone with it: typing is ordinary text, and Backspace eats
// one character.
screen.type_text(&m, &c, "hi");
assert_eq!(screen.value(), "hi");
let typed = input_text(&mut screen);
assert!(!typed.spans.iter().any(|s| s.style.is_italic()));
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "h");
// A box opened on ordinary text deletes one character at a time.
let mut screen = ChatScreen::new(&m, &c, "half-written", false, 0, 640, 360, full());
assert!(!draft_style(&input_text(&mut screen)));
assert_eq!(
screen.key(&m, &c, &[], Backspace, false, false),
ChatAction::None
);
assert_eq!(screen.value(), "half-writte");
// A command draft is grey italic as a whole: `formatChat` is registered
// before `CommandSuggestions.formatText`, so it wins over the argument
// colours.
let mut screen = ChatScreen::new(&m, &c, "/give @s stone", true, 0, 640, 360, full());
assert!(draft_style(&input_text(&mut screen)));
}
#[test]
fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
let font = font();
@@ -283,7 +371,7 @@ fn mod_commands_are_coloured_and_complete_like_vanilla_ones() {
dispatcher,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, line);
screen
};
@@ -333,7 +421,7 @@ fn chat_completes_the_servers_custom_completions_with_the_players() {
dispatcher,
registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, line);
// Chat shows no list by itself: Tab shows it, Tab again uses it.
for _ in 0..2 {
@@ -381,7 +469,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
let tab = |screen: &mut ChatScreen| {
screen.key(&m, &c, &[], winit::keyboard::KeyCode::Tab, false, false);
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
let (request, sent) = screen.take_request().expect("the server is asked");
assert_eq!(sent, "/bossbar get pt");
@@ -403,7 +491,7 @@ fn ask_server_nodes_wait_for_the_servers_completions() {
dispatcher: local,
registries: &registries,
};
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, full());
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, full());
screen.type_text(&m, &c, "/bossbar get pt");
assert_eq!(screen.take_request(), None);
}
@@ -473,7 +561,7 @@ fn the_chat_option_decides_what_may_be_sent() {
assert_eq!(abilities.allows("hi"), chat, "{visibility:?}");
assert_eq!(abilities.allows("/time set day"), command, "{visibility:?}");
for (line, allowed) in [("hi", chat), ("/time set day", command)] {
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, abilities);
let mut screen = ChatScreen::new(&m, &c, "", false, 0, 640, 360, abilities);
screen.type_text(&m, &c, line);
match enter(&mut screen) {
ChatAction::Submit(sent) => {
@@ -499,7 +587,16 @@ fn restricted_input_says_why_it_will_not_send() {
registries: &registries,
};
let usage = |visibility, line: &str| {
let mut screen = ChatScreen::new(&m, &c, "", 0, 640, 360, ChatAbilities::new(visibility));
let mut screen = ChatScreen::new(
&m,
&c,
"",
false,
0,
640,
360,
ChatAbilities::new(visibility),
);
screen.type_text(&m, &c, line);
screen.usage.iter().map(|t| t.plain()).collect::<Vec<_>>()
};
@@ -0,0 +1,143 @@
//! The files of the resources the game loaded, resource packs' over vanilla's, for a client mod reading textures and
//! other resources on any thread (`native_client_resources`; docs/native-mods.md).
use horizon_core::prepared_files;
use std::path::PathBuf;
/// A texture's pixels: `width` by `height` RGBA, rows top first, as its PNG stores them (gamma-encoded, alpha not
/// premultiplied). An animated texture is its frames one under another, as in its file.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TexturePixels {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
// A mod reads textures on a worker thread.
const _: () = {
const fn shareable<T: Send + Sync + 'static>() {}
shareable::<ClientResources>()
};
/// The resources the game loaded, as one tree: vanilla's, with the resource packs turned on (a higher pack over a
/// lower one) and the content mods' files over it. A pack turned on or off makes a new tree: ask for it again then.
/// It reads the files on disk at each call, not a copy: while a reload rebuilds them, a read can miss a file or get
/// the new pack's before `native_block_sprites_version()` changes.
#[derive(Clone, Debug)]
pub struct ClientResources {
root: PathBuf,
}
impl ClientResources {
/// The tree at `root` (the game's `options.resources`).
pub fn new(root: PathBuf) -> Self {
Self { root }
}
/// The bytes of the resource at `path` in the tree (`assets/minecraft/textures/entity/cow/cow_temperate.png`).
/// None when there is no such file, or for a path that isn't plain names under `assets/`.
pub fn read(&self, path: &str) -> Option<Vec<u8>> {
let plain = |p: &str| !p.is_empty() && p != "." && p != ".." && !p.contains('\\');
if !path.starts_with("assets/") || !path.split('/').all(plain) {
return None;
}
prepared_files::read(&self.root.join(path)).ok()
}
/// A texture's pixels by name (`block/stone`, `entity/cow/cow_temperate`, a mod's `mymod:item/gear`): the PNG
/// under `assets/<namespace>/textures/`, decoded. None when there is no such PNG or it doesn't decode.
pub fn texture(&self, name: &str) -> Option<TexturePixels> {
let path = crate::atlas_sources::texture_path(&self.root, name, "textures");
let (width, height, rgba) =
crate::hud::decode_bytes(&prepared_files::read(&path).ok()?).ok()?;
Some(TexturePixels {
width,
height,
rgba,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::packs::{build_overlay, discover};
use std::{fs, path::Path};
const COW: &str = "assets/minecraft/textures/entity/cow/cow_temperate.png";
const RED: [u8; 4] = [255, 0, 0, 255];
const BLUE: [u8; 4] = [0, 0, 255, 255];
fn one_pixel_png(rgba: [u8; 4]) -> Vec<u8> {
let mut out = Vec::new();
let mut encoder = png::Encoder::new(&mut out, 1, 1);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&rgba)
.unwrap();
out
}
fn write(path: &Path, bytes: &[u8]) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, bytes).unwrap();
}
#[test]
fn a_resource_pack_overrides_a_texture() {
let base =
std::env::temp_dir().join(format!("horizon-client-resources-{}", std::process::id()));
let _ = fs::remove_dir_all(&base);
let vanilla = base.join("vanilla");
write(&vanilla.join(COW), &one_pixel_png(RED));
let pack = base.join("packs/Blue cows");
write(
&pack.join("pack.mcmeta"),
br#"{"pack":{"description":"blue cows","pack_format":97}}"#,
);
write(&pack.join(COW), &one_pixel_png(BLUE));
let overlay = base.join("overlay");
build_overlay(&vanilla, &discover(&vanilla, &base.join("packs")), &overlay).unwrap();
let (plain, packed) = (ClientResources::new(vanilla), ClientResources::new(overlay));
assert_eq!(plain.read(COW), Some(one_pixel_png(RED)));
assert_eq!(packed.read(COW), Some(one_pixel_png(BLUE)));
assert_eq!(
packed.texture("entity/cow/cow_temperate"),
Some(TexturePixels {
width: 1,
height: 1,
rgba: BLUE.to_vec(),
})
);
let _ = fs::remove_dir_all(base);
}
#[test]
fn only_plain_paths_under_assets_are_read() {
let base = std::env::temp_dir().join(format!(
"horizon-client-resources-paths-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&base);
let root = base.join("root");
write(&root.join(COW), &one_pixel_png(RED));
write(&root.join("horizon-resources.json"), b"{}");
write(&base.join("secret.txt"), b"secret");
let resources = ClientResources::new(root);
assert!(resources.read(COW).is_some());
for refused in [
"horizon-resources.json",
"assets/../horizon-resources.json",
"assets/../../secret.txt",
"assets//minecraft/textures/entity/cow/cow_temperate.png",
"/assets/minecraft/textures/entity/cow/cow_temperate.png",
] {
assert_eq!(resources.read(refused), None, "{refused}");
}
let _ = fs::remove_dir_all(base);
}
}
+14 -1
View File
@@ -243,11 +243,24 @@ impl SceneCollisions<'_, '_> {
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
) -> Result<Vec3> {
self.resolve_in_border_with(bounds, movement, max_step, on_ground, border, &[])
}
/// [`Self::resolve_in_border`], also stopped by `entities`: the boxes
/// `EntityGetter.getEntityCollisions` gave the moving entity.
pub fn resolve_in_border_with(
self,
bounds: Aabb,
movement: Vec3,
max_step: f32,
on_ground: bool,
border: Option<(&BorderGeometry, [f64; 2])>,
entities: &[Aabb],
) -> Result<Vec3> {
let context = self.context(bounds);
EntityCollisions {
blocks: self,
entities: &[],
entities,
border,
}
.resolve_with_context(bounds, movement, max_step, on_ground, context)
+22 -1
View File
@@ -10,6 +10,7 @@ use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use chrono::{DateTime, Local};
use horizon_telemetry::system::machine_arch;
/// What the game is doing, set as it goes: graphics adapter, settings, packs, world.
static DETAILS: Mutex<Vec<(&'static str, String)>> = Mutex::new(Vec::new());
@@ -242,9 +243,18 @@ fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
/// The build's architecture, and the machine's after "on" when it emulates the build.
fn arch_label(build: &str, machine: &str) -> String {
if build == machine {
build.to_owned()
} else {
format!("{build} on {machine}")
}
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
fn operating_system() -> String {
let arch = std::env::consts::ARCH;
let arch = arch_label(std::env::consts::ARCH, machine_arch());
let version = if cfg!(target_os = "linux") {
let name = read("/etc/os-release").and_then(|t| {
t.lines()
@@ -683,6 +693,17 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
mod tests {
use super::*;
#[test]
fn an_emulated_build_names_both_architectures() {
// A Windows ARM64 PC playing the x64 build: its reports say so (#7513 read "x86_64").
assert_eq!(arch_label("x86_64", "aarch64"), "x86_64 on aarch64");
assert_eq!(arch_label("aarch64", "aarch64"), "aarch64");
assert_eq!(arch_label("x86_64", "x86_64"), "x86_64");
// On the machine running the tests: native builds name one architecture.
let label = arch_label(std::env::consts::ARCH, machine_arch());
assert!(label.starts_with(std::env::consts::ARCH));
}
#[test]
fn a_release_reports_its_built_in_version() {
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
+46 -5
View File
@@ -116,8 +116,9 @@ pub enum EffectAction {
}
/// `shouldApplyEffectTickThisTick` + `applyEffectTick` for the periodic effects.
fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
// Java's `>>` and `<<` take the shift distance modulo 32 (`wrapping_*`).
let every = |base: i32| {
let interval = base >> amp;
let interval = base.wrapping_shr(amp as u32);
interval <= 0 || tick % interval == 0
};
match id {
@@ -126,10 +127,12 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
"wither" if every(40) => Some(EffectAction::Hurt("minecraft:wither", 1.0)),
"hunger" => Some(EffectAction::Exhaust(0.005 * (amp + 1) as f32)),
"saturation" if tick >= 1 => Some(EffectAction::Eat(amp + 1, 1.0)),
"instant_health" if tick >= 1 => Some(EffectAction::Heal((4 << amp.min(30)) as f32)),
"instant_health" if tick >= 1 => Some(EffectAction::Heal(
4i32.wrapping_shl(amp as u32).max(0) as f32,
)),
"instant_damage" if tick >= 1 => Some(EffectAction::Hurt(
"minecraft:magic",
(6 << amp.min(30)) as f32,
6i32.wrapping_shl(amp as u32) as f32,
)),
_ => None,
}
@@ -139,11 +142,11 @@ fn periodic(id: &str, tick: i32, amp: i32) -> Option<EffectAction> {
pub fn instant(id: &str, amp: i32, scale: f64) -> Option<EffectAction> {
match id {
"instant_health" => Some(EffectAction::Heal(
(scale * f64::from(4 << amp.min(30)) + 0.5) as i32 as f32,
(scale * f64::from(4i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32,
)),
"instant_damage" => Some(EffectAction::Hurt(
"minecraft:magic",
(scale * f64::from(6 << amp.min(30)) + 0.5) as i32 as f32,
(scale * f64::from(6i32.wrapping_shl(amp as u32)) + 0.5) as i32 as f32,
)),
"saturation" => Some(EffectAction::Eat(amp + 1, 1.0)),
_ => None,
@@ -494,6 +497,44 @@ pub fn effect_modifier(effect: &str) -> Option<(&'static str, &'static str, f64,
#[cfg(test)]
mod tests {
use super::*;
/// `HealOrHarmMobEffect`: Java's `<<` shifts by the amplifier modulo
/// 32 and wraps, so amplifier 32 is amplifier 0 again; a ticked heal is
/// at least 0 (#11386).
#[test]
fn heal_and_harm_shift_like_java() {
let magic = "minecraft:magic";
assert_eq!(
instant("instant_health", 32, 1.0),
Some(EffectAction::Heal(4.0))
);
assert_eq!(
instant("instant_damage", 33, 1.0),
Some(EffectAction::Hurt(magic, 12.0))
);
assert_eq!(
instant("instant_health", 1, 0.5),
Some(EffectAction::Heal(4.0))
);
assert_eq!(
periodic("instant_health", 1, 32),
Some(EffectAction::Heal(4.0))
);
// 4 << 29 is i32::MIN: the heal stops at 0. 6 << 30 wraps to i32::MIN.
assert_eq!(
periodic("instant_health", 1, 29),
Some(EffectAction::Heal(0.0))
);
assert_eq!(
periodic("instant_damage", 1, 30),
Some(EffectAction::Hurt(magic, i32::MIN as f32))
);
// Regeneration: 50 >> 32 is 50 again, not 0 (which would heal each tick).
assert_eq!(periodic("regeneration", 49, 32), None);
assert_eq!(
periodic("regeneration", 50, 32),
Some(EffectAction::Heal(1.0))
);
}
#[test]
fn adding_counts_a_change_only_when_the_effect_changed() {
+317 -97
View File
@@ -926,6 +926,10 @@ pub struct Frame<'a> {
/// Which entity `entities`' vertices belong to: (first vertex, entity id), each run up
/// to the next; id 0 for none. Frame generation follows these entities' own motion.
pub entity_motion: &'a [(u32, u64)],
/// For each mark of `entity_motion`, the GPU-posed vertex count at it (`skin`'s
/// `vertices`): where that entity's posed vertices start. A shader pack's `at_velocity`
/// matches both parts of an entity with last frame's.
pub entity_motion_posed: &'a [u32],
/// The glowing entities' silhouettes in their team colours (`OutlineBufferSource`),
/// outlined through walls after the hand.
pub glowing: &'a [Vertex],
@@ -1030,6 +1034,25 @@ pub struct ScreenshotRequest {
/// `Renderer::screenshot_quality`, not the window's.
pub scaled: bool,
}
/// A solid terrain pipeline without HDR output's lighting (terrain.wgsl's `HDR_LIGHTING`),
/// for the frames HDR is off, and with it where the display offers HDR output: the
/// lighting's code, even never run, slows the shader on Apple GPUs (~15% of the solid
/// pass at Retina sizes).
pub struct SolidPipeline {
plain: wgpu::RenderPipeline,
hdr: Option<wgpu::RenderPipeline>,
}
impl SolidPipeline {
/// The pipeline for a frame with HDR output on (`hdr`) or off.
pub fn get(&self, hdr: bool) -> &wgpu::RenderPipeline {
match &self.hdr {
Some(lit) if hdr => lit,
_ => &self.plain,
}
}
}
pub struct Renderer {
instance: wgpu::Instance,
adapter: wgpu::Adapter,
@@ -1081,10 +1104,10 @@ pub struct Renderer {
/// operation fails on a lost device (no error scope sees it).
device_lost: Arc<std::sync::atomic::AtomicBool>,
pub config: wgpu::SurfaceConfiguration,
solid: wgpu::RenderPipeline,
solid: SolidPipeline,
/// `solid` split in two passes for the world pass (terrain.wgsl's `opaque_quad`).
solid_opaque: wgpu::RenderPipeline,
solid_cutout: wgpu::RenderPipeline,
solid_opaque: SolidPipeline,
solid_cutout: SolidPipeline,
translucent: wgpu::RenderPipeline,
translucent_mark: wgpu::RenderPipeline,
/// Flat section draws (`flat`), where multi-draw is emulated.
@@ -1376,9 +1399,12 @@ pub struct Renderer {
hdr_output: Option<hdr::Output>,
/// What every pipeline that draws the frame targets: the swapchain's 8-bit format, or
/// `Rgba16Unorm` when the renderer was made with HDR output on (`wide_frame`) and the
/// device draws and blends into it. A 16-bit frame with HDR off since reaches the
/// swapchain through `readback`.
/// device draws and blends into it, or `LINEAR_FRAME` (`frame_format`). A 16-bit frame
/// with HDR off since reaches the swapchain through `readback`.
frame_format: wgpu::TextureFormat,
/// Whether a pack picked or dropped since the renderer was made has been told it runs on
/// the frame that exists (`set_native_shader_pack`).
frame_kept_logged: bool,
/// The marker file of an HDR output that has not presented a frame yet
/// (`graphics::HDR_MARKER`).
hdr_marker: Option<std::path::PathBuf>,
@@ -1392,6 +1418,8 @@ pub struct Renderer {
native: Option<native_pack::NativePack>,
/// Time the shader pack's passes on the GPU (F3, or `HORIZON_SHADER_PACK_TIMING`).
pass_timing: bool,
/// Least time between timed frames (`set_pass_timing_gap`).
pass_timing_gap: std::time::Duration,
/// Times Horizon's own passes on the GPU, as `pass_timing` does (`own_pass_times`).
own_timing: Option<pack_timing::PassTiming>,
/// The entity vertices Horizon draws itself while a shader pack draws the others.
@@ -1852,9 +1880,10 @@ impl wgpu::rwh::HasDisplayHandle for WindowDisplay {
}
/// The backends to open a device on, each tried in turn until one gives a
/// device. `api` is the player's choice (`GraphicsApi::with_env` applied);
/// `os` is `std::env::consts::OS`; `wgpu_backend`: whether `WGPU_BACKEND` is set,
/// which picks the backends itself. Auto tries DirectX 12 first on Windows ARM64,
/// then Vulkan, based on the device-loss observed in a Windows ARM64 test. Other
/// `os` is `std::env::consts::OS`; `arch` the machine's (`system::machine_arch`), not the
/// build's; `wgpu_backend`: whether `WGPU_BACKEND` is set, which picks the backends itself.
/// Auto tries DirectX 12 first on Windows ARM64, the x64 build emulated there included,
/// then Vulkan, based on the device losses seen there (#7513). Other
/// Windows systems try Vulkan, then DirectX 12, then OpenGL; Linux tries Vulkan then
/// OpenGL. Each is tried when the one before has no surface, adapter or device. A native
/// crash in the driver can't be
@@ -1977,7 +2006,13 @@ fn draws_wide_frame(features: wgpu::Features, format: wgpu::TextureFormatFeature
features.contains(
wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES,
) && format.allowed_usages.contains(
) && draws_frame(format)
}
/// Whether a format with the adapter's `format` features takes every usage and flag the
/// passes take of the frame.
fn draws_frame(format: wgpu::TextureFormatFeatures) -> bool {
format.allowed_usages.contains(
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC
@@ -1987,8 +2022,14 @@ fn draws_wide_frame(features: wgpu::Features, format: wgpu::TextureFormatFeature
)
}
/// What a renderer's frame is drawn in: `WIDE_FRAME` when it is made with HDR on and Smooth
/// Gradients (`wide_frame`, `Graphics::wide_frame`), the surface offering HDR output
/// The frame of a renderer made with a shader API 0.2 pack picked under
/// `HORIZON_HDR_FRAME=float` (docs/design/hdr-linear-light.md): float, so values over 1
/// survive from the world to the tonemap.
const LINEAR_FRAME: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// What a renderer's frame is drawn in: `LINEAR_FRAME` for a 0.2 pack under the switch
/// (`linear`: `(picked, the device draws it)`); else `WIDE_FRAME` when it is made with HDR on
/// and Smooth Gradients (`wide_frame`, `Graphics::wide_frame`), the surface offering HDR output
/// (`hdr_offered`) and the device drawing it (`draws_wide_frame`): an 8-bit frame's steps band
/// in dark gradients, and HDR shows them. Else the swapchain's 8-bit format, as without HDR.
fn frame_format(
@@ -1996,8 +2037,11 @@ fn frame_format(
hdr_offered: bool,
device_draws_it: bool,
swapchain: wgpu::TextureFormat,
linear: (bool, bool),
) -> wgpu::TextureFormat {
if wide_frame && hdr_offered && device_draws_it {
if linear.0 && linear.1 {
LINEAR_FRAME
} else if wide_frame && hdr_offered && device_draws_it {
WIDE_FRAME
} else {
swapchain.remove_srgb_suffix()
@@ -2018,7 +2062,8 @@ async fn open_device(
for (order, allow_cpu) in device_search(
api.with_env(),
std::env::consts::OS,
std::env::consts::ARCH,
// The machine's: the x64 build emulated on a Windows ARM64 PC has its GPU too (#7513).
horizon_telemetry::system::machine_arch(),
wgpu_backend,
) {
match open_device_with(window, offscreen, &order, layers, allow_cpu).await {
@@ -2470,6 +2515,7 @@ impl Renderer {
translucent_batching: bool,
wide_frame: bool,
shader_pack: bool,
linear_pack: bool,
) -> Result<Self> {
let Opened {
instance,
@@ -2603,7 +2649,22 @@ impl Renderer {
adapter.get_texture_format_features(WIDE_FRAME),
);
crate::graphics::offer_smooth_gradients(wide_ok);
let frame_format = frame_format(wide_frame, hdr_output.is_some(), wide_ok, config.format);
let linear = (
linear_pack,
draws_frame(adapter.get_texture_format_features(LINEAR_FRAME)),
);
if linear == (true, false) {
log::warn!(
"Horizon renderer: the device can't draw a float frame; the shader API 0.2 pack runs on the 8-bit one"
);
}
let frame_format = frame_format(
wide_frame,
hdr_output.is_some(),
wide_ok,
config.format,
linear,
);
if previous.is_none() {
startup_step(format_args!("creating the pipelines"));
}
@@ -2723,16 +2784,24 @@ impl Renderer {
step_mode: wgpu::VertexStepMode::Instance,
attributes: &debug_line_attributes,
})];
let pipeline_in = |layout: &wgpu::PipelineLayout,
label: &str,
vertex: &str,
fragment: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
topology: wgpu::PrimitiveTopology,
cull: Option<wgpu::Face>,
depth_write: bool,
depth_test: bool,
blend: Option<wgpu::BlendState>| {
// The solid terrain's fragment constants without HDR output's lighting, for the
// frames HDR is off (`SolidPipeline`).
let plain_constants = [
sprite_constants[0],
sprite_constants[1],
("HDR_LIGHTING", 0.0),
];
let pipeline_with = |constants: &[(&str, f64)],
layout: &wgpu::PipelineLayout,
label: &str,
vertex: &str,
fragment: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
topology: wgpu::PrimitiveTopology,
cull: Option<wgpu::Face>,
depth_write: bool,
depth_test: bool,
blend: Option<wgpu::BlendState>| {
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(layout),
@@ -2763,7 +2832,7 @@ impl Renderer {
module: &shader,
entry_point: Some(fragment),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &sprite_constants,
constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
@@ -2776,6 +2845,57 @@ impl Renderer {
cache: None,
})
};
let pipeline_in = |layout: &wgpu::PipelineLayout,
label: &str,
vertex: &str,
fragment: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
topology: wgpu::PrimitiveTopology,
cull: Option<wgpu::Face>,
depth_write: bool,
depth_test: bool,
blend: Option<wgpu::BlendState>| {
pipeline_with(
&sprite_constants,
layout,
label,
vertex,
fragment,
buffers,
topology,
cull,
depth_write,
depth_test,
blend,
)
};
// A solid terrain pipeline (back faces culled, depth written, no blend) without HDR
// output's lighting, and with it where the display offers HDR output.
let solid_in = |layout: &wgpu::PipelineLayout,
label: &str,
vertex: &str,
fragment: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>]| {
let build = |constants: &[(&str, f64)]| {
pipeline_with(
constants,
layout,
label,
vertex,
fragment,
buffers,
wgpu::PrimitiveTopology::TriangleList,
Some(wgpu::Face::Back),
true,
true,
None,
)
};
SolidPipeline {
plain: build(&plain_constants),
hdr: hdr_output.is_some().then(|| build(&sprite_constants)),
}
};
let pipeline = |label: &str,
vertex: &str,
fragment: &str,
@@ -2799,41 +2919,29 @@ impl Renderer {
)
};
let tri = wgpu::PrimitiveTopology::TriangleList;
let solid = pipeline(
let solid = solid_in(
&layout,
"Terrain solid",
"vs_terrain",
"fs_solid",
&terrain_buffers,
tri,
Some(wgpu::Face::Back),
true,
true,
None,
);
// `solid` in two passes (terrain.wgsl's `opaque_quad`, `mesher::Part`), for the
// world pass without a shader pack: no cutout first, through a shader that never
// discards, then the rest.
let solid_opaque = pipeline(
let solid_opaque = solid_in(
&layout,
"Terrain solid opaque",
"vs_terrain_opaque",
"fs_opaque",
&terrain_buffers,
tri,
Some(wgpu::Face::Back),
true,
true,
None,
);
let solid_cutout = pipeline(
let solid_cutout = solid_in(
&layout,
"Terrain solid cutout",
"vs_terrain_cutout",
"fs_solid",
&terrain_buffers,
tri,
Some(wgpu::Face::Back),
true,
true,
None,
);
// The water mirror's camera sees the world from the other side of the plane, which
// flips the winding: its solid terrain culls the front.
@@ -4354,6 +4462,7 @@ impl Renderer {
layout, label, vertex, fragment, buffers, tri, cull, true, true, blend,
)
},
|layout, label, vertex, fragment| solid_in(layout, label, vertex, fragment, &[]),
)
});
// With flat draws' page groups the per-section solid draws read their vertices from
@@ -4367,40 +4476,24 @@ impl Renderer {
..Default::default()
});
let origins = [None, terrain_buffers[1].clone()];
let pulled = |label: &str, vertex: &str, fragment: &str, cull| {
let solid = |label: &str, vertex: &str, fragment: &str| {
solid_in(&pulled_layout, label, vertex, fragment, &origins)
};
(
solid("Terrain solid", "vs_section", "fs_solid"),
solid("Terrain solid opaque", "vs_section_opaque", "fs_opaque"),
solid("Terrain solid cutout", "vs_section_cutout", "fs_solid"),
pipeline_in(
&pulled_layout,
label,
vertex,
fragment,
&origins,
tri,
Some(cull),
true,
true,
None,
)
};
let back = wgpu::Face::Back;
(
pulled("Terrain solid", "vs_section", "fs_solid", back),
pulled(
"Terrain solid opaque",
"vs_section_opaque",
"fs_opaque",
back,
),
pulled(
"Terrain solid cutout",
"vs_section_cutout",
"fs_solid",
back,
),
pulled(
"Terrain solid (mirrored)",
"vs_section",
"fs_solid_mirror",
wgpu::Face::Front,
&origins,
tri,
Some(wgpu::Face::Front),
true,
true,
None,
),
)
}
@@ -4737,11 +4830,13 @@ impl Renderer {
emission_stats,
hdr_output,
frame_format,
frame_kept_logged: false,
hdr_marker: None,
pack_post: None,
pack_loading: None,
native: None,
pass_timing: pack_timing::PassTiming::logging(),
pass_timing_gap: std::time::Duration::ZERO,
own_timing: None,
pack_entity_rest: Vec::new(),
cloud_bindings,
@@ -6683,6 +6778,18 @@ impl Renderer {
block_light::LightTable,
)>,
) -> std::result::Result<Vec<String>, String> {
let linear = pack
.as_ref()
.is_some_and(|p| p.0.manifest.api == horizon_shader_api::Api::V0_2);
if (linear || self.frame_format == LINEAR_FRAME)
&& linear != (self.frame_format == LINEAR_FRAME)
&& !std::mem::replace(&mut self.frame_kept_logged, true)
{
log::info!(
"Horizon renderer: the {:?} frame stays until the renderer is made again; a shader API 0.2 pack picked or dropped since runs on it",
self.frame_format
);
}
let Some((report, constants, sprite_tags, sprite_materials, lights)) = pack else {
self.native = None;
return Ok(Vec::new());
@@ -6826,6 +6933,15 @@ impl Renderer {
pub fn set_pass_timing(&mut self, on: bool) {
self.pass_timing = on || pack_timing::PassTiming::logging();
}
/// Times a frame at most once per `gap` while passes are timed (the F3 overlay needs far fewer
/// samples than a measurement). `HORIZON_SHADER_PACK_TIMING` keeps every sample.
pub fn set_pass_timing_gap(&mut self, gap: std::time::Duration) {
self.pass_timing_gap = if pack_timing::PassTiming::logging() {
std::time::Duration::ZERO
} else {
gap
};
}
/// The running shader pack's timer, of either kind.
fn pass_timer<'a>(
pack: &'a mut Option<(i32, Option<pack_post::PackPost>)>,
@@ -7401,6 +7517,7 @@ impl Renderer {
.flatten();
}
if let Some(t) = &mut self.own_timing {
t.gap = self.pass_timing_gap;
t.begin(&self.device, &self.queue);
}
if self.animated_textures {
@@ -8531,6 +8648,7 @@ impl Renderer {
frame.end_portal_sprites[1],
&mut self.pack_entity_rest,
skin.filter(|_| pack_skin),
(frame.entity_motion, frame.entity_motion_posed),
);
entities = &self.pack_entity_rest;
}
@@ -9038,6 +9156,9 @@ impl Renderer {
.then(|| pack_timing::PassTiming::new(&self.device, &self.queue))
.flatten();
}
if let Some(Some(t)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
t.gap = self.pass_timing_gap;
}
let pack_reads_world = pack_post || native_post;
laps.lap("r_uploads");
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
@@ -9441,6 +9562,8 @@ impl Renderer {
}
}
if let Some(native) = &mut self.native {
let reflection =
reflection.map(|view| native.frame.linear_mirror(&self.device, &mut encoder, view));
native
.frame
.set_reflection(&self.device, reflection.as_ref());
@@ -9474,7 +9597,9 @@ impl Renderer {
.as_ref()
.filter(|_| !frame.title)
.and_then(|n| Some((n.material.as_ref()?, &n.frame.group)));
let solid_pipeline = material.map_or(&self.solid, |(m, _)| &m.solid);
// Without HDR output, the solid shaders without its lighting (`SolidPipeline`).
let hdr = self.hdr.is_some();
let solid_pipeline = material.map_or(self.solid.get(hdr), |(m, _)| &m.solid);
// Two solid passes only where a discard costs the GPU its hidden surface
// removal: Apple's tiled GPUs, behind Metal. Elsewhere the second pass
// would double the solid draw calls and vertex work for nothing.
@@ -9699,14 +9824,14 @@ impl Renderer {
let passes: &[(&wgpu::RenderPipeline, Calls, Calls)] = match pack_flat {
Some(p) => &[(&p.0, 0..flat.phase1_calls, 0..phase2_calls)],
None if split_solid => &[
(&flat.solid_opaque, 0..opaque, 0..phase2_split),
(flat.solid_opaque.get(hdr), 0..opaque, 0..phase2_split),
(
&flat.solid_cutout,
flat.solid_cutout.get(hdr),
cutout..flat.phase1_calls,
phase2_split..phase2_calls,
),
],
None => &[(&flat.solid, 0..flat.phase1_calls, 0..phase2_calls)],
None => &[(flat.solid.get(hdr), 0..flat.phase1_calls, 0..phase2_calls)],
};
let draw = |pass: &mut wgpu::RenderPass<'_>| {
for (pipeline, calls, _) in passes {
@@ -9759,9 +9884,13 @@ impl Renderer {
type Draws = std::ops::Range<u32>;
let passes: &[(&wgpu::RenderPipeline, Draws, Draws)] = match material {
None if split_solid => &[
(&self.solid_opaque, 0..opaque, phase1_draws..phase2_cutout),
(
&self.solid_cutout,
self.solid_opaque.get(hdr),
0..opaque,
phase1_draws..phase2_cutout,
),
(
self.solid_cutout.get(hdr),
cutout..phase1_draws,
phase2_cutout..solid_draws,
),
@@ -10103,12 +10232,7 @@ impl Renderer {
depth.size(),
);
if let Some((color, _)) = &scaled {
let color = color.texture();
encoder.copy_texture_to_texture(
color.as_image_copy(),
native.frame.opaque_color.as_image_copy(),
color.size(),
);
native.frame.copy_opaque(&self.device, &mut encoder, color);
}
pass = world_pass_after_culling(
&mut encoder,
@@ -10740,12 +10864,18 @@ impl Renderer {
{
self.readback.present(&self.device, &mut encoder, image);
}
// A 0.2 pack is shown at paper white: HDR output lifts nothing of its world.
let lifts_world = !frame.title
&& self
.native
.as_ref()
.is_none_or(|n| n.frame.api == horizon_shader_api::Api::V0_1);
match (&self.hdr, &surface_frame) {
(Some(hdr), Some(f)) => hdr.present(
&self.queue,
&mut encoder,
&f.texture,
!frame.title,
lifts_world,
self.own_timing.as_mut(),
),
// Offscreen nothing shows HDR: the target gets the frame as SDR shows it, for
@@ -10764,7 +10894,7 @@ impl Renderer {
.filter(|(request, _)| request.size.is_none())
.zip(self.hdr.as_ref())
.and_then(|(_, hdr)| {
let texture = hdr.capture(&self.device, &self.queue, &mut encoder, !frame.title)?;
let texture = hdr.capture(&self.device, &self.queue, &mut encoder, lifts_world)?;
let size = [self.config.width, self.config.height];
Some(copy_frame(&self.device, &mut encoder, &texture, size))
});
@@ -12294,20 +12424,39 @@ mod tests {
fn the_frame_is_16_bit_only_with_hdr_and_a_device_that_draws_it() {
use super::frame_format;
use wgpu::TextureFormat::{Bgra8Unorm, Bgra8UnormSrgb, Rgba8Unorm, Rgba16Unorm};
assert_eq!(frame_format(true, true, true, Bgra8Unorm), Rgba16Unorm);
for (wide_frame, hdr_offered, draws) in [
(false, true, true),
(true, false, true),
(true, true, false),
(false, false, false),
] {
// No 0.2 pack (or a device without the float frame) leaves it as before.
for linear in [(false, false), (false, true), (true, false)] {
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb),
Bgra8Unorm
frame_format(true, true, true, Bgra8Unorm, linear),
Rgba16Unorm
);
for (wide_frame, hdr_offered, draws) in [
(false, true, true),
(true, false, true),
(true, true, false),
(false, false, false),
] {
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb, linear),
Bgra8Unorm
);
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Rgba8Unorm, linear),
Rgba8Unorm
);
}
}
}
/// A shader API 0.2 pack picked under `HORIZON_HDR_FRAME=float` gets the float frame
/// wherever the device draws it, HDR on or off.
#[test]
fn a_0_2_pack_gets_the_float_frame_where_the_device_draws_it() {
use super::{LINEAR_FRAME, frame_format};
use wgpu::TextureFormat::Bgra8UnormSrgb;
for (wide_frame, hdr_offered, draws) in [(true, true, true), (false, false, false)] {
assert_eq!(
frame_format(wide_frame, hdr_offered, draws, Rgba8Unorm),
Rgba8Unorm
frame_format(wide_frame, hdr_offered, draws, Bgra8UnormSrgb, (true, true)),
LINEAR_FRAME
);
}
}
@@ -12707,6 +12856,77 @@ mod tests {
}
}
#[test]
fn plain_solid_shaders_translate_for_every_backend() {
// `SolidPipeline::plain` builds terrain.wgsl's solid entries with `HDR_LIGHTING`
// off on every backend: they must still translate (the other test translates them
// with the constants' defaults).
let module = naga::front::wgsl::parse_str(include_str!("terrain.wgsl")).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
let stage = naga::ShaderStage::Fragment;
let constants = [("HDR_LIGHTING".to_owned(), 0.0)].into_iter().collect();
for entry in ["fs_solid", "fs_opaque"] {
let (module, info) = naga::back::pipeline_constants::process_overrides(
&module,
&info,
Some((stage, entry)),
&constants,
)
.unwrap();
let msl = naga::back::msl::write_string(
&module,
&info,
&naga::back::msl::Options {
fake_missing_bindings: true,
..Default::default()
},
&naga::back::msl::PipelineOptions {
entry_point: Some((stage, entry.to_owned())),
..Default::default()
},
);
// Metal compiles the lighting out from this constant.
assert!(
msl.unwrap()
.0
.contains("constant bool HDR_LIGHTING = false;"),
"{entry}"
);
let mut hlsl = String::new();
naga::back::hlsl::Writer::new(
&mut hlsl,
&Default::default(),
&naga::back::hlsl::PipelineOptions {
entry_point: Some((stage, entry.to_owned())),
},
)
.write(&module, &info, None)
.unwrap_or_else(|e| panic!("{entry}: {e}"));
let mut glsl = String::new();
naga::back::glsl::Writer::new(
&mut glsl,
&module,
&info,
&naga::back::glsl::Options {
version: naga::back::glsl::Version::Desktop(330),
..Default::default()
},
&naga::back::glsl::PipelineOptions {
shader_stage: stage,
entry_point: entry.to_owned(),
multiview: None,
},
Default::default(),
)
.and_then(|mut w| w.write())
.unwrap_or_else(|e| panic!("{entry}: {e}"));
}
}
#[test]
fn entity_shader_validates_with_its_overlay_constants() {
let module = naga::front::wgsl::parse_str(&super::entity_shader()).unwrap();
naga::valid::Validator::new(
@@ -1040,7 +1040,14 @@ mod tests {
cache: None,
})
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline);
let pipeline = &pipeline;
let solid = |layout: &wgpu::PipelineLayout, label: &str, _: &str, _: &str| {
super::super::SolidPipeline {
plain: pipeline(layout, label, "", "", None, None, true),
hdr: None,
}
};
let mut flat = flat::Flat::new(&device, &terrain_layout, 4 * GRAIN, 0, pipeline, solid);
let stride = std::mem::size_of::<crate::mesher::TerrainVertex>() as u64;
let mut translucent = Arena::translucent(&device, stride, 4 * GRAIN);
translucent.page_limit = 4 * GRAIN;
@@ -530,6 +530,7 @@ impl Scene {
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_motion_posed: &[],
entity_spans: &[],
skin: None,
glowing: &[],
+10 -16
View File
@@ -17,6 +17,7 @@
//! copied after the test, which tells which ones it saw: each run of a page
//! reserves room for all of its draws, the copy packs the seen ones at its
//! start, and the run is drawn indirectly with the count the copy reached.
use super::SolidPipeline;
use super::arena::GRAIN;
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
@@ -86,12 +87,12 @@ pub struct Call {
pub struct Flat {
/// The solid draws in one pass (`fs_solid`), where the GPU doesn't hide pixels
/// by tiles (`split_solid` in `Renderer::draw_frame`: elsewhere than Metal).
pub solid: wgpu::RenderPipeline,
pub solid: SolidPipeline,
/// The solid draws in two passes, each over its own part of the draws (terrain.wgsl's
/// `opaque_quad`): the quads with no cutout first, through a shader that never discards,
/// then the rest.
pub solid_opaque: wgpu::RenderPipeline,
pub solid_cutout: wgpu::RenderPipeline,
pub solid_opaque: SolidPipeline,
pub solid_cutout: SolidPipeline,
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
@@ -357,7 +358,8 @@ fn plan(
impl Flat {
/// `pipeline(layout, label, vertex entry, fragment entry, blend, cull, pulled)` builds a
/// terrain pipeline as the per-section ones, without the origin buffer, and without
/// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`).
/// vertex buffers when `pulled` (its vertex shader reads the page, `ARENA_BINDING`);
/// `solid(layout, label, vertex entry, fragment entry)` a pulled solid one.
pub fn new(
device: &wgpu::Device,
terrain_layout: &wgpu::BindGroupLayout,
@@ -372,6 +374,7 @@ impl Flat {
Option<wgpu::Face>,
bool,
) -> wgpu::RenderPipeline,
solid: impl Fn(&wgpu::PipelineLayout, &str, &str, &str) -> SolidPipeline,
) -> Self {
let vertex = wgpu::ShaderStages::VERTEX;
let compute = wgpu::ShaderStages::COMPUTE;
@@ -432,32 +435,23 @@ impl Flat {
Self {
// The solid draws read either layout of the solid quads (`mesher::packed`) from
// the page; translucent ones keep their vertex buffer.
solid: pipeline(
solid: solid(
&layout,
"Terrain solid (flat)",
"vs_section_flat",
"fs_solid",
None,
back,
true,
),
solid_opaque: pipeline(
solid_opaque: solid(
&layout,
"Terrain solid opaque (flat)",
"vs_section_flat_opaque",
"fs_opaque",
None,
back,
true,
),
solid_cutout: pipeline(
solid_cutout: solid(
&layout,
"Terrain solid cutout (flat)",
"vs_section_flat_cutout",
"fs_solid",
None,
back,
true,
),
// The mirror flips the winding: its solid terrain culls the front.
solid_mirror: pipeline(
@@ -162,11 +162,13 @@ fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32
horizon_material_face_normal = normal;
horizon_surface = vec4<f32>(0.0);
let tangent = horizon_material_tangent(in.base, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a),
vec4(horizon_line_in(in.tint.rgb), in.tint.a), in.light, horizon_line_in(in.lightmap),
in.position, in.uv, translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent,
horizon_terrain_native_colored_light(in.position), in.displaced)).color;
horizon_line_in(horizon_terrain_native_colored_light(in.position)), in.displaced)).color;
return vec4(horizon_line_out(out.rgb), out.a);
}
var<private> horizon_material_face_normal: vec3<f32>;
struct HorizonMaterialSolid {
@@ -332,7 +334,7 @@ fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: v
let fluid = high >= 8u && (high & 7u) < 6u;
let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit,
vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid)));
vec4(horizon_line_in(color.rgb), 1.0), select(0u, high & 7u, fluid), u32(fluid)));
}
";
@@ -349,12 +351,13 @@ fn shader_source(
source: &str,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
(vertex, displaced): (bool, bool),
api: horizon_shader_api::Api,
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}{}{}",
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{}{ENTRIES}{}{}",
vanilla_terrain(),
super::native_post::line_helpers(api),
if vertex {
DISPLACE_PACK
} else {
@@ -378,8 +381,9 @@ fn module(
materials: &[(u32, u32)],
vertex: bool,
displaced: bool,
api: horizon_shader_api::Api,
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials, vertex, displaced);
let text = shader_source(&stage.source, tags, materials, (vertex, displaced), api);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -403,6 +407,7 @@ impl NativeMaterial {
shape.sprite_materials,
vertex,
displaced.is_some(),
frame.api,
)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
@@ -562,7 +567,7 @@ impl NativeMaterial {
#[cfg(test)]
mod tests {
use super::*;
use horizon_shader_api::{MANIFEST, Pack, StageKind, check};
use horizon_shader_api::{Api, MANIFEST, Pack, StageKind, check};
fn stage(material: &str) -> CheckedStage {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
@@ -609,7 +614,7 @@ mod tests {
let tags = [(7, 1), (3, 1), (9, 2)];
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials, false, false).unwrap();
let module = module(&stage, &tags, &materials, false, false, Api::V0_1).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -622,8 +627,8 @@ mod tests {
&stage.source,
&tags,
&materials,
false,
false
(false, false),
Api::V0_1
))
)
});
@@ -647,6 +652,33 @@ mod tests {
);
}
/// The terrain shader of a 0.2 pack with a vertex stage, whose tint the renderer decodes.
#[test]
fn the_terrain_validates_with_a_0_2_pack() {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n\
[material]\nfile = \"material.wgsl\"\n[vertex]\nfile = \"vertex.wgsl\"\n";
let material = "fn material(input: MaterialInput) -> Surface {\n \
return Surface(input.albedo * input.tint * vec4(input.colored_light, 1.0));\n}\n";
let vertex = "fn vertex(input: VertexInput) -> vec3<f32> {\n \
return input.position + input.tint.rgb;\n}\n";
let files = [
(MANIFEST, manifest),
("material.wgsl", material),
("vertex.wgsl", vertex),
]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.iter().find(|s| s.kind == StageKind::Material);
let module = module(stage.unwrap(), &[], &[], true, false, Api::V0_2).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
}
#[test]
fn the_displaced_grid_validates_with_a_pack() {
let manifest = "schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.1\"\n\
@@ -661,14 +693,14 @@ mod tests {
let mut report = check(&Pack::from_files(files).unwrap()).unwrap();
let stage = report.stages.remove(0);
let materials = [(5, 1000)];
let module = module(&stage, &[], &materials, false, true).unwrap();
let module = module(&stage, &[], &materials, false, true, Api::V0_1).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| {
let text = shader_source(&stage.source, &[], &materials, false, true);
let text = shader_source(&stage.source, &[], &materials, (false, true), Api::V0_1);
panic!("{}", e.emit_to_string(&text))
});
for entry in [
@@ -681,4 +713,116 @@ mod tests {
);
}
}
/// An identity material (vanilla's own lighting, `albedo * tint * lightmap`) under 0.2,
/// its colours decoded on the way in and encoded on the way out as the entries do, draws
/// what 0.1's does (vanilla's) within an 8-bit step.
#[test]
fn an_identity_0_2_material_is_vanilla_s_within_an_8_bit_step() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
// The entries' call of `material`, with a 16×16 grid of colours.
let entry = "@vertex fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
let c = floor(p.xy) / 15.0;
let albedo = vec4(c.x, c.y, 1.0 - c.x, 1.0);
let tint = vec4(0.9, 1.0 - 0.5 * c.y, 0.7, 1.0);
let lightmap = vec3(c.y, 0.8, 1.0 - c.y);
let out = material(MaterialInput(vec4(horizon_line_in(albedo.rgb), albedo.a),
vec4(horizon_line_in(tint.rgb), tint.a), vec2(1.0), horizon_line_in(lightmap),
vec3(0.0), vec2(0.0), false, 0u, 0u, vec2(0.0), 0u, 0u, 0.0, vec3(0.0, 1.0, 0.0),
vec4(1.0, 0.0, 0.0, 1.0), horizon_line_in(vec3(0.0)), 0.0)).color;
return vec4(horizon_line_out(out.rgb), out.a);
}
";
let vanilla = "fn material(input: MaterialInput) -> Surface {\n \
return Surface(input.albedo * input.tint * vec4(input.lightmap, 1.0));\n}\n";
let format = wgpu::TextureFormat::Rgba8Unorm;
let draw = |api: Api| {
let manifest = format!(
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"{api}\"\n\
[material]\nfile = \"material.wgsl\"\n"
);
let files = [(MANIFEST, manifest.as_str()), ("material.wgsl", vanilla)]
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let source = report.stages[0].source.clone()
+ horizon_shader_api::stages::PBR_STUB
+ super::super::native_post::line_helpers(api)
+ entry;
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: None,
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(format.into())],
}),
multiview_mask: None,
cache: None,
});
let size = wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
};
let target = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = target.create_view(&Default::default());
let mut encoder = device.create_command_encoder(&Default::default());
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
depth_slice: None,
ops: Default::default(),
})],
..Default::default()
});
pass.set_pipeline(&pipeline);
pass.draw(0..3, 0..1);
drop(pass);
let screenshot = crate::gfx::copy_frame(&device, &mut encoder, &target, [16, 16]);
queue.submit([encoder.finish()]);
screenshot
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
screenshot.slice(..).get_mapped_range().unwrap().to_vec()
};
let (old, new) = (draw(Api::V0_1), draw(Api::V0_2));
let worst = old.iter().zip(&new).map(|(a, b)| a.abs_diff(*b)).max();
assert!(worst.is_some_and(|w| w <= 1), "{worst:?}");
}
}
@@ -101,7 +101,15 @@ impl NativePack {
let settings = chosen
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
let api = report.manifest.api;
let mut frame = NativeFrame::new(
device,
settings.as_ref(),
textures,
lights,
terrain.pbr,
api,
);
// `[displacement]`, unless its option is off; nothing to displace without heights.
let displacement = report
.manifest
@@ -222,8 +230,13 @@ impl NativePack {
)
})
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
// A 0.2 pack always has one: its decode and tonemap run without a chain too.
let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap);
let post = (!post.is_empty() || api == horizon_shader_api::Api::V0_2)
.then(|| {
let manifest = &report.manifest;
NativePost::new(device, post, manifest, constants, format, &frame, tonemap)
})
.transpose()?;
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
labels.extend(
@@ -7,11 +7,209 @@
//! (`NativeFrame`).
use glam::{Mat4, Vec3};
use horizon_shader_api::{Frame as FrameInputs, check::CheckedStage, stages::GROUP};
use horizon_shader_api::{
Api, Frame as FrameInputs, StageKind, check::CheckedStage, stages::GROUP,
};
use super::Frame;
const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
pub(super) const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// What the renderer's own entries call where a stage's colours cross into and out of it: as
/// they are under 0.1; under 0.2 vanilla's 2.2 power, as the prelude's `horizon_decode` and
/// `horizon_encode` (the shadow pass has no prelude, so these stand alone).
pub fn line_helpers(api: Api) -> &'static str {
match api {
Api::V0_1 => {
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> { return c; }
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> { return c; }
"
}
Api::V0_2 => {
"fn horizon_line_in(c: vec3<f32>) -> vec3<f32> {
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(2.2));
}
fn horizon_line_out(c: vec3<f32>) -> vec3<f32> {
return pow(select(vec3<f32>(0.0), c, c > vec3<f32>(0.0)), vec3<f32>(1.0 / 2.2));
}
"
}
}
}
/// 0.2's decoding copy: a picture in the frame's gamma into linear light in `INTERMEDIATE`, of
/// the same size, alpha 1 (`scene_color`, `opaque_color`, the mirror).
#[derive(Clone)]
pub struct Decoder(wgpu::RenderPipeline);
impl Decoder {
fn new(device: &wgpu::Device) -> Self {
let source = line_helpers(Api::V0_2).to_owned()
+ "@group(0) @binding(0) var horizon_from: texture_2d<f32>;
@vertex fn horizon_vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u)) * 2.0 - 1.0, 0.0, 1.0);
}
@fragment fn horizon_fs(@builtin(position) p: vec4<f32>) -> @location(0) vec4<f32> {
return vec4<f32>(horizon_line_in(textureLoad(horizon_from, vec2<i32>(p.xy), 0).rgb), 1.0);
}
";
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Shader API 0.2 decode"),
source: wgpu::ShaderSource::Wgsl(source.into()),
});
let entries = (None, "horizon_vs", "horizon_fs");
Self(fullscreen_pipeline(
device,
"Shader API 0.2 decode",
&module,
entries,
INTERMEDIATE,
&[],
))
}
/// Draws `from` decoded onto `to`.
pub fn run(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
from: &wgpu::TextureView,
to: &wgpu::TextureView,
) {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader API 0.2 decode"),
layout: &self.0.get_bind_group_layout(0),
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(from),
}],
});
fullscreen_pass(
encoder,
"Shader API 0.2 decode",
to,
&self.0,
(0, &group),
None,
);
}
}
/// A full-screen pipeline `label` of `module`'s entries `(vertex, fragment)`, unblended.
fn fullscreen_pipeline(
device: &wgpu::Device,
label: &str,
module: &wgpu::ShaderModule,
(layout, vertex, fragment): (Option<&wgpu::PipelineLayout>, &str, &str),
format: wgpu::TextureFormat,
constants: &[(&str, f64)],
) -> wgpu::RenderPipeline {
let options = wgpu::PipelineCompilationOptions {
constants,
..Default::default()
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout,
vertex: wgpu::VertexState {
module,
entry_point: Some(vertex),
compilation_options: options.clone(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module,
entry_point: Some(fragment),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
}
/// One full-screen triangle of `pipeline` onto `view`, cleared first.
fn fullscreen_pass(
encoder: &mut wgpu::CommandEncoder,
label: &str,
view: &wgpu::TextureView,
pipeline: &wgpu::RenderPipeline,
(index, group): (u32, &wgpu::BindGroup),
timing: Option<&mut super::pack_timing::PassTiming>,
) {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})],
timestamp_writes: timing.and_then(|t| t.writes(label)),
..Default::default()
});
pass.set_pipeline(pipeline);
pass.set_bind_group(index, group, &[]);
pass.draw(0..3, 0..1);
}
/// The tonemap stage's entries as the renderer draws them: the check's, with the result
/// encoded into the frame's gamma, where the camera overlays and the HUD draw on it.
fn tonemap_entries() -> String {
StageKind::Tonemap.entries(Api::V0_2).replace(
"return vec4<f32>(min(horizon_sanitize(out), vec3<f32>(frame.headroom)), 1.0);",
"return vec4<f32>(horizon_encode(min(horizon_sanitize(out), vec3<f32>(frame.headroom))), 1.0);",
)
}
/// Horizon's tonemap for a 0.2 pack without `[tonemap]`: SDR's clip at white, which is the
/// entries' hold at `frame.headroom` (1 until 0.2 packs get HDR output).
const DEFAULT_TONEMAP: &str =
"fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n";
/// A colour target of `INTERMEDIATE` and `size`, drawn and read.
fn intermediate(device: &wgpu::Device, label: &str, size: [u32; 2]) -> 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: INTERMEDIATE,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
}
/// A 0.2 pack's display (docs/api/shaders-reference-0.2.md): the world decoded into
/// `scene_color` (the first pass's `previous`), the chain ending in `picture`, and the tonemap
/// stage drawing that onto the screen.
struct Display {
decoder: Decoder,
/// The pack's `[tonemap]` label, `None` for Horizon's.
label: Option<String>,
tonemap: wgpu::RenderPipeline,
/// `scene_color` at the world's size; `picture` at the window's (none without a chain:
/// the tonemap reads `scene_color`).
scene: Option<([u32; 2], wgpu::TextureView)>,
picture: Option<([u32; 2], wgpu::TextureView)>,
}
/// The world's colour and depth views a set of bind groups was made for.
type WorldViews = (wgpu::TextureView, wgpu::TextureView, wgpu::TextureView);
@@ -98,6 +296,8 @@ pub struct NativePost {
named: Option<([u32; 2], Vec<wgpu::TextureView>)>,
/// The screen's colour format, the world target's too.
format: wgpu::TextureFormat,
/// Under 0.2: the decode, the picture and the tonemap.
display: Option<Display>,
}
/// The `frame` every stage of a pack reads (group 3, binding 0), written once a frame, and
@@ -145,6 +345,11 @@ pub struct NativeFrame {
frames: u32,
/// `frame.wetness`, and when it was last written.
wetness: (f32, Option<std::time::Instant>),
/// The shader API line of the pack, and under 0.2 the decoding copy and the mirror it
/// decoded (`linear_mirror`).
pub api: Api,
pub decoder: Option<Decoder>,
mirror: Option<wgpu::TextureView>,
}
/// A depth texture of `size` the world's depth is copied into.
@@ -179,7 +384,10 @@ fn copy_target(
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
// RENDER_ATTACHMENT: 0.2 draws `opaque_color` decoded rather than copying it.
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::RENDER_ATTACHMENT,
view_formats: &[],
})
}
@@ -225,6 +433,7 @@ impl NativeFrame {
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
lights: super::block_light::LightTable,
pbr: Option<&[wgpu::Texture; 2]>,
api: Api,
) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack frame"),
@@ -290,7 +499,7 @@ impl NativeFrame {
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let shadow_map = super::native_shadow::ShadowMap::new(device, shadow);
let shadow_map = super::native_shadow::ShadowMap::new(device, shadow, api);
let block_light = super::block_light::BlockLight::new(device, lights);
let array_view = |texture: &wgpu::Texture| {
texture.create_view(&wgpu::TextureViewDescriptor {
@@ -347,6 +556,9 @@ impl NativeFrame {
shadow_map,
shadow_camera: shadow.map(super::native_shadow::camera),
reflects: false,
api,
decoder: (api == Api::V0_2).then(|| Decoder::new(device)),
mirror: None,
last: None,
eye: [f32::NAN; 4],
history_reset: true,
@@ -460,6 +672,7 @@ impl NativeFrame {
) {
let size = [depth.width(), depth.height()];
let (color_size, format) = match color {
Some(c) if self.decoder.is_some() => ([c.width(), c.height()], INTERMEDIATE),
Some(c) => ([c.width(), c.height()], c.format()),
None => ([1, 1], self.opaque_color.format()),
};
@@ -478,6 +691,46 @@ impl NativeFrame {
self.regroup(device);
}
/// Fills `opaque_color` from the world's colour `color`: a copy, under 0.2 decoded.
pub fn copy_opaque(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
) {
match &self.decoder {
Some(d) => {
let to = self.opaque_color.create_view(&Default::default());
d.run(device, encoder, color, &to);
}
None => encoder.copy_texture_to_texture(
color.texture().as_image_copy(),
self.opaque_color.as_image_copy(),
color.texture().size(),
),
}
}
/// The water mirror `view` as the material reads it: under 0.2 decoded into a copy.
pub fn linear_mirror(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
view: wgpu::TextureView,
) -> wgpu::TextureView {
let Some(decoder) = &self.decoder else {
return view;
};
let size = [view.texture().width(), view.texture().height()];
let fits = |m: &wgpu::TextureView| [m.texture().width(), m.texture().height()] == size;
if !self.mirror.as_ref().is_some_and(fits) {
self.mirror = Some(intermediate(device, "Water mirror, linear", size));
}
let mirror = self.mirror.clone().unwrap();
decoder.run(device, encoder, &view, &mirror);
mirror
}
/// Sizes the surface target: the world's while a material stage writes it, else a blank.
pub fn fit_surface(&mut self, device: &wgpu::Device, size: [u32; 2]) {
if [self.surface.width(), self.surface.height()] != size {
@@ -543,6 +796,10 @@ impl NativeFrame {
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
let mut inputs = frame_inputs(frame, size, seconds, index, self.shadow_camera.as_ref());
inputs.wetness = self.wetness.0;
if self.api == Api::V0_2 {
inputs.sky_color = decoded(inputs.sky_color);
inputs.fog_color = decoded(inputs.fog_color);
}
if !self.reflects {
inputs.has_reflection = 0;
}
@@ -684,7 +941,8 @@ fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
impl NativePost {
/// Pipelines for `stages` (the pack's post passes, in order) with the option values
/// `constants`, drawing onto a screen of `format`. Errors name the pass wgpu refused.
/// `constants`, drawing onto a screen of `format`; under 0.2 the last onto `picture`, and
/// `tonemap` (else Horizon's) onto the screen. Errors name the pass wgpu refused.
pub fn new(
device: &wgpu::Device,
stages: Vec<CheckedStage>,
@@ -692,6 +950,7 @@ impl NativePost {
constants: &[(String, f64)],
format: wgpu::TextureFormat,
frame: &NativeFrame,
tonemap: Option<&CheckedStage>,
) -> Result<Self, String> {
let targets = manifest.post_targets();
let outputs: Vec<Option<usize>> = manifest
@@ -764,7 +1023,7 @@ impl NativePost {
entry_point: Some("horizon_fs_post"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: if plan[i].writes == Writes::Screen {
format: if plan[i].writes == Writes::Screen && frame.decoder.is_none() {
format
} else {
INTERMEDIATE
@@ -781,6 +1040,41 @@ impl NativePost {
}
passes.push((stage.label, pipeline));
}
let display = match &frame.decoder {
Some(decoder) => {
let source = tonemap.map_or_else(
|| StageKind::Tonemap.prelude(Api::V0_2) + DEFAULT_TONEMAP,
|s| s.source.clone(),
) + &tonemap_entries();
let file = tonemap.map_or("<tonemap>", |s| s.file.as_str());
let module = naga::front::wgsl::parse_str(&source)
.map_err(|e| format!("{file}: {}", e.emit_to_string(&source)))?;
let present = present_constants(&module, constants);
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Tonemap"),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let entries = (
Some(&pipeline_layout),
"horizon_vs_tonemap",
"horizon_fs_tonemap",
);
let pipeline =
fullscreen_pipeline(device, "Tonemap", &module, entries, format, &present);
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("{file}: {e}"));
}
Some(Display {
decoder: decoder.clone(),
label: tonemap.map(|s| s.label.clone()),
tonemap: pipeline,
scene: None,
picture: None,
})
}
None => None,
};
let sampler = |label, filter| {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(label),
@@ -811,12 +1105,18 @@ impl NativePost {
named: None,
plan,
format,
display,
})
}
/// The passes' labels, in order.
/// The passes' labels, in order, then the pack's tonemap's.
pub fn labels(&self) -> Vec<&str> {
self.passes.iter().map(|(l, _)| l.as_str()).collect()
let tonemap = self.display.as_ref().and_then(|d| d.label.as_deref());
self.passes
.iter()
.map(|(l, _)| l.as_str())
.chain(tonemap)
.collect()
}
fn prepare(
@@ -824,8 +1124,26 @@ impl NativePost {
device: &wgpu::Device,
world: (&wgpu::TextureView, &wgpu::TextureView, &wgpu::TextureView),
size: [u32; 2],
window: [u32; 2],
history_reset: bool,
) {
if let Some(d) = &mut self.display {
if d.scene.as_ref().is_none_or(|s| s.0 != size) {
d.scene = Some((
size,
intermediate(device, "Shader API 0.2 scene_color", size),
));
self.groups = None;
}
let chain = !self.passes.is_empty();
if chain && d.picture.as_ref().is_none_or(|p| p.0 != window) {
d.picture = Some((
window,
intermediate(device, "Shader API 0.2 picture", window),
));
self.groups = None;
}
}
if !self.scales.is_empty() && self.named.as_ref().is_none_or(|n| n.0 != size) {
let views = self
.scales
@@ -913,85 +1231,98 @@ impl NativePost {
}
.create_view(&Default::default());
let blank = self.history_blank.create_view(&Default::default());
let groups = (0..self.passes.len())
// Under 0.2 the passes read the world decoded.
let scene = self
.display
.as_ref()
.and_then(|d| d.scene.as_ref())
.map_or(world.0, |s| &s.1);
let group = |previous: &wgpu::TextureView, writes: Option<Writes>| {
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(scene),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(world.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
},
];
entries.extend(pack_texture_group_entries(&self.textures));
// The pack's targets, a blank one in place of the one this pass writes.
if let Some((_, views)) = &self.named {
entries.extend(views.iter().enumerate().map(|(j, view)| {
let view = if writes == Some(Writes::Target(j)) {
&blank
} else {
view
};
wgpu::BindGroupEntry {
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
resource: wgpu::BindingResource::TextureView(view),
}
}));
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack post"),
layout: &self.layout,
entries: &entries,
})
};
let mut groups: Vec<_> = (0..self.passes.len())
.map(|i| {
// The chain's picture so far: the world's before its first pass.
let previous = match (&self.targets, self.plan[i].reads) {
(Some((_, t)), Some(slot)) => &t[slot],
_ => world.0,
_ => scene,
};
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(world.0),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(world.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
},
];
entries.extend(pack_texture_group_entries(&self.textures));
// The pack's targets, a blank one in place of the one this pass writes.
if let Some((_, views)) = &self.named {
entries.extend(views.iter().enumerate().map(|(j, view)| {
let view = if self.plan[i].writes == Writes::Target(j) {
&blank
} else {
view
};
wgpu::BindGroupEntry {
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
resource: wgpu::BindingResource::TextureView(view),
}
}));
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack post"),
layout: &self.layout,
entries: &entries,
})
group(previous, Some(self.plan[i].writes))
})
.collect();
// The tonemap's, last: the chain's picture, else the decoded world.
if let Some(d) = &self.display {
groups.push(group(d.picture.as_ref().map_or(scene, |p| &p.1), None));
}
self.groups = Some((
(world.0.clone(), world.1.clone(), world.2.clone()),
blank_history,
@@ -1011,18 +1342,34 @@ impl NativePost {
history_reset: bool,
mut timing: Option<&mut super::pack_timing::PassTiming>,
) {
self.prepare(device, world, size, history_reset);
let window = [screen.texture().width(), screen.texture().height()];
self.prepare(device, world, size, window, history_reset);
if let Some(t) = timing.as_deref_mut() {
t.mark(device, encoder, (world.0, self.format));
}
let Some((_, _, groups)) = &self.groups else {
return;
};
if let Some(Display {
decoder,
scene: Some((_, scene)),
..
}) = &self.display
{
decoder.run(device, encoder, world.0, scene);
}
let last = match &self.display {
Some(Display {
picture: Some((_, picture)),
..
}) => picture,
_ => screen,
};
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
let view = match (self.plan[i].writes, &self.targets, &self.named) {
(Writes::Chain(slot), Some((_, t)), _) => &t[slot],
(Writes::Target(j), _, Some((_, n))) => &n[j],
_ => screen,
_ => last,
};
let attachments = [Some(wgpu::RenderPassColorAttachment {
view,
@@ -1043,9 +1390,13 @@ impl NativePost {
pass.set_bind_group(GROUP, group, &[]);
pass.draw(0..3, 0..1);
}
if let (Some(d), Some(group)) = (&self.display, groups.last()) {
let label = d.label.as_deref().unwrap_or("Tonemap");
fullscreen_pass(encoder, label, screen, &d.tonemap, (GROUP, group), timing);
}
// The chain's last picture becomes next frame's `history`, once every pass has read
// this frame's.
let before_last = &self.plan[..self.plan.len() - 1];
let before_last = &self.plan[..self.plan.len().saturating_sub(1)];
if let Some(Writes::Chain(slot)) = before_last
.iter()
.rfind(|p| matches!(p.writes, Writes::Chain(_)))
@@ -1080,6 +1431,11 @@ fn history_target(device: &wgpu::Device, label: &str, size: [u32; 2]) -> wgpu::T
})
}
/// Vanilla's gamma colour `c` in linear light (0.2's decode; negatives and NaN made 0).
fn decoded(c: [f32; 3]) -> [f32; 3] {
c.map(|v| if v > 0.0 { v.powf(2.2) } else { 0.0 })
}
/// `wetness` after `dt` seconds of `rain`: halfway there in 30 seconds while it rains
/// harder, in 10 while it dries (the half-lives of the `shaders/` format's `wetness`).
fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
@@ -1342,7 +1698,14 @@ mod tests {
};
let hud = crate::hud::HudBatch::new([4, 4]);
let mut frame = crate::gfx::pack_uniforms::initial_history_tests::frame(&hud);
let mut native = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let mut native = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_1,
);
frame.pack.still_ticks = Some(47_811);
frame.pack.eye_light = [0, 15];
frame.pack.rain = 1.0;
@@ -1418,7 +1781,14 @@ mod tests {
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_1,
);
queue.write_buffer(
&frame.buffer,
0,
@@ -1431,6 +1801,7 @@ mod tests {
&[("gain".into(), 1.0)],
format,
&frame,
None,
)
.unwrap();
assert_eq!(post.labels(), ["a.wgsl", "b.wgsl"]);
@@ -1538,7 +1909,14 @@ file = \"b.wgsl\"
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_1,
);
let mut post = NativePost::new(
&device,
report.stages,
@@ -1546,6 +1924,7 @@ file = \"b.wgsl\"
&[],
format,
&frame,
None,
)
.unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
@@ -1650,7 +2029,14 @@ file = \"b.wgsl\"
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_1,
);
let mut post = NativePost::new(
&device,
report.stages,
@@ -1658,6 +2044,7 @@ file = \"b.wgsl\"
&[],
format,
&frame,
None,
)
.unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
@@ -1929,4 +2316,126 @@ file = \"b.wgsl\"
);
}
}
/// Shader API 0.2: the world decoded, through a chain that passes it on or none, and
/// Horizon's tonemap or the pack's identity one, comes back encoded as it went in (within
/// an 8-bit step), at alpha 1.
#[test]
fn a_0_2_world_comes_back_through_decode_tonemap_and_encode() {
assert!(tonemap_entries().contains("horizon_encode(min(horizon_sanitize(out)"));
let Some((device, queue)) = device() else {
return;
};
let post = "[[post]]\nfile = \"a.wgsl\"\n[[post]]\nfile = \"b.wgsl\"\n";
let curve = "[tonemap]\nfile = \"t.wgsl\"\n";
// A pack needs a stage: the first has only a sky, and no chain or tonemap.
let sky = "[sky]\nfile = \"s.wgsl\"\n";
for extra in [
sky.into(),
post.into(),
curve.into(),
format!("{post}{curve}"),
] {
let manifest = format!(
"schema = 1\nid = \"t\"\nname = \"T\"\nversion = \"1\"\napi = \"0.2\"\n{extra}"
);
let pass = "fn post(input: PostInput) -> vec4<f32> { return input.color; }\n";
let identity = "fn tonemap(input: TonemapInput) -> vec3<f32> { return input.color; }\n";
let mut files = vec![(MANIFEST, manifest.as_str())];
if extra.contains("[[post]]") {
files.extend([("a.wgsl", pass), ("b.wgsl", pass)]);
}
if extra.contains("[tonemap]") {
files.push(("t.wgsl", identity));
}
if extra.contains("[sky]") {
files.push((
"s.wgsl",
"fn sky(input: SkyInput) -> vec4<f32> { return input.color; }\n",
));
}
let files = files
.into_iter()
.map(|(n, t)| (n.to_owned(), t.as_bytes().to_vec()))
.collect();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let (passes, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
.partition(|s| s.kind == StageKind::Post);
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
Api::V0_2,
);
let inputs = FrameInputs {
headroom: 1.0,
..Default::default()
};
queue.write_buffer(&frame.buffer, 0, bytemuck::bytes_of(&inputs));
let tonemap = others.iter().find(|s| s.kind == StageKind::Tonemap);
let mut post = NativePost::new(
&device,
passes,
&report.manifest,
&[],
format,
&frame,
tonemap,
)
.unwrap();
assert_eq!(post.labels().last() == Some(&"t.wgsl"), tonemap.is_some());
let usage =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
.create_view(&Default::default());
let screen = texture(
&device,
format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
);
let mut encoder = device.create_command_encoder(&Default::default());
let clear = wgpu::Color {
r: 0.8,
g: 0.4,
b: 0.2,
a: 0.5,
};
drop(encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &color,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(clear),
store: wgpu::StoreOp::Store,
},
})],
..Default::default()
}));
let view = screen.create_view(&Default::default());
let world = (&color, &depth, &frame.surface_view);
post.run(&device, &mut encoder, world, &view, [4, 4], true, None);
let buffer = crate::gfx::copy_frame(&device, &mut encoder, &screen, [4, 4]);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let pixel = buffer.slice(..).get_mapped_range().unwrap()[..4].to_vec();
let want = [204, 102, 51, 255];
assert!(
pixel
.iter()
.zip(want)
.all(|(&got, want)| got.abs_diff(want) <= 1),
"{extra}: {pixel:?}"
);
}
}
}
@@ -6,7 +6,7 @@
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
@@ -75,7 +75,7 @@ fn horizon_shadow_cutout(in: HorizonShadowOut) {
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
if color.a < 0.01 { discard; }
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
return vec4(horizon_line_in(color.rgb * in.tint), select(min(color.a, 0.99), 1.0, in.fluid != 0u));
}
";
@@ -152,8 +152,13 @@ pub struct ShadowGrid<'a> {
pub normals: &'a wgpu::TextureView,
}
/// `shadow_color`'s format.
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// `shadow_color`'s format: under 0.2 it holds linear light, which 8 bits would band.
fn shadow_color(api: Api) -> wgpu::TextureFormat {
match api {
Api::V0_1 => wgpu::TextureFormat::Rgba8Unorm,
Api::V0_2 => super::native_post::INTERMEDIATE,
}
}
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
@@ -218,7 +223,7 @@ pub struct ShadowMap {
}
impl ShadowMap {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>, api: Api) -> Self {
let size = settings.map_or(1, |s| s.resolution);
let texture = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
@@ -250,7 +255,7 @@ impl ShadowMap {
);
let color = texture(
"Native shader pack shadow colour",
SHADOW_COLOR,
shadow_color(api),
wgpu::TextureUsages::empty(),
);
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
@@ -474,6 +479,7 @@ fn shader_source(
vertex: Option<&str>,
materials: &[(u32, u32)],
grid: Option<&str>,
api: Api,
) -> String {
let table = super::native_material::layer_table("horizon_sprite_material", materials);
let (head, displace) = match (grid, vertex) {
@@ -504,11 +510,12 @@ fn shader_source(
),
};
format!(
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}",
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{}{displace}{ENTRIES}",
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX
)
),
super::native_post::line_helpers(api)
)
}
@@ -527,6 +534,7 @@ impl NativeShadow {
vertex.map(|s| s.source.as_str()),
shape.sprite_materials,
grid.as_ref().map(|g| g.material.source.as_str()),
frame.api,
);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
@@ -613,7 +621,7 @@ impl NativeShadow {
attributes: &instance_attributes,
};
let color_target = [Some(wgpu::ColorTargetState {
format: SHADOW_COLOR,
format: shadow_color(frame.api),
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})];
@@ -731,8 +739,9 @@ mod tests {
#[test]
fn the_shadow_shader_validates() {
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None);
let lines = [Api::V0_1, Api::V0_2];
for (materials, api) in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]].iter().zip(lines) {
let text = shader_source(&shadow_solid(materials, &[7]), None, materials, None, api);
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
@@ -767,6 +776,7 @@ mod tests {
None,
&materials,
Some(&stage.source),
Api::V0_1,
);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
@@ -17,7 +17,8 @@ const PREFIX: &str = "horizon_sky_";
const PACK_ENTRY: &str = "
@fragment fn horizon_sky_fs_pack(in: horizon_sky_SkyOut) -> @location(0) vec4<f32> {
let d = horizon_sky_view_ray(in.ndc);
return vec4(sky(SkyInput(d, vec4(horizon_sky_sky_along_ray(d), 1.0))).rgb, 1.0);
let color = vec4(horizon_line_in(horizon_sky_sky_along_ray(d)), 1.0);
return vec4(horizon_line_out(sky(SkyInput(d, color)).rgb), 1.0);
}
";
@@ -38,10 +39,11 @@ fn vanilla_sky() -> String {
}
/// The sky shader for the pack's sky stage `source`.
fn shader_source(source: &str) -> String {
fn shader_source(source: &str, api: horizon_shader_api::Api) -> String {
format!(
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{PACK_ENTRY}",
vanilla_sky()
"{source}\n// Horizon's sky (terrain.wgsl)\n{}{}{PACK_ENTRY}",
vanilla_sky(),
super::native_post::line_helpers(api)
)
}
@@ -99,7 +101,7 @@ fn build(
frame: &NativeFrame,
stage: &CheckedStage,
) -> Result<(naga::Module, wgpu::PipelineLayout), String> {
let text = shader_source(&stage.source);
let text = shader_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
@@ -195,7 +197,7 @@ mod tests {
// A pack may use the renderer's names for its own things.
let own = "override gain: f32;\nconst PI: f32 = 3.0;\nfn view_ray() -> f32 { return PI; }\n\
fn sky(input: SkyInput) -> vec4<f32> { return input.color * gain * view_ray(); }\n";
let text = shader_source(&stage(own).source);
let text = shader_source(&stage(own).source, horizon_shader_api::Api::V0_1);
let module = naga::front::wgsl::parse_str(&text).unwrap_or_else(|e| {
panic!("{}", e.emit_to_string(&text));
});
@@ -252,7 +254,14 @@ mod tests {
resource: globals_buffer.as_entire_binding(),
}],
});
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let frame = NativeFrame::new(
&device,
None,
Vec::new(),
Default::default(),
None,
horizon_shader_api::Api::V0_1,
);
queue.write_buffer(
&frame.buffer,
0,
@@ -32,13 +32,28 @@ pub fn buffers() -> [Option<wgpu::VertexBufferLayout<'static>>; 1] {
/// normal. The quad's corners, read from the same vertices at set 1, give the face normal where
/// the vertex has none (block entities) and `at_tangent` along the texture's u, as for terrain.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
let words = std::mem::size_of::<Vertex>() / 4;
let mut declarations = "layout(location = 0) in vec3 hz_v_pos;\nlayout(location = 1) in vec2 hz_v_uv;\n\
layout(location = 2) in uint hz_v_overlay;\nlayout(location = 3) in vec4 hz_v_color;\n\
layout(location = 4) in uvec4 hz_v_light;\nlayout(location = 5) in vec4 hz_v_normal;\n\
layout(set = 1, binding = 0) readonly buffer HzEntityVertices { uint hz_entity_vertices[]; };\n\
vec4 hz_tangent; vec2 hz_mid;\n"
.to_owned();
let words = std::mem::size_of::<Vertex>() / 4;
// `at_velocity`: the vertex's offset from last frame in view space, this frame's view
// position less last frame's. Last frame's vertices stay at binding 2, relative to last
// frame's camera, and binding 3 gives each vertex its index there (none: no velocity).
declarations.push_str(&format!(
"layout(set = 1, binding = 2) readonly buffer HzEntityPrevious {{ uint hz_entity_previous[]; }};\n\
layout(set = 1, binding = 3) readonly buffer HzEntityPreviousIndex {{ uint hz_previous_index[]; }};\n\
uint hz_vi;\n\
vec3 hz_velocity(mat4 view, mat4 previous_view) {{\n \
uint p = hz_previous_index[hz_vi];\n \
if (p == 0xffffffffu) return vec3(0.0);\n \
uint w = p * {words}u;\n \
vec3 then = uintBitsToFloat(uvec3(hz_entity_previous[w], hz_entity_previous[w + 1u], hz_entity_previous[w + 2u]));\n \
return (view * vec4(hz_v_pos, 1.0)).xyz - (previous_view * vec4(then, 1.0)).xyz;\n\
}}\n"
));
let corner = |k: usize, field: &str| {
let at = |o: usize| format!("hz_entity_vertices[hz_q + {}u]", k * words + o);
match field {
@@ -47,7 +62,8 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
}
};
let body = format!(
" uint hz_q = (uint(gl_VertexIndex) & ~3u) * {words}u;\n \
" hz_vi = uint(gl_VertexIndex);\n \
uint hz_q = (uint(gl_VertexIndex) & ~3u) * {words}u;\n \
vec3 hz_e1 = {p1} - {p0};\n \
vec3 hz_e2 = {p2} - {p0};\n \
vec2 hz_t0 = {t0};\n \
@@ -87,6 +103,10 @@ pub fn inputs() -> horizon_shaders::GbuffersInputs {
("vaUV2", "ivec2(hz_MultiTexCoord1.xy)"),
("vaColor", "hz_Color"),
("vaNormal", "hz_Normal"),
(
"at_velocity",
"hz_velocity(gbufferModelView, gbufferPreviousModelView)",
),
];
horizon_shaders::GbuffersInputs {
declarations,
@@ -229,7 +249,15 @@ impl Casters {
/// The vertices and per-draw uniforms of the runs a pack draws, rebuilt each frame.
pub(super) struct PackEntities {
vertices: wgpu::Buffer,
/// Set 1: the vertices again, for the quads' corners.
/// Last frame's `vertices` (the two swap each frame), for `at_velocity`.
previous: wgpu::Buffer,
/// For each of `vertices`, its index in `previous` (`NO_PREVIOUS`: none).
previous_index: wgpu::Buffer,
/// This frame's entities (`Frame::entity_motion`) and last frame's.
motion: Motion,
previous_motion: Motion,
index_scratch: Vec<u32>,
/// Set 1: the vertices again, for the quads' corners; last frame's and the indices into them.
pub table: wgpu::BindGroupLayout,
pub table_group: wgpu::BindGroup,
slots: wgpu::Buffer,
@@ -248,23 +276,32 @@ impl PackEntities {
let stride = SLOT.max(u64::from(
device.limits().min_uniform_buffer_offset_alignment,
));
let entry = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let table = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack entity vertices"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
entries: &[entry(0), entry(2), entry(3)],
});
let (vertices, table_group) = Self::vertex_buffer(device, &table, 4096);
let vertices = Self::vertex_buffer(device, 4096);
let previous = Self::vertex_buffer(device, 4096);
let previous_index = Self::index_buffer(device, 4096);
let table_group = Self::table_group(device, &table, &vertices, &previous, &previous_index);
let (slots, group) = Self::slot_buffer(device, layout, 64, stride);
Self {
vertices,
previous,
previous_index,
motion: Motion::default(),
previous_motion: Motion::default(),
index_scratch: Vec::new(),
table,
table_group,
slots,
@@ -278,28 +315,51 @@ impl PackEntities {
}
}
fn vertex_buffer(
device: &wgpu::Device,
table: &wgpu::BindGroupLayout,
vertices: u64,
) -> (wgpu::Buffer, wgpu::BindGroup) {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
fn vertex_buffer(device: &wgpu::Device, vertices: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack entity vertices"),
size: vertices * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX
| wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
})
}
fn index_buffer(device: &wgpu::Device, vertices: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack entity previous indices"),
size: vertices * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
fn table_group(
device: &wgpu::Device,
table: &wgpu::BindGroupLayout,
vertices: &wgpu::Buffer,
previous: &wgpu::Buffer,
index: &wgpu::Buffer,
) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack entity vertices"),
layout: table,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
(buffer, group)
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: vertices.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: previous.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: index.as_entire_binding(),
},
],
})
}
fn slot_buffer(
@@ -359,11 +419,17 @@ impl PackEntities {
shadow: Casters,
rest: &mut Vec<Vertex>,
skin: Option<&super::entity_skin::SkinFrame>,
motion: (&[(u32, u64)], &[u32]),
) {
self.draws.clear();
self.scratch.clear();
self.uniforms.clear();
rest.clear();
// Last frame's vertices stay for `at_velocity`; this frame's go to the other buffer.
std::mem::swap(&mut self.vertices, &mut self.previous);
std::mem::swap(&mut self.motion, &mut self.previous_motion);
self.motion.clear();
let mut copied = Vec::new();
let posed = skin.map_or(0, |s| s.vertices);
if let Some(skin) = skin {
for (span, range, overlay) in skin_runs(skin) {
@@ -389,6 +455,7 @@ impl PackEntities {
extend_without_portals(rest, &entities[..first.min(entities.len())]);
let (mut other, mut portal_faces, mut beam_faces) = (Vec::new(), Vec::new(), Vec::new());
for (span, range) in runs(entities.len(), spans) {
let run_start = range.start as u32;
let whole = &entities[range];
// A block entity's run can hold End portal faces and beams beside plain parts (an End
// gateway: its cube and its beam): each goes to its own program.
@@ -444,6 +511,10 @@ impl PackEntities {
Part::Beam => beam_segments(run, &mut self.scratch),
_ => self.scratch.extend_from_slice(run),
}
// A run copied whole: its vertices keep their order, for `at_velocity`.
if !split {
copied.push((run_start, posed + start, run.len() as u32));
}
if program == DESTROY {
project_cracks(&mut self.scratch[start as usize..], crack.0, crack.1);
}
@@ -468,6 +539,14 @@ impl PackEntities {
if self.draws.is_empty() && posed == 0 {
return;
}
let total = posed + self.scratch.len() as u32;
let posed_marks = if skin.is_some() { motion.1 } else { &[] };
self.motion
.collect(motion.0, posed_marks, entities.len() as u32, posed, &copied);
self.index_scratch.clear();
self.index_scratch.resize(total as usize, NO_PREVIOUS);
self.motion
.link(&self.previous_motion, &mut self.index_scratch);
let vertex_size = std::mem::size_of::<Vertex>() as u64;
let (need_vertices, need_slots) = (
u64::from(posed) + self.scratch.len() as u64,
@@ -476,9 +555,23 @@ impl PackEntities {
let have_vertices = self.vertices.size() / vertex_size;
let have_slots = self.slots.size() / self.stride;
if need_vertices > have_vertices {
(self.vertices, self.table_group) =
Self::vertex_buffer(device, &self.table, need_vertices.next_power_of_two());
self.vertices = Self::vertex_buffer(device, need_vertices.next_power_of_two());
}
if need_vertices * 4 > self.previous_index.size() {
self.previous_index = Self::index_buffer(device, need_vertices.next_power_of_two());
}
queue.write_buffer(
&self.previous_index,
0,
bytemuck::cast_slice(&self.index_scratch),
);
self.table_group = Self::table_group(
device,
&self.table,
&self.vertices,
&self.previous,
&self.previous_index,
);
if need_slots > have_slots {
(self.slots, self.group) =
Self::slot_buffer(device, layout, need_slots.next_power_of_two(), self.stride);
@@ -670,6 +763,87 @@ fn drawn_by_pack(span: &EntitySpan, drawn: [bool; PROGRAMS]) -> Option<usize> {
}
/// The kind of program that draws `span`'s run, when a pack may.
/// A vertex with no copy last frame: `at_velocity` reads zero.
const NO_PREVIOUS: u32 = u32::MAX;
/// Where each entity's vertices are in a frame's pack buffer, to match them with last frame's
/// (`at_velocity`): the indices of each one's GPU-posed vertices, then of its CPU ones.
#[derive(Default)]
struct Motion {
/// Entity id (`Frame::entity_motion`) → (first index in `indices`, posed count, CPU count).
entities: std::collections::HashMap<u64, (u32, u32, u32)>,
indices: Vec<u32>,
}
impl Motion {
fn clear(&mut self) {
self.entities.clear();
self.indices.clear();
}
/// Records each marked entity (`marks`: first CPU vertex and id, each run up to the next;
/// `posed_marks`: the posed count at each mark, empty without posing). A CPU vertex is in
/// the pack buffer where `copied` (first vertex in `Frame::entities`, first index in the
/// buffer, count, in order) has it, else `NO_PREVIOUS`.
fn collect(
&mut self,
marks: &[(u32, u64)],
posed_marks: &[u32],
cpu_total: u32,
posed_total: u32,
copied: &[(u32, u32, u32)],
) {
let at = |e: u32| {
let i = copied.partition_point(|c| c.0 <= e);
match i.checked_sub(1).map(|i| copied[i]) {
Some((first, index, n)) if e < first + n => index + (e - first),
_ => NO_PREVIOUS,
}
};
for (i, &(cpu, id)) in marks.iter().enumerate() {
if id == 0 {
continue;
}
let cpu_end = marks.get(i + 1).map_or(cpu_total, |m| m.0).max(cpu);
let posed = posed_marks.get(i).copied().unwrap_or(0).min(posed_total);
let posed_end = posed_marks
.get(i + 1)
.copied()
.unwrap_or(posed_total)
.clamp(posed, posed_total);
let first = self.indices.len() as u32;
self.indices.extend(posed..posed_end);
self.indices.extend((cpu..cpu_end).map(at));
self.entities
.insert(id, (first, posed_end - posed, cpu_end - cpu));
}
}
/// For each entity drawn last frame with the same posed and CPU counts, each vertex's index
/// last frame, written into `out` (indexed by this frame's buffer).
fn link(&self, previous: &Motion, out: &mut [u32]) {
for (id, &(first, posed, cpu)) in &self.entities {
let Some(&(then, p, c)) = previous.entities.get(id) else {
continue;
};
if (p, c) != (posed, cpu) {
continue;
}
let n = (posed + cpu) as usize;
let now = &self.indices[first as usize..][..n];
let before = &previous.indices[then as usize..][..n];
for (&now, &before) in now.iter().zip(before) {
if now != NO_PREVIOUS
&& before != NO_PREVIOUS
&& let Some(slot) = out.get_mut(now as usize)
{
*slot = before;
}
}
}
}
}
pub fn program_of(span: &EntitySpan) -> Option<usize> {
Some(if span.stage == render_stage("ENTITIES") {
if span.translucent {
@@ -1079,6 +1253,7 @@ mod gpu_span_tests {
},
&mut rest,
None,
(&[], &[]),
);
assert_eq!(
(geometry.draws.iter())
@@ -1160,6 +1335,7 @@ mod gpu_span_tests {
shadows,
&mut rest,
Some(&skin),
(&[], &[]),
);
assert_eq!(rest.len(), 4, "untagged CPU prefix stays in Horizon's draw");
assert_eq!(geometry.scratch.len(), 4, "only CPU geometry is uploaded");
@@ -1203,6 +1379,7 @@ mod gpu_span_tests {
shadows,
&mut rest,
None,
(&[], &[]),
);
assert!(geometry.draws.is_empty());
assert_eq!(
@@ -1230,8 +1407,125 @@ mod gpu_span_tests {
Casters::default(),
&mut rest,
Some(&skin),
(&[], &[]),
);
assert!(geometry.draws.is_empty());
assert!(geometry.skin_target().size() >= 5000 * 44);
}
}
#[cfg(test)]
mod velocity_tests {
use super::{Motion, NO_PREVIOUS};
/// Two mobs (ids 7 and 9) and a run of unmarked geometry (id 0); `copied` places CPU
/// vertices `first..first + n` at buffer index `at`.
fn frame(order: [(u64, u32); 2], copied: &[(u32, u32, u32)]) -> Motion {
let mut marks = Vec::new();
let mut cpu = 0;
for (id, n) in order {
marks.push((cpu, id));
cpu += n;
}
marks.push((cpu, 0));
let mut m = Motion::default();
m.collect(&marks, &[], cpu + 2, 0, copied);
m
}
/// The same mob's vertices find themselves however the frame's order and the buffer's
/// layout moved between frames; geometry of no entity, and vertices not in the buffer,
/// find nothing.
#[test]
fn each_vertex_finds_its_copy_from_last_frame() {
// Last frame: mob 7 (4 vertices) then mob 9 (2), copied whole from buffer index 10.
let before = frame([(7, 4), (9, 2)], &[(0, 10, 8)]);
// This frame: mob 9 first, then mob 7, at buffer index 0; 9's last vertex not drawn.
let now = frame([(9, 2), (7, 4)], &[(0, 0, 1), (2, 1, 6)]);
let mut out = vec![NO_PREVIOUS; 8];
now.link(&before, &mut out);
// 9's first vertex (buffer 0) was 10 + 4; 7's four (buffer 1..5) were 10..14.
assert_eq!(
out,
[14, 10, 11, 12, 13, NO_PREVIOUS, NO_PREVIOUS, NO_PREVIOUS]
);
}
/// A mob whose vertex count changed (armor put on, an item picked up) gets no velocity
/// this frame rather than the wrong vertices'.
#[test]
fn a_changed_model_gets_none() {
let before = frame([(7, 4), (9, 2)], &[(0, 0, 8)]);
let now = frame([(7, 6), (9, 2)], &[(0, 0, 10)]);
let mut out = vec![NO_PREVIOUS; 10];
now.link(&before, &mut out);
assert!(out[..6].iter().all(|&i| i == NO_PREVIOUS));
assert_eq!(out[6..8], [4, 5]);
}
/// GPU-posed vertices come first in the buffer: each mob's posed part is matched by its
/// own posed count, then its CPU part.
#[test]
fn posed_and_cpu_parts_match_separately() {
let marks = [(0, 7), (2, 9), (2, 0)];
let mut before = Motion::default();
// Mob 7: 3 posed (0..3) and 2 CPU; mob 9: 1 posed (3), no CPU.
before.collect(&marks, &[0, 3, 4], 2, 4, &[(0, 4, 2)]);
let mut now = Motion::default();
// Mob 9 posed first this frame (0), then mob 7 (1..4).
now.collect(&[(0, 9), (0, 7), (2, 0)], &[0, 1, 4], 2, 4, &[(0, 4, 2)]);
let mut out = vec![NO_PREVIOUS; 6];
now.link(&before, &mut out);
assert_eq!(out, [3, 0, 1, 2, 4, 5]);
}
}
#[cfg(test)]
mod velocity_translate_tests {
/// A pack that reads `at_velocity` translates with the entity inputs (which read the
/// previous view and last frame's vertices) and with the hand's (which read zero).
#[test]
fn a_pack_reading_at_velocity_translates_for_entities_and_the_hand() {
let dir = std::env::temp_dir().join(format!("hz-at-velocity-{}", std::process::id()));
let shaders = dir.join("shaders");
std::fs::create_dir_all(&shaders).unwrap();
std::fs::write(
shaders.join("gbuffers_entities.vsh"),
"#version 120\nattribute vec3 at_velocity;\nvarying vec3 v;\n\
void main() {\n gl_Position = ftransform();\n v = at_velocity;\n}\n",
)
.unwrap();
std::fs::write(
shaders.join("gbuffers_entities.fsh"),
"#version 120\nvarying vec3 v;\nvoid main() {\n gl_FragData[0] = vec4(v, 1.0);\n}\n",
)
.unwrap();
let pack = horizon_shaders::Pack::open(&dir).unwrap();
let m = horizon_shaders::Macros::standard();
let vsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.vsh", &m);
let fsh = horizon_shaders::preprocess(&pack, "gbuffers_entities.fsh", &m);
for (who, inputs) in [
("entities", super::inputs()),
("hand", super::super::pack_hand::inputs()),
] {
let p = horizon_shaders::translate_gbuffers(
&vsh,
&fsh,
horizon_shaders::Target::Buffers,
&inputs,
)
.unwrap_or_else(|e| panic!("{who}: {e:?}"));
let previous = p
.interface
.uniforms
.iter()
.any(|u| u.name == "gbufferPreviousModelView");
assert_eq!(
previous,
who == "entities",
"{who}: previous view in the block"
);
}
let _ = std::fs::remove_dir_all(&dir);
}
}
@@ -19,6 +19,13 @@ const SLOT: u64 = 32;
/// What `gbuffers_hand` reads: the entity inputs, and the hand's projection from set 1.
pub fn inputs() -> horizon_shaders::GbuffersInputs {
let mut inputs = super::pack_entities::inputs();
// No last frame's hand to measure from: the hand's `at_velocity` reads zero (and its set 1
// has no previous vertices).
for (name, value) in &mut inputs.attributes {
if name == "at_velocity" {
*value = "vec3(0.0)".to_owned();
}
}
// Iris 1.11.7 declares `currentRenderedItemId` in the hand's programs (observed).
inputs.item_id_declared = true;
inputs.declarations.push_str(
@@ -43,6 +43,9 @@ mod higher_shadowcolor_tests;
#[cfg(test)]
mod single_side_tests;
#[cfg(test)]
mod mixed_size_tests;
#[cfg(test)]
mod sky_disc_blend_tests;
@@ -2376,7 +2379,16 @@ impl PackPost {
} else {
written.next()
};
if !in_shadow && group != Group::Final && written.any(|s| Some(s) != first) {
// Sizes of one kind that differ never meet. A size relative to the render size and
// one in pixels can: the pass then draws while they resolve to one size
// (`PackPost::sizes_differ`). Iris 1.11.7 drew a composite program writing
// colortex2 at `0.25 0.25` and colortex3 at `320 180` at 1280×720 (#10454).
let apart = |s: horizon_shaders::BufferSize| {
first.is_some_and(|f| {
f != s && (depth || std::mem::discriminant(&f) == std::mem::discriminant(&s))
})
};
if !in_shadow && group != Group::Final && written.any(apart) {
notes.push(format!(
"{}: writes buffers of different sizes (size.buffer); skipped",
pass.name
@@ -3853,6 +3865,7 @@ impl PackPost {
portal: [f32; 4],
rest: &mut Vec<super::Vertex>,
skin: Option<&super::entity_skin::SkinFrame>,
motion: (&[(u32, u64)], &[u32]),
) {
self.portal_rect = portal;
let shadow = self.shadow_casters();
@@ -3873,6 +3886,7 @@ impl PackPost {
shadow,
rest,
skin,
motion,
);
}
@@ -4286,6 +4300,18 @@ impl PackPost {
})
}
/// Whether the buffers `pass` writes have different sizes at the current render size: then
/// it is skipped, as one whose `size.buffer` sizes never meet is when the pack loads. Only
/// a pass writing buffers sized relative to the render size and in pixels can.
fn sizes_differ(&self, pass: &Pass) -> bool {
let Some(targets) = &self.targets else {
return false;
};
let mut sizes = pass.writes.iter().map(|&(_, b)| targets.sizes[b]);
let first = sizes.next();
!pass.group.shadow() && pass.group != Group::Final && sizes.any(|s| Some(s) != first)
}
/// Whether the pack draws terrain itself, or has nothing draw it.
pub fn has_terrain(&self) -> bool {
self.terrain.is_some() || self.terrain_cutout.is_some() || self.terrain_undrawn
@@ -4527,6 +4553,14 @@ impl PackPost {
mips,
});
self.forget_groups();
for pass in self.passes.iter().filter(|p| self.sizes_differ(p)) {
log::info!(
"shader pack: {}: writes buffers of different sizes at {}x{} (size.buffer); skipped",
pass.name,
size[0],
size[1]
);
}
}
/// The shadow pass (2.1, passes 2 and 3): the shadow buffers cleared, then the `shadowcomp`
@@ -5073,6 +5107,10 @@ impl PackPost {
terrain_drawn = true;
self.draw_opaque(device, queue, encoder, size, frame, terrain);
}
// As if the pack didn't have it: no compute stages, flips or stage changes.
if self.sizes_differ(pass) {
continue;
}
if !water_drawn && matches!(pass.group, Group::Composite | Group::Final) {
water_drawn = true;
self.draw_translucent(device, queue, encoder, size, terrain);
@@ -613,6 +613,7 @@ pub(super) fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
entity_motion_posed: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
@@ -451,6 +451,7 @@ fn fixture_frame(hud: &HudBatch) -> Frame<'_> {
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
entity_motion_posed: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
@@ -0,0 +1,89 @@
//! A composite program writing two buffers whose `size.buffer` lines declare one relative to the
//! render size and one in pixels (#10454). Iris 1.11.7 drew such a program where both came to
//! the same size (our probe pack: `0.25 0.25` and `320 180` at 1280×720). The programs here are
//! original and cut down.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
const VERTEX: &str = "#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n";
/// `composite` writes (0.25, 0.5, 0.75) into colortex2, sized `0.25 0.25`, and colortex3, sized
/// `colortex3` (`size.buffer` words); `final` shows colortex2's red and green and colortex3's
/// blue.
fn pack(colortex3: &str) -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
add(
"shaders.properties",
&format!("size.buffer.colortex2 = 0.25 0.25\nsize.buffer.colortex3 = {colortex3}\n"),
);
add("composite.vsh", VERTEX);
add(
"composite.fsh",
"#version 120\n/* RENDERTARGETS: 2,3 */\nvoid main() {\n\
gl_FragData[0] = vec4(0.25, 0.5, 0.75, 1.0);\ngl_FragData[1] = vec4(0.25, 0.5, 0.75, 1.0);\n}\n",
);
add("final.vsh", VERTEX);
add(
"final.fsh",
"#version 120\nuniform sampler2D colortex2;\nuniform sampler2D colortex3;\nvarying vec2 uv;\n\
void main() {\ngl_FragColor = vec4(texture2D(colortex2, uv).rg, texture2D(colortex3, uv).b, 1.0);\n}\n",
);
let pack =
horizon_shaders::Pack::from_zip_bytes("mixed-sizes", zip.finish().unwrap().into_inner())
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
horizon_shaders::load_post(&pack, "", &properties, &macros, &Default::default())
}
/// Every pixel's colour, with how many pixels differ from it by more than one step.
fn one_colour(pixels: &[[u8; 4]]) -> ([u8; 4], usize) {
let first = pixels[0];
let off = pixels
.iter()
.filter(|p| p.iter().zip(first).any(|(a, b)| a.abs_diff(b) > 1))
.count();
(first, off)
}
/// At 16×16 both buffers are 4×4: the program draws. At 32×32 colortex2 is 8×8 and colortex3
/// still 4×4: the program is skipped there, as one whose sizes never meet is (Iris refused
/// the whole pack in that case; Horizon skips only the program).
#[test]
fn a_program_writing_buffers_that_come_to_one_size_draws() {
let Some(gpu) = Fixture::new() else { return };
let mut post = gpu.build(pack("4 4"));
let (colour, off) = one_colour(&gpu.render(&mut post, [16, 16]));
assert_eq!(off, 0, "{colour:?}");
for (got, want) in colour.iter().zip([64, 128, 191, 255]) {
assert!(got.abs_diff(want) <= 1, "{colour:?}");
}
let (colour, off) = one_colour(&gpu.render(&mut post, [32, 32]));
assert_eq!((colour, off), ([0, 0, 0, 255], 0));
}
/// Sizes of one kind that differ never meet: the program is skipped as the pack loads.
#[test]
fn a_program_writing_buffers_of_different_relative_sizes_is_skipped() {
let Some(gpu) = Fixture::new() else { return };
let post = pack("0.5 0.5");
let post = gpu
.try_build(post)
.unwrap_or_else(|_| panic!("the pack runs"));
assert!(
post.notes
.iter()
.any(|n| n == "composite: writes buffers of different sizes (size.buffer); skipped"),
"{:?}",
post.notes
);
}
@@ -420,6 +420,7 @@ fn frame(hud: &HudBatch) -> Frame<'_> {
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
entity_motion_posed: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
@@ -3,13 +3,15 @@
//! `HORIZON_SHADER_PACK_TIMING=1`. One frame
//! at a time is timed, and the CPU never waits for it: its timestamps are resolved in a later
//! frame, once the GPU has finished it (Metal resolves them without waiting for the passes that
//! write them), then read back when mapped. The next frame after that is timed again.
//! write them), then read back when mapped. The next frame after that is timed again, once `gap`
//! has passed since the last.
use std::collections::VecDeque;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::{Duration, Instant};
const REPORT: usize = 30;
/// Passes timed per frame at most (two timestamps each).
@@ -43,6 +45,10 @@ pub struct PassTiming {
/// `mark`'s pipeline, for the target format it was made for.
marker: Option<(wgpu::TextureFormat, wgpu::RenderPipeline)>,
reports: usize,
/// Least time between timed frames.
pub gap: Duration,
/// When the last timed frame began.
timed_at: Option<Instant>,
}
impl PassTiming {
@@ -81,6 +87,8 @@ impl PassTiming {
log: Self::logging(),
marker: None,
reports: 0,
gap: Duration::ZERO,
timed_at: None,
})
}
@@ -123,9 +131,12 @@ impl PassTiming {
}
_ => {}
}
if matches!(self.stage, Stage::Idle) {
if matches!(self.stage, Stage::Idle)
&& self.timed_at.is_none_or(|at| at.elapsed() >= self.gap)
{
self.names.clear();
self.stage = Stage::Recording;
self.timed_at = Some(Instant::now());
}
}
@@ -2431,6 +2431,7 @@ pub(super) mod initial_history_tests {
visible_static_block_entity_sections: &[],
static_block_entity_stats: crate::block_entity_render::StaticCacheFrameStats::default(),
entity_motion: &[],
entity_motion_posed: &[],
colored_lights: &[],
emissive_layers: &[],
shadow_only: &[],
@@ -323,4 +323,26 @@ mod tests {
let unorm = wgpu::TextureFormat::Rgba16Unorm;
reaches_an_8_bit_swapchain(&device, &queue, unorm, color, [0, 51, 255, 255]);
}
/// `Rgba16Float`, the frame of a shader API 0.2 pack under `HORIZON_HDR_FRAME=float`
/// (`Renderer::frame_format`): what the tonemap left over 1 is clipped on the way.
#[test]
fn a_float_frame_reaches_an_8_bit_swapchain() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let color = wgpu::Color {
r: 1.5,
g: 0.2,
b: -0.5,
a: 1.0,
};
let float = wgpu::TextureFormat::Rgba16Float;
reaches_an_8_bit_swapchain(&device, &queue, float, color, [0, 51, 255, 255]);
}
}
+23 -17
View File
@@ -217,15 +217,19 @@ struct HorizonWeatherOut {
textureDimensions(horizon_entity_atlas));
let color = textureLoad(horizon_entity_atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
return weather(WeatherInput(color, in.light, in.position, in.snow != 0u));
let input = WeatherInput(vec4(horizon_line_in(color.rgb), color.a), in.light, in.position,
in.snow != 0u);
let out = weather(input);
return vec4(horizon_line_out(out.rgb), out.a);
}
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str) -> String {
fn native_source(source: &str, api: horizon_shader_api::Api) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_")
"{source}\n// Horizon's weather (entity.wgsl)\n{}{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_"),
super::native_post::line_helpers(api)
)
}
@@ -242,7 +246,7 @@ pub(super) fn native_pipeline(
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
let text = native_source(&stage.source);
let text = native_source(&stage.source, frame.api);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let present = super::native_post::present_constants(&module, constants);
@@ -318,19 +322,21 @@ mod tests {
#[test]
fn a_native_weather_stage_validates() {
use horizon_shader_api::stages::StageKind;
let source = StageKind::Weather.prelude(horizon_shader_api::Api::V0_1)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source);
let module = naga::front::wgsl::parse_str(&text)
use horizon_shader_api::{Api, stages::StageKind};
for api in [Api::V0_1, Api::V0_2] {
let source = StageKind::Weather.prelude(api)
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source, api);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
}
use crate::weather::{Parameters, Time};
use wgpu::util::DeviceExt;
+1 -1
View File
@@ -771,7 +771,7 @@ pub struct Graphics {
/// is made (start, resource-pack reload), like `translucent_batching`, where it is offered
/// (`smooth_gradients_offered`).
pub hdr_smooth_gradients: bool,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
/// The native `horizon:<manifest-id>` or classic filename picked in `shaderpacks/`; empty: none. Its key is
/// not `shaderPack`, which versions before the clean-room rewrite saved and which stays
/// ignored.
pub shader_pack: String,
@@ -639,12 +639,20 @@ pub fn rows() -> Vec<(Page, Vec<(&'static str, Vec<Setting>)>)> {
/// BlueMap download radii, in chunks.
const BLUE_MAP_RADII: [u32; 5] = [16, 32, 64, 128, 256];
const UPLOAD_STEPS: [u32; 8] = [0, 256, 512, 1024, 2048, 4096, 8192, 16384];
/// What the LOD Distance slider stops at, in chunks: off, then each doubling
/// What the LOD Distance slider stops at, in chunks: off, fine steps up to 256
/// for a short ring of far terrain past a small render distance (a phone or an
/// integrated GPU at 8 chunks wants LOD to 12 or 16, not 64), then each doubling
/// up to the whole world.
const LOD_STEPS: [u32; 18] = [
const LOD_STEPS: [u32; 24] = [
0,
16,
24,
32,
48,
64,
96,
128,
192,
256,
512,
1024,
@@ -2692,12 +2700,21 @@ mod tests {
item.label(&s).plain(),
format!("LOD Distance: {} chunks", crate::lod::MAX_CHUNKS)
);
item.set_slider(&mut s, 5.0 / 17.0);
item.set_slider(&mut s, 11.0 / 23.0);
assert_eq!(s.horizon.lod_distance, 1024);
assert_eq!(item.label(&s).plain(), "LOD Distance: 1024 chunks");
// The fine stops: a ring of far terrain just past a small render
// distance, which the doubling steps skipped (#9694).
item.set_slider(&mut s, 1.0 / 23.0);
assert_eq!(s.horizon.lod_distance, 16);
assert_eq!(item.label(&s).plain(), "LOD Distance: 16 chunks");
item.set_slider(&mut s, 2.0 / 23.0);
assert_eq!(s.horizon.lod_distance, 24);
// A distance between stops (from the file) sits on the next one.
s.horizon.lod_distance = 3000;
assert_eq!(item.slider(&s), Some(7.0 / 17.0));
assert_eq!(item.slider(&s), Some(13.0 / 23.0));
s.horizon.lod_distance = 12;
assert_eq!(item.slider(&s), Some(1.0 / 23.0));
assert_eq!(
Item::Vanilla(Opt::RenderDistance).label(&s).plain(),
format!("Full Detail Distance: {near} chunks")
@@ -485,11 +485,6 @@ pub struct HealthHud {
/// `Util.getMillis()` of the last health change.
pub last_health_time: i64,
pub health_blink_time: i64,
/// `Hud.leftHeight` and `rightHeight` once the health, armor, food, mount
/// and air layers have run; the selected item name and the action bar sit
/// above them.
pub left_height: i32,
pub right_height: i32,
/// `Hud.lastBubblePopSoundPlayed`.
pub last_bubble_pop_sound: i32,
/// The `ui.hud.bubble_pop` volume and pitch `playAirBubblePoppedSound`
@@ -655,9 +650,6 @@ impl HealthHud {
let rows = ceil((max_health + absorption as f32) / 2.0 / 10.0);
let row_height = (10 - (rows - 2)).max(3);
let mut y_air = y_base - 10;
self.left_height = 39 + (rows - 1) * row_height + 10 + if i.armor > 0 { 10 } else { 0 };
// The mount's heart rows (`extractVehicleHealth`); food adds its row below.
self.right_height = 39 + 10 * ((i.vehicle_hearts + 9) / 10);
let offset_index = if i.regeneration {
i.tick % ceil(max_health + 5.0)
} else {
@@ -752,13 +744,11 @@ impl HealthHud {
}
}
y_air -= 10;
self.right_height += 10;
}
// Hud.extractAirBubbles.
let max_air = i.max_air.max(1);
let air = i.air.clamp(0, max_air);
if i.under_water || air < max_air {
self.right_height += 10;
let rows = (f64::from(i.vehicle_hearts) / 10.0).ceil() as i32;
let y = y_air - (rows - 1) * 10;
let bubble = |offset: i32| ceil(((air + offset) * 10) as f32 / max_air as f32);
@@ -323,13 +323,12 @@ impl Titles {
}
}
}
/// `Hud.extractOverlayMessage`; `bar_height` is
/// `max(leftHeight, rightHeight)`.
/// `Hud.extractOverlayMessage`: the action bar keeps its place however
/// tall the health, food, air and mount rows above the hotbar are.
pub fn overlay_message(
&self,
w: i32,
h: i32,
bar_height: i32,
partial: f32,
font: &Font,
background: u32,
@@ -345,12 +344,11 @@ impl Titles {
let alpha = ((t * 255.0 / 20.0) as i32).min(255);
if alpha > 0 {
let message = message.resolve_legacy();
// The bars' height plus the gap between the item name (59) and the
// overlay (68), at least the default level.
let y_shift = bar_height + (68 - 59);
// `Hud.extractOverlayMessage` translates to `guiHeight - 68`, a
// constant the bars above the hotbar never move.
out.push(GuiDraw::Push(vec![PoseOp::Translate(
(w / 2) as f32,
(h - y_shift.max(68)) as f32,
(h - 68) as f32,
)]));
let color = if self.overlay_animated {
hsv_to_argb(t / 50.0, 0.7, 0.6, alpha as u32)
@@ -468,13 +466,13 @@ impl ToolHighlight {
self.last = selected;
}
/// `Hud.extractSelectedItemName`; `can_hurt` is
/// `gameMode.canHurtPlayer()` (survival and adventure) and `bar_height`
/// is `max(leftHeight, rightHeight)`.
/// `gameMode.canHurtPlayer()` (survival and adventure). The name sits 59
/// pixels above the bottom edge whatever the bars above the hotbar do,
/// and 14 lower when the mode cannot hurt the player.
pub fn draw(
&self,
w: i32,
h: i32,
bar_height: i32,
can_hurt: bool,
font: &Font,
background: u32,
@@ -495,7 +493,7 @@ impl ToolHighlight {
}
let str_width = width(font, &name);
let x = (w - str_width) / 2;
let mut y = h - bar_height.max(59);
let mut y = h - 59;
if !can_hurt {
y += 14;
}
@@ -1295,3 +1293,92 @@ mod subtitle_tests {
assert!(!o.listening());
}
}
#[cfg(test)]
mod text_position_tests {
use super::*;
use crate::{font::Font, hud::Rect};
use std::{collections::HashMap, path::Path};
/// The missing-glyph metrics let these tests run without game assets.
fn font() -> Font {
Font::load(
Path::new("/nonexistent-horizon-hud-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,
},
)
}
fn held() -> HeldName {
HeldName {
item: "minecraft:stone".into(),
hover_name: Text::literal("Stone"),
rarity_color: 0xFFFF_FFFF,
custom_name: false,
}
}
fn text_top(draws: &[GuiDraw]) -> Option<i32> {
draws.iter().find_map(|d| match d {
GuiDraw::Text { y, .. } => Some(*y),
_ => None,
})
}
/// `Hud.extractSelectedItemName`: `guiHeight - 59`, then `+ 14` when the
/// game mode cannot hurt the player. The health, food, air and mount rows
/// above the hotbar never move it: with armor and a second row of hearts
/// they reach 69 pixels, and the name stays where vanilla draws it.
#[test]
fn the_selected_item_name_sits_fifty_nine_above_the_bottom_edge() {
let mut highlight = ToolHighlight {
last: Some(held()),
timer: 40,
};
let f = font();
let draws = highlight.draw(320, 240, true, &f, 0);
assert_eq!(text_top(&draws), Some(240 - 59));
// Creative and spectator add the fourteen pixels the experience bar
// and the food row would have taken.
let draws = highlight.draw(320, 240, false, &f, 0);
assert_eq!(text_top(&draws), Some(240 - 59 + 14));
// No timer, no name.
highlight.timer = 0;
assert!(highlight.draw(320, 240, true, &f, 0).is_empty());
}
/// `Hud.extractOverlayMessage`: the action bar and its text translate to
/// `guiHeight - 68` and `-4`, again wherever the bars above the hotbar
/// reach.
#[test]
fn the_action_bar_sits_sixty_eight_above_the_bottom_edge() {
let mut titles = Titles::default();
titles.set_overlay(Text::literal("Action bar"), false);
let f = font();
let draws = titles.overlay_message(320, 240, 0.0, &f, 0);
let translate = draws.iter().find_map(|d| match d {
GuiDraw::Push(ops) => ops.iter().find_map(|op| match op {
PoseOp::Translate(_, y) => Some(*y),
_ => None,
}),
_ => None,
});
assert_eq!(translate, Some((240 - 68) as f32));
// The text is drawn four pixels below that origin, as vanilla's is.
let relative = draws.iter().find_map(|d| match d {
GuiDraw::Text { y, .. } => Some(*y),
_ => None,
});
assert_eq!(relative, Some(-4));
}
}
+2
View File
@@ -14,6 +14,7 @@ pub mod block_entity_anim;
pub mod block_entity_cem;
pub mod block_entity_render;
pub mod block_sprites;
pub mod block_states;
pub mod bluemap;
pub mod bluemap_detect;
pub mod border_render;
@@ -27,6 +28,7 @@ pub mod cit;
pub mod cit_armor;
pub mod cit_assets;
pub mod cit_glint;
pub mod client_resources;
pub mod clouds;
pub mod collisions;
pub mod command_block;
+37 -1
View File
@@ -274,6 +274,8 @@ enum Out {
date: NaiveDate,
lines: Vec<String>,
stderr: Option<String>,
/// The message's level, thread and text, for the [`set_line_hook`] callback.
record: (log::Level, String, String),
},
/// Answer once everything before is written.
Flush(mpsc::SyncSender<()>),
@@ -293,12 +295,14 @@ struct Writer {
impl Writer {
fn run(mut self, out: mpsc::Receiver<Out>) {
let stderr = io::stderr();
let mut hook_alive = true;
for msg in out {
match msg {
Out::Write {
date,
lines,
stderr: message,
record: (level, thread, text),
} => {
if let Some(message) = message {
let _ = writeln!(stderr.lock(), "{message}");
@@ -319,6 +323,12 @@ impl Writer {
text.push('\n');
let _ = latest.file.write_all(text.as_bytes());
}
// After the file has it. A callback that panics is not called again: the
// panic's own log line would reach it next.
if let Some(hook) = LINE_HOOK.get().filter(|_| hook_alive) {
let call = || hook(level.as_str(), &thread, &text, &lines.join("\n"));
hook_alive = std::panic::catch_unwind(call).is_ok();
}
}
Out::Flush(done) => {
if let Some(latest) = &mut self.file {
@@ -395,6 +405,18 @@ impl Floods {
static LOGGER: std::sync::OnceLock<Logger> = std::sync::OnceLock::new();
/// What [`set_line_hook`] installed: level (`INFO`, `WARN`, `ERROR`), thread, message, and the
/// lines as `latest.log` got them.
type LineHook = fn(&str, &str, &str, &str);
static LINE_HOOK: std::sync::OnceLock<LineHook> = std::sync::OnceLock::new();
/// Calls `hook` on the log's writer thread for each message written to the log, once the file
/// has it, so the thread that logged never waits on it. Once per process; a second call keeps
/// the first.
pub fn set_line_hook(hook: LineHook) {
let _ = LINE_HOOK.set(hook);
}
fn ours(target: &str) -> bool {
target.starts_with("horizon")
}
@@ -490,6 +512,7 @@ impl Logger {
date: now.date_naive(),
lines,
stderr,
record: (level, thread.to_owned(), message.to_owned()),
};
if let Err(mpsc::SendError(Out::Write {
stderr: Some(message),
@@ -502,12 +525,25 @@ impl Logger {
}
}
/// The `latest.log` this run writes, once `init` opened it.
static LATEST_PATH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
/// The `latest.log` this run writes, absolute; None before `init` and when it logs to the
/// terminal only.
pub fn latest_path() -> Option<PathBuf> {
LATEST_PATH.get().cloned()
}
/// Starts the log in `home/logs`. The main thread shows as `main_thread` in it. Messages are
/// still printed when the file cannot be opened.
pub fn init(home: &Path, main_thread: &'static str) {
let dir = logs_dir(home);
let file = match open_latest(&dir) {
Ok(Some(latest)) => Some(latest),
Ok(Some(latest)) => {
let path = dir.join("latest.log");
let _ = LATEST_PATH.set(std::path::absolute(&path).unwrap_or(path));
Some(latest)
}
Ok(None) => {
eprintln!(
"Horizon: another game is writing {}; this one logs to the terminal only",
+11 -1
View File
@@ -338,11 +338,21 @@ impl Settings {
.map_or(-1, |i| i as i32)
}
pub fn load(path: &Path) -> Self {
let horizon = path
let mut horizon = path
.parent()
.map(crate::graphics::Graphics::load)
.unwrap_or_default();
crate::mipmap::set_dither_cutouts(horizon.dither_cutout_mips);
if let Some(dir) = path.parent() {
let (key, _) = crate::shader_pack_list::resolve(
&crate::logging::shared_dir(dir).join(crate::shader_pack_list::DIR),
&horizon.shader_pack,
);
if key != horizon.shader_pack {
horizon.shader_pack = key;
horizon.save(dir);
}
}
let controller = path
.parent()
.map(crate::gamepad::PadSettings::load)
@@ -18,8 +18,10 @@ pub enum Kind {
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
/// The folder's or file's name in `shaderpacks`: what the pick saves.
/// The folder or file name in `shaderpacks`.
pub file: String,
/// Native manifest identity (`horizon:<id>`), or a classic filename.
pub key: String,
/// The pack's own name for Horizon's packs, else `file`.
pub title: String,
pub kind: Kind,
@@ -33,7 +35,7 @@ pub struct Entry {
impl Entry {
/// Saves the player's option values, for the pack in the folder `dir`.
pub fn save_values(&self, dir: &Path) {
let path = dir.join(&self.file);
let path = values_pack(&dir.join(&self.file), &self.key);
if let Err(e) = save_values(&path, &self.options, &self.values) {
log::warn!("shader pack options of {}: {e}", self.file);
}
@@ -106,7 +108,22 @@ pub fn scan(dir: &Path) -> Vec<Entry> {
let Ok(entries) = std::fs::read_dir(dir) else {
return Vec::new();
};
let mut packs: Vec<Entry> = entries.flatten().filter_map(|e| entry(&e.path())).collect();
let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect();
paths.sort();
let mut keys = std::collections::HashSet::new();
let mut packs: Vec<Entry> = paths
.iter()
.filter_map(|p| entry(p))
.filter(|e| keys.insert(e.key.clone()))
.collect();
for pack in &mut packs {
if let Some(id) = pack.key.strip_prefix("horizon:") {
pack.values = load_values(
&native_values_pack(&dir.join(&pack.file), id),
&pack.options,
);
}
}
packs.sort_by(|a, b| {
(a.kind == Kind::Classic, a.title.to_lowercase())
.cmp(&(b.kind == Kind::Classic, b.title.to_lowercase()))
@@ -122,26 +139,18 @@ fn entry(path: &Path) -> Option<Entry> {
if horizon_shader_api::package::is_pack(path) {
// Its manifest's name; a pack that can't be read is still listed, under its file
// name: picking it says why in the chat.
let manifest = if path.is_dir() {
std::fs::read_to_string(path.join(horizon_shader_api::MANIFEST))
.ok()
.and_then(|t| horizon_shader_api::Manifest::parse(&t).ok())
} else {
horizon_shader_api::Pack::read(path)
.ok()
.map(|p| p.manifest)
};
let (title, options, pages) = manifest.map_or_else(
|| (file.clone(), Vec::new(), Vec::new()),
|m| (m.name, m.options, m.pages),
let manifest = horizon_shader_api::Pack::read_manifest(path).ok();
let (key, title, options, pages) = manifest.map_or_else(
|| (file.clone(), file.clone(), Vec::new(), Vec::new()),
|m| (format!("horizon:{}", m.id), m.name, m.options, m.pages),
);
let values = load_values(path, &options);
return Some(Entry {
key,
file,
title,
kind: Kind::Horizon,
options,
values,
values: Vec::new(),
pages,
});
}
@@ -151,6 +160,7 @@ fn entry(path: &Path) -> Option<Entry> {
.is_some_and(|e| e.eq_ignore_ascii_case("zip"));
(zip || path.join("shaders").is_dir()).then(|| Entry {
title: file.clone(),
key: file.clone(),
file,
kind: Kind::Classic,
options: Vec::new(),
@@ -159,6 +169,104 @@ fn entry(path: &Path) -> Option<Entry> {
})
}
/// Resolves a saved identity, migrating a legacy native filename when its manifest or the
/// launcher's `<id>-<version>.hshader` name identifies it. Duplicate IDs use the first filename
/// in lexical order, matching `scan`; versions are free-form strings, not ordered versions.
/// An absent arbitrary old filename cannot be attributed to a manifest safely.
pub fn resolve(dir: &Path, picked: &str) -> (String, PathBuf) {
if picked.is_empty() {
return (String::new(), dir.join(picked));
}
let direct = dir.join(picked);
if direct.exists() && !horizon_shader_api::package::is_pack(&direct) {
return (picked.to_owned(), direct);
}
let mut native: Vec<_> = std::fs::read_dir(dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let path = e.path();
let file = path.file_name()?.to_str()?.to_owned();
if file.starts_with('.') || !horizon_shader_api::package::is_pack(&path) {
return None;
}
Some((file, horizon_shader_api::Pack::read_manifest(&path).ok()?))
})
.collect();
native.sort_by(|a, b| a.0.cmp(&b.0));
let id = native
.iter()
.find(|(file, _)| file == picked)
.map(|(_, m)| m.id.as_str())
.or_else(|| picked.strip_prefix("horizon:"))
.or_else(|| {
// Never reinterpret an installed classic pack as a native identity.
if dir.join(picked).exists() {
return None;
}
let stem = picked.strip_suffix(".hshader")?;
native
.iter()
.find(|(_, m)| {
stem.strip_prefix(&m.id)
.is_some_and(|v| v.starts_with('-') && v.len() > 1)
})
.map(|(_, m)| m.id.as_str())
});
if let Some((file, m)) = native.iter().find(|(_, m)| Some(m.id.as_str()) == id) {
let key = format!("horizon:{}", m.id);
if !picked.starts_with("horizon:") {
migrate_values(&dir.join(picked), &key);
}
migrate_values(&dir.join(file), &key);
return (key, dir.join(file));
}
(picked.to_owned(), dir.join(picked))
}
fn values_pack(pack: &Path, key: &str) -> PathBuf {
key.strip_prefix("horizon:").map_or_else(
|| pack.to_path_buf(),
|id| {
pack.parent()
.unwrap_or_else(|| Path::new("."))
.join(".horizon-options")
.join(id)
},
)
}
/// The native options path; imports a filename sidecar once, keeping the original intact.
pub fn native_values_pack(pack: &Path, id: &str) -> PathBuf {
let key = format!("horizon:{id}");
migrate_values(pack, &key);
values_pack(pack, &key)
}
fn migrate_values(pack: &Path, key: &str) {
let target = values_path(&values_pack(pack, key));
if target.exists() {
return;
}
if let Ok(text) = std::fs::read(values_path(pack)) {
let result = std::fs::create_dir_all(target.parent().unwrap()).and_then(|()| {
use std::io::Write;
// Another reader may already have imported the values.
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&target)?;
file.write_all(&text)
});
if let Err(e) = result
&& e.kind() != std::io::ErrorKind::AlreadyExists
{
log::warn!("shader pack options migration: {e}");
}
}
}
/// Where the option values of the Horizon pack at `pack` are kept: `<pack>.txt` beside it,
/// one `id=value` a line.
pub fn values_path(pack: &Path) -> PathBuf {
@@ -186,8 +294,7 @@ pub fn load_values(pack: &Path, options: &[PackOption]) -> Vec<f64> {
.collect()
}
/// Saves `values` of `options`; values at their defaults are left out, and with none left
/// the file goes.
/// Saves non-default values. An empty file records a reset, so old sidecars stay retired.
fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::Result<()> {
let text: String = options
.iter()
@@ -196,14 +303,10 @@ fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::
.map(|(o, v)| format!("{}={v}\n", o.id))
.collect();
let path = values_path(pack);
if text.is_empty() {
match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
} else {
std::fs::write(path, text)
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, text)
}
fn clamp(kind: &OptionKind, v: f64) -> f64 {
@@ -315,6 +418,102 @@ pub fn show_value(kind: &OptionKind, v: f64) -> String {
mod tests {
use super::*;
fn install(dir: &Path, file: &str, version: &str) {
let manifest = format!(
"schema = 1\nid = \"warm\"\nname = \"Warm\"\nversion = \"{version}\"\napi = \"0.1\"\n\
[[post]]\nfile = \"p.wgsl\"\n[[options]]\nid = \"strength\"\nname = \"Strength\"\ntype = \"float\"\ndefault = 1.0\nmin = 0.0\nmax = 2.0\nstep = 0.5\n"
);
let files = [
(horizon_shader_api::MANIFEST.into(), manifest.into_bytes()),
("p.wgsl".into(), b"original test fixture".to_vec()),
]
.into();
let pack = horizon_shader_api::Pack::from_files(files).unwrap();
std::fs::write(dir.join(file), pack.package().unwrap()).unwrap();
}
#[test]
fn native_selection_and_values_survive_install_update_rename_and_reset() {
let dir =
std::env::temp_dir().join(format!("horizon-identity-update-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
install(&dir, "warm-1.hshader", "1");
std::fs::write(dir.join("warm-1.hshader.txt"), "strength=1.5\n").unwrap();
let options = dir.join("options.txt");
std::fs::write(&options, "").unwrap();
std::fs::write(
dir.join(crate::graphics::FILE),
"pickedShaderPack:warm-1.hshader\n",
)
.unwrap();
// Use the real settings loader, including persistence of the migrated selection.
// Its shaderpacks folder sits beside options.txt.
let packs = dir.join(DIR);
std::fs::create_dir_all(&packs).unwrap();
std::fs::rename(dir.join("warm-1.hshader"), packs.join("warm-1.hshader")).unwrap();
std::fs::rename(
dir.join("warm-1.hshader.txt"),
packs.join("warm-1.hshader.txt"),
)
.unwrap();
let settings = crate::settings::Settings::load(&options);
assert_eq!(settings.horizon.shader_pack, "horizon:warm");
assert_eq!(
crate::graphics::Graphics::load(&dir).shader_pack,
"horizon:warm"
);
assert_eq!(scan(&packs)[0].values, [1.5]);
install(&packs, "warm-2.hshader", "2");
std::fs::remove_file(packs.join("warm-1.hshader")).unwrap();
let (_, path) = resolve(&packs, &settings.horizon.shader_pack);
assert_eq!(path, packs.join("warm-2.hshader"));
std::fs::rename(path, packs.join("renamed.hshader")).unwrap();
let (_, path) = resolve(&packs, "horizon:warm");
assert_eq!(path, packs.join("renamed.hshader"));
let manifest = horizon_shader_api::Pack::read_manifest(&path).unwrap();
assert_eq!(
load_values(&native_values_pack(&path, &manifest.id), &manifest.options),
[1.5]
);
let mut entry = scan(&packs).remove(0);
entry.values[0] = 0.5;
entry.save_values(&packs);
assert_eq!(
load_values(&native_values_pack(&path, &manifest.id), &manifest.options),
[0.5]
);
entry.reset_options();
entry.save_values(&packs);
assert_eq!(scan(&packs)[0].values, [1.0]);
assert_eq!(
std::fs::read_to_string(packs.join("warm-1.hshader.txt")).unwrap(),
"strength=1.5\n"
);
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn migrates_a_removed_registry_filename_and_resolves_duplicates_deterministically() {
let dir = std::env::temp_dir().join(format!(
"horizon-identity-duplicates-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
install(&dir, "z.hshader", "newest");
install(&dir, "a.hshader", "oldest");
std::fs::write(dir.join("warm-previous.hshader.txt"), "strength=2\n").unwrap();
let (key, path) = resolve(&dir, "warm-previous.hshader");
assert_eq!(key, "horizon:warm");
assert_eq!(path, dir.join("a.hshader"));
assert_eq!(resolve(&dir, "z.hshader"), (key.clone(), path));
assert_eq!(scan(&dir).len(), 1);
assert_eq!(scan(&dir)[0].values, [2.0]);
// A classic folder named like a native ID keeps its filename identity.
std::fs::create_dir_all(dir.join("warm/shaders")).unwrap();
assert_eq!(resolve(&dir, "warm"), ("warm".into(), dir.join("warm")));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn lists_both_kinds_horizon_first_and_skips_the_rest() {
let dir = std::env::temp_dir().join(format!("horizon-shader-list-{}", std::process::id()));
@@ -397,7 +596,7 @@ mod tests {
std::fs::write(values_path(&pack), "a=9\nb=1\nzzz=4\n").unwrap();
assert_eq!(load_values(&pack, &options), [2.0, 1.0]);
save_values(&pack, &options, &[1.0, 0.0]).unwrap();
assert!(!values_path(&pack).exists());
assert_eq!(std::fs::read_to_string(values_path(&pack)).unwrap(), "");
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -417,6 +616,7 @@ mod tests {
};
let mut entry = Entry {
file: "p".into(),
key: "horizon:p".into(),
title: "P".into(),
kind: Kind::Horizon,
options: vec![
+30 -16
View File
@@ -64,7 +64,7 @@ fn picked(s: &Settings) -> Option<(usize, Kind)> {
let i = s
.shader_packs
.iter()
.position(|p| !s.horizon.shader_pack.is_empty() && p.file == s.horizon.shader_pack)?;
.position(|p| !s.horizon.shader_pack.is_empty() && p.key == s.horizon.shader_pack)?;
Some((i, s.shader_packs[i].kind))
}
@@ -360,7 +360,7 @@ impl ShaderUi {
Item::Pack(i) => Some(
s.shader_packs
.get(usize::from(i))
.is_some_and(|p| p.file == s.horizon.shader_pack),
.is_some_and(|p| p.key == s.horizon.shader_pack),
),
Item::Option(i) => match picked(s)? {
(p, Kind::Horizon) => {
@@ -406,7 +406,7 @@ impl ShaderUi {
Kind::Horizon => "A Horizon shader pack.",
Kind::Classic => "An OptiFine or Iris shader pack.",
});
let what = lang::get(if pack.file == s.horizon.shader_pack {
let what = lang::get(if pack.key == s.horizon.shader_pack {
"In use: press to turn shaders off."
} else {
"Press to use it."
@@ -588,8 +588,14 @@ impl ShaderUi {
// The picked pack's own screens go under its name, apart from Horizon's pack list.
if side.len() > 1 {
let [x, y, w, _] = cols.page(1);
let pack = &s.horizon.shader_pack;
let pack = pack.strip_suffix(".zip").unwrap_or(pack);
let pack = match picked(s) {
Some((i, Kind::Horizon)) => s.shader_packs[i].title.as_str(),
_ => s
.horizon
.shader_pack
.strip_suffix(".zip")
.unwrap_or(&s.horizon.shader_pack),
};
let caption = lang::tr("%s settings", &[&pack]);
let rect = [x + 2.0, y + 6.0, w - 4.0, 8.0];
crate::widgets::record(&caption, rect, false);
@@ -854,10 +860,10 @@ impl ShaderUi {
let Some(p) = s.shader_packs.get(usize::from(i)) else {
return Vec::new();
};
s.horizon.shader_pack = if s.horizon.shader_pack == p.file {
s.horizon.shader_pack = if s.horizon.shader_pack == p.key {
String::new()
} else {
p.file.clone()
p.key.clone()
};
vec![Action::ButtonClick, Action::Picked]
}
@@ -1107,6 +1113,7 @@ mod tests {
fn entry(file: &str, kind: Kind) -> Entry {
Entry {
file: file.into(),
key: file.into(),
title: file.into(),
kind,
options: Vec::new(),
@@ -1174,14 +1181,20 @@ mod tests {
#[test]
fn picking_a_pack_and_picking_it_again_turns_shaders_off() {
let mut s = settings();
let mut ui = ShaderUi::default();
let actions = ui.activate(&mut s, Item::Pack(1), false, None);
assert_eq!(actions, [Action::ButtonClick, Action::Picked]);
assert_eq!(s.horizon.shader_pack, "B.zip");
ui.activate(&mut s, Item::Pack(1), false, None);
assert_eq!(s.horizon.shader_pack, "");
assert_eq!(ShaderUi::switch(Item::Pack(0), &s), Some(false));
for (kind, key) in [(Kind::Classic, "B.zip"), (Kind::Horizon, "horizon:warm")] {
let mut s = settings();
s.shader_packs[1].kind = kind;
s.shader_packs[1].key = key.into();
let mut ui = ShaderUi::default();
let actions = ui.activate(&mut s, Item::Pack(1), false, None);
assert_eq!(actions, [Action::ButtonClick, Action::Picked]);
assert_eq!(s.horizon.shader_pack, key);
assert_eq!(picked(&s), Some((1, kind)));
assert_eq!(ShaderUi::switch(Item::Pack(1), &s), Some(true));
ui.activate(&mut s, Item::Pack(1), false, None);
assert_eq!(s.horizon.shader_pack, "");
assert_eq!(ShaderUi::switch(Item::Pack(0), &s), Some(false));
}
}
#[test]
@@ -1251,6 +1264,7 @@ mod tests {
#[test]
fn a_horizon_packs_slider_drag_is_saved_once_when_let_go() {
let mut pack = entry("H", Kind::Horizon);
pack.key = "horizon:slider_test".into();
pack.options = vec![horizon_shader_api::PackOption {
id: "strength".into(),
name: "Strength".into(),
@@ -1267,7 +1281,7 @@ mod tests {
shader_packs: vec![pack],
..Settings::default()
};
s.horizon.shader_pack = "H".into();
s.horizon.shader_pack = "horizon:slider_test".into();
let mut ui = ShaderUi::default();
ui.open(Tab::Screen(0));
let cols = Columns::of(SIZE.0, SIZE.1);
@@ -63,6 +63,11 @@ struct Globals {
// which then samples exactly as before.
override SPRITE_ARRAYS: u32 = 1u;
override SPRITE_SPLIT: u32 = 2048u;
// Whether the solid shaders carry HDR output's lighting (a pipeline constant): the
// renderer builds its solid terrain without it for the frames HDR is off, where
// `hdr_light.z` is 0, since even a branch never taken costs Apple GPUs ~15% of a
// solid pixel's time (gfx.rs's `SolidPipeline`).
override HDR_LIGHTING: bool = true;
// A sprite sampled with the given gradients, from whichever array holds its layer.
fn sprite_grad(uv: vec2<f32>, layer: u32, dx: vec2<f32>, dy: vec2<f32>) -> vec4<f32> {
let l = i32(layer % SPRITE_SPLIT);
@@ -620,7 +625,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
}
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
if globals.hdr_light.z != 0.0 {
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
}
let texel = sprite(in.uv, in.layer & 0x7fffu);
@@ -637,7 +642,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
fn solid_color(in: TerrainOut) -> vec4<f32> {
let dx = dpdx(in.local);
let dy = dpdy(in.local);
if globals.hdr_light.z != 0.0 {
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
hdr_normal = normalize(cross(dy, dx));
}
if faded(in.fade, in.local, dx, dy) { discard; }
@@ -665,7 +670,7 @@ fn solid_finish_at(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>, du: vec2<
// fully fogged mountain would stand out from the fog behind it.
// Without HDR output nothing reads the alpha, so its lighting is not worked out.
var emissive = 1.0;
if globals.hdr_light.z != 0.0 {
if HDR_LIGHTING && globals.hdr_light.z != 0.0 {
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
emissive = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()),
@@ -50,6 +50,7 @@ pub fn main() -> horizon_client::Result<()> {
&m,
&c,
case["initial"].as_str().unwrap_or(""),
false,
history.len(),
w,
h,
@@ -101,7 +101,7 @@ fn title(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
}
let partial = float(c, "partial", 0.0);
let bg = background(c);
let mut out = t.overlay_message(w, h, 39, partial, &atlas.font, bg);
let mut out = t.overlay_message(w, h, partial, &atlas.font, bg);
out.extend(t.title(w, h, partial, &atlas.font, bg));
out
}
@@ -129,7 +129,7 @@ fn selected(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
}
let mode = c["gameMode"].as_str().unwrap_or("survival");
let can_hurt = mode == "survival" || mode == "adventure";
highlight.draw(w, h, 39, can_hurt, &atlas.font, background(c))
highlight.draw(w, h, can_hurt, &atlas.font, background(c))
}
fn boss(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
+32
View File
@@ -146,6 +146,16 @@ fn frame(v: &Value) -> Frame {
}
}
impl Tree {
/// `PlayerAdvancements.checkForAutomaticTriggers`: the advancements that
/// have no criteria at all, so nothing can ever grant them a criterion and
/// vanilla hands them over (and grants their rewards) as the player's
/// progress loads.
pub fn automatic(&self) -> Vec<usize> {
(0..self.list.len())
.filter(|&i| self.list[i].criteria.is_empty())
.collect()
}
/// [`Tree::load`], shared while anyone holds it: a server's advancement
/// packets (`ServerAdvancements`) and its game's criteria read the same
/// files, and each kept its own copy (7 MB). A world whose data packs bring
@@ -2677,6 +2687,28 @@ mod tests {
p.grant("minecraft:t", "b", "now");
assert!(p.done(&a));
}
/// `AdvancementRequirements.test` answers false with no requirement groups, so an advancement
/// without criteria is never done by `isDone`; `PlayerAdvancements.checkForAutomaticTriggers`
/// still grants its rewards as progress loads, and `Tree::automatic` is the list it walks.
#[test]
fn an_advancement_without_criteria_is_not_done_but_granted_automatically() {
let empty = parse("minecraft:free", &serde_json::json!({"criteria": {}})).unwrap();
assert!(!Progress::default().done(&empty), "no groups: not done");
let waiting = parse(
"minecraft:waits",
&serde_json::json!({"criteria": {"a": {"trigger": "minecraft:tick"}}}),
)
.unwrap();
assert!(!Progress::default().done(&waiting));
let tree = tree_of(&[
(
"minecraft:waits",
serde_json::json!({"criteria": {"a": {"trigger": "minecraft:tick"}}}),
),
("minecraft:free", serde_json::json!({"criteria": {}})),
]);
assert_eq!(tree.automatic(), vec![1]);
}
#[test]
fn layout_spreads_children_like_tree_node_position() {
let mut tree = Tree::default();
@@ -57,6 +57,21 @@ pub fn emit(event: Event<'_>) {
dispatch(event, &mut 0.0);
}
/// The game's native trample hook (`native_trample`), set once by the game: dimension, entity
/// id, entity type, the farmland's position, the fall distance and cancel.
pub static TRAMPLE: OnceLock<fn(&str, i32, &str, [i32; 3], f64, &mut bool)> = OnceLock::new();
/// Whether the mods let `entity` (of type `kind`) trample the farmland at `position`.
pub fn trample_allowed(entity: i32, kind: &str, position: BlockPos, distance: f64) -> bool {
let (Some(dimension), Some(hook)) = (LEVEL.get(), TRAMPLE.get()) else {
return true;
};
let mut cancel = false;
let at = [position.x, position.y, position.z];
hook(dimension, entity, kind, at, distance, &mut cancel);
!cancel
}
/// Damage is the calculated fall damage, before hurt handling and fall sounds.
pub fn fall(contact: Contact<'_>, distance: f64, mut damage: f32) -> f32 {
dispatch(Event::FallOn(contact, distance), &mut damage);
+14 -14
View File
@@ -10,20 +10,20 @@ pub mod block_contact;
pub mod powder_snow;
pub use horizon_client_render::{
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
block_entity_anim, block_entity_render, block_sprites, bluemap, bluemap_detect, border_render,
breaking, brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets, cit_glint,
clouds, collisions, command_block, containers, controller_ui, crash, datapack, dynamic_light,
effects, environment, extras, fast_hash, fog, font, gfx, graphics, graphics_style, graphics_ui,
hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects, java_text, jigsaw_block,
json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap, mipmap,
mod_browser, mod_list, model_library, models, multiline_edit, native_emissive, options_file,
options_fixes, options_ui, overlay, pack_browser, pack_screen, packs, pad_cursor, pad_glyph,
par, particles, poi_sections, post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud,
screen_draw, screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, sound_engine, sound_events, sound_preview, spans, split_layout,
split_map, split_profiles, sprites, streaming, structure_block, tab_list, telemetry,
test_block, test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets,
world, worldborder, zip,
block_entity_anim, block_entity_render, block_sprites, block_states, bluemap, bluemap_detect,
border_render, breaking, brewing, camera, chat, chunk, chunk_map, cit, cit_armor, cit_assets,
cit_glint, client_resources, clouds, collisions, command_block, containers, controller_ui,
crash, datapack, dynamic_light, effects, environment, extras, fast_hash, fog, font, gfx,
graphics, graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays,
inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging,
menu_blur, mesher, minimap, mipmap, mod_browser, mod_list, model_library, models,
multiline_edit, native_emissive, options_file, options_fixes, options_ui, overlay,
pack_browser, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, screen_draw, screen_effects,
sculk, section_mask, server_terrain, settings, shader_options, shader_pack_list, signs, skins,
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
+88
View File
@@ -289,6 +289,8 @@ impl ServerTickRateManager {
/// Ticks one sprint frame may run (rendering carries on between frames).
const SPRINT_PER_FRAME: i64 = 40;
const SAMPLES: usize = 100;
/// The windows of [`FixedStep::tps`], in seconds: one, five and fifteen minutes.
const TPS_WINDOWS: [f64; 3] = [60.0, 300.0, 900.0];
/// A step command's broadcast value and the ticks still owed to a new client.
/// `ServerTickRateManager.updateStepTicks` sends the former immediately;
@@ -316,6 +318,10 @@ pub struct FixedStep {
tick_times: [i64; SAMPLES],
aggregated: i64,
tick_count: i64,
/// The ticks a second over the last [`TPS_WINDOWS`], decayed once a second ([`Self::tps`]).
tps: [f64; 3],
/// When the second being counted started (at a recorded tick's end) and the ticks since.
tps_second: Option<(std::time::Instant, u32)>,
/// A server's client: its level's tick rate only slows the timer down
/// (`Minecraft.getTickTargetMillis`), since the server runs the ticks.
client: bool,
@@ -349,6 +355,8 @@ impl FixedStep {
tick_times: [0; SAMPLES],
aggregated: 0,
tick_count: 0,
tps: [20.0; 3],
tps_second: None,
client: false,
step_changes: 0,
step_requested: 0,
@@ -530,6 +538,41 @@ impl FixedStep {
let end = self.now_ns() + took;
self.manager.end_tick_work(end);
}
self.count_tick(std::time::Instant::now());
}
/// A recorded tick ended at `now`: once a second has passed since the second being
/// counted started, its ticks a second join each average of [`Self::tps`], weighted as a
/// load average is (`exp(-elapsed / window)` kept of the old value).
fn count_tick(&mut self, now: std::time::Instant) {
let Some((start, ticks)) = &mut self.tps_second else {
self.tps_second = Some((now, 0));
return;
};
*ticks += 1;
let elapsed = now.saturating_duration_since(*start).as_secs_f64();
if elapsed < 1.0 {
return;
}
let rate = f64::from(*ticks) / elapsed;
for (average, window) in self.tps.iter_mut().zip(TPS_WINDOWS) {
let kept = (-elapsed / window).exp();
*average = *average * kept + rate * (1.0 - kept);
}
self.tps_second = Some((now, 0));
}
/// The ticks a second the server kept over the last one, five and fifteen minutes (what
/// Bukkit's `Server.getTPS` reports; vanilla keeps no such average): measured between
/// recorded tick ends, so paused passes count where they are recorded. Starts at 20.
pub fn tps(&self) -> [f64; 3] {
self.tps
}
/// `getTickTimesNanos` as vanilla lays it out: the tick numbered `n` (from 1, as
/// `MinecraftServer.tickCount` after its increment) at index `n % 100`, so the
/// newest is at the recorded ticks' count `% 100`; 0 where no tick was recorded yet.
pub fn tick_times_nanos(&self) -> Vec<u64> {
(0..SAMPLES)
.map(|i| self.tick_times[(i + SAMPLES - 1) % SAMPLES].max(0) as u64)
.collect()
}
/// `getAverageTickTimeNanos`.
pub fn average_nanos(&self) -> u64 {
@@ -547,6 +590,51 @@ impl FixedStep {
mod tests {
use super::*;
/// Twenty ticks a second keep 20; ten a second pull the one-minute average down
/// fastest; a second not yet over changes nothing.
#[test]
fn tps_averages_follow_the_ticks_a_second() {
let mut clock = FixedStep::new(false);
let t0 = std::time::Instant::now();
let ms = std::time::Duration::from_millis;
for i in 0..=40 {
clock.count_tick(t0 + ms(50 * i));
}
assert_eq!(
clock.tps().map(|v| (v * 1000.0).round() / 1000.0),
[20.0; 3]
);
let slow = t0 + ms(2000);
for i in 1..=10 {
clock.count_tick(slow + ms(100 * i));
}
let [one, five, fifteen] = clock.tps();
assert!(
one < five && five < fifteen && fifteen < 20.0,
"{:?}",
clock.tps()
);
assert!((one - (20.0 - 10.0 * (1.0 - (-1.0f64 / 60.0).exp()))).abs() < 1e-9);
clock.count_tick(slow + ms(1050));
assert_eq!(clock.tps(), [one, five, fifteen]);
}
/// The tick times sit where vanilla's array has them: tick n at n % 100.
#[test]
fn tick_times_are_laid_out_as_vanillas() {
let mut clock = FixedStep::new(false);
assert_eq!(clock.tick_times_nanos(), vec![0; SAMPLES]);
for n in 1..=150u64 {
clock.record(std::time::Duration::from_nanos(n));
}
let times = clock.tick_times_nanos();
assert_eq!(times.len(), SAMPLES);
assert_eq!(times[50], 150, "the newest, tick 150");
assert_eq!(times[51], 51, "tick 51, overwritten by none yet");
assert_eq!(times[0], 100);
assert_eq!(times[1], 101);
}
#[test]
fn timer_carries_fractions_and_caps_nothing_itself() {
let mut t = Timer::new(20.0, 0);
+11 -8
View File
@@ -308,12 +308,13 @@ impl DebugEntries {
pub fn is_using_profile(&self, profile: Profile) -> bool {
self.profile == Some(profile)
}
/// `getStatus`: never for an entry the statuses leave out.
/// `getStatus`: never for an entry the statuses leave out. The debug renderers ask every
/// frame, so no identifier is built for the lookup.
pub fn status(&self, path: &str) -> Status {
self.statuses
.get(&identifier(path))
.copied()
.unwrap_or(Status::Never)
.iter()
.find(|(id, _)| id.strip_prefix("minecraft:") == Some(path))
.map_or(Status::Never, |(_, status)| *status)
}
/// `setStatus`: the statuses are custom from now on, and saved.
pub fn set_status(&mut self, path: &str, status: Status) {
@@ -343,11 +344,13 @@ impl DebugEntries {
};
shown && entry(path).is_some_and(|e| e.reduced || !reduced)
}
/// `getCurrentlyEnabled().isEmpty()` negated: something to draw.
/// `getCurrentlyEnabled().isEmpty()` negated: something to draw. Asked every frame, F3 closed
/// or not, so it walks the few statuses kept rather than every entry.
pub fn any_enabled(&self, overlay: bool, reduced: bool) -> bool {
ENTRIES
.iter()
.any(|e| self.enabled(e.path, overlay, reduced))
self.statuses
.keys()
.filter_map(|id| id.strip_prefix("minecraft:"))
.any(|path| self.enabled(path, overlay, reduced))
}
}
+96 -8
View File
@@ -3,10 +3,12 @@
//! them last, over any screen, at the debug GUI scale.
use crate::{
font::Font,
hud::HudVertex,
hud_layout::{GuiDraw, PoseOp, fill},
java_text,
text::Text,
};
use std::time::{Duration, Instant};
/// `extractLines`'s background: ARGB 0x90505050.
const BACKGROUND: u32 = 0x9050_5050;
@@ -15,6 +17,39 @@ const TEXT: u32 = 0xFFE0_E0E0;
/// `Font.lineHeight`.
const LINE_HEIGHT: i32 = 9;
/// How long the painted overlay is reused: 20 times a second, the tick rate most of its lines
/// change at or below (fps once a second, memory twice).
const REPAINT: Duration = Duration::from_millis(50);
/// Least time between frames timed for the overlay's GPU lines. Their medians still span about
/// 1.5 s of samples.
pub const GPU_TIMING_GAP: Duration = Duration::from_millis(100);
/// The overlay's painted vertices between rebuilds: copying them is far cheaper than formatting
/// its lines and laying out their glyphs each frame.
#[derive(Default)]
pub struct PaintCache {
vertices: Vec<HudVertex>,
/// When they were painted, and for what layout (see [`PaintCache::fresh`]).
painted: Option<(Instant, PaintLayout)>,
}
/// What the painted vertices depend on besides the lines: the framebuffer size, the GUI scale's
/// bits and `Font::epoch` (glyphs move in the atlas when it changes).
pub type PaintLayout = ([u32; 2], u32, u64);
impl PaintCache {
/// The vertices, while they are recent and painted for this `layout`.
pub fn fresh(&self, now: Instant, layout: PaintLayout) -> Option<&[HudVertex]> {
let (at, painted) = self.painted?;
(painted == layout && now.duration_since(at) < REPAINT).then_some(&self.vertices)
}
pub fn store(&mut self, now: Instant, layout: PaintLayout, vertices: &[HudVertex]) {
self.vertices.clear();
self.vertices.extend_from_slice(vertices);
self.painted = Some((now, layout));
}
}
/// What the enabled entries reported (`DebugScreenDisplayer`).
#[derive(Default, Debug)]
pub struct Displayer {
@@ -422,8 +457,9 @@ pub fn light_line(sky: u8, block: u8) -> String {
}
/// What the process holds, for `DebugEntryMemory` (Horizon has no JVM heap):
/// anonymous resident memory for the heap's used part, all resident memory
/// for its committed part, the machine's memory for its maximum.
/// anonymous (on Windows, private) resident memory for the heap's used part,
/// all resident memory for its committed part, the machine's memory for its
/// maximum.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ProcessMemory {
pub used: u64,
@@ -432,14 +468,20 @@ pub struct ProcessMemory {
}
impl ProcessMemory {
/// From `/proc/self/status` and `/proc/meminfo`; `None` elsewhere.
/// From `/proc/self/status` and `/proc/meminfo` on Linux, the process's
/// counters on Windows; `None` elsewhere.
pub fn read() -> Option<Self> {
if !cfg!(target_os = "linux") {
return None;
if cfg!(target_os = "linux") {
let status = std::fs::read_to_string("/proc/self/status").ok()?;
let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
return Self::parse(&status, &meminfo);
}
let status = std::fs::read_to_string("/proc/self/status").ok()?;
let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
Self::parse(&status, &meminfo)
let [used, allocated, max] = horizon_threads::process_memory::usage()?;
Some(Self {
used,
allocated,
max: max.max(1),
})
}
fn parse(status: &str, meminfo: &str) -> Option<Self> {
let kib = |text: &str, key: &str| -> Option<u64> {
@@ -510,6 +552,38 @@ mod tests {
v.iter().map(|s| (*s).to_owned()).collect()
}
#[test]
fn painted_vertices_are_reused_until_stale_or_laid_out_again() {
let vertex = HudVertex {
pos: [1.0, 2.0],
uv: [0.0; 2],
color: 7,
depth: 0.0,
glint_uv: [0.0; 2],
text: 1,
};
let layout = ([1920, 1080], 2.0f32.to_bits(), 5);
let start = Instant::now();
let mut cache = PaintCache::default();
assert!(cache.fresh(start, layout).is_none());
cache.store(start, layout, &[vertex]);
let just_before = start + Duration::from_millis(49);
assert_eq!(cache.fresh(just_before, layout).map(<[_]>::len), Some(1));
// Stale after a twentieth of a second, so the stats keep moving.
assert!(cache.fresh(start + REPAINT, layout).is_none());
// Another window size, GUI scale or glyph layout repaints at once.
for other in [
([1280, 720], layout.1, layout.2),
(layout.0, 3.0f32.to_bits(), layout.2),
(layout.0, layout.1, 6),
] {
assert!(cache.fresh(just_before, other).is_none());
}
// A smaller repaint leaves nothing of the last one behind.
cache.store(start, layout, &[]);
assert_eq!(cache.fresh(start, layout).map(<[_]>::len), Some(0));
}
#[test]
fn looking_at_lines_match_vanilla() {
let props = [
@@ -611,6 +685,20 @@ mod tests {
);
}
#[cfg(windows)]
#[test]
fn the_memory_lines_show_on_windows() {
let m = ProcessMemory::read().expect("Windows reports the process's memory");
assert!(
0 < m.used && m.used <= m.allocated && m.allocated <= m.max,
"{m:?}"
);
let lines = MemoryMeter::default()
.lines()
.expect("the meter reads the same counters");
assert!(lines[0].starts_with("Mem: "), "{lines:?}");
}
#[test]
fn columns_follow_the_vanilla_split() {
// The default profile's entries, sorted by identifier as
+33 -3
View File
@@ -666,7 +666,7 @@ impl ItemEntity {
wanted
} else {
// `Entity.collide`: the entities that `canBeCollidedWith` the item stop it
// too (a boat, a minecart, a shulker, a happy ghast held still), and
// too (a boat, a shulker, a happy ghast held still; not a minecart), and
// so does the world border (`Entity.collectColliders`).
crate::collisions::EntityCollisions {
blocks: scene,
@@ -1313,6 +1313,35 @@ impl ExperienceOrb {
border: Option<&BorderGeometry>,
fluids: &crate::motion::FluidResources,
on_sound: &mut dyn FnMut([f64; 3], f32),
) -> bool {
self.tick_among(
world,
facts,
collision,
player,
server,
next_float,
border,
fluids,
on_sound,
&[],
)
}
/// [`Self::tick`], its move also stopped by `entities`: the boxes of the
/// entities that `canBeCollidedWith` it (`EntityGetter.getEntityCollisions`).
#[allow(clippy::too_many_arguments)]
pub fn tick_among(
&mut self,
world: &World,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
player: Option<[f64; 3]>,
server: bool,
next_float: &mut dyn FnMut() -> f32,
border: Option<&BorderGeometry>,
fluids: &crate::motion::FluidResources,
on_sound: &mut dyn FnMut([f64; 3], f32),
entities: &[Aabb],
) -> bool {
self.ticks += 1;
self.previous = self.position;
@@ -1374,10 +1403,11 @@ impl ExperienceOrb {
// `double fallSpeed = getDeltaMovement().y`, taken after the stuck
// push and before the move.
let wanted = self.velocity;
// Entity.collide: the world border's walls stop the orb too.
// Entity.collide: the world border's walls and the entities that
// `canBeCollidedWith` it (a boat, a shulker) stop the orb too.
let border = border.map(|b| (b, [self.position.x, self.position.z]));
let moved = scene
.resolve_in_border(bounds, wanted, 0.0, self.on_ground, border)
.resolve_in_border_with(bounds, wanted, 0.0, self.on_ground, border, entities)
.unwrap_or(Vec3::ZERO);
self.position = Vec3::new(
self.position.x + moved.x,
+17
View File
@@ -7763,6 +7763,23 @@ mod generic_home_nbt_tests {
])
}
#[test]
fn spawned_trader_home_restricts_and_survives_save() {
let mut m = mob(Species::WanderingTrader, "minecraft:wandering_trader");
crate::mobs::trader::start_visit(&mut m, [4, 201, 0]);
m.position = Vec3::new(19.5, 201.0, 0.5);
assert!(crate::mobs::trader::within_home(&m));
m.position = Vec3::new(20.5, 201.0, 0.5);
assert!(!crate::mobs::trader::within_home(&m));
let saved = mob_to_nbt(&m, [0; 4], None, None, |_| None);
assert_eq!(saved.get("home_radius"), Some(&Tag::Int(16)));
assert_eq!(saved.get("home_pos"), Some(&Tag::IntArray(vec![4, 201, 0])));
let mut loaded = mob(Species::WanderingTrader, "minecraft:wandering_trader");
loaded.position = m.position;
read_mob_home(&mut loaded, &saved);
assert!(!crate::mobs::trader::within_home(&loaded));
}
#[test]
fn generic_home_output_uses_model_and_removes_stale_raw_fields() {
let mut m = mob(Species::Pig, "minecraft:pig");
+61
View File
@@ -46,6 +46,8 @@ pub fn main_with(mods: crate::mod_api::Mods) {
}
(logging::game_home(), "client", "Render thread")
};
// Before the log opens, so a mod's injection hears its first lines.
logging::set_line_hook(crate::native_client_hooks::native_log_line);
if !help {
logging::init(
logging::player_dir_override().unwrap_or(home.as_path()),
@@ -98,6 +100,35 @@ pub fn main_with(mods: crate::mod_api::Mods) {
{
log::warn!("Native data-load hooks already installed; keeping the first callbacks");
}
if horizon_world::worldgen::native_generate::install(
horizon_world::worldgen::native_generate::NativeGenerateHooks {
chunk_generate: |dimension, chunk, seed, flat, chunk_out, generate| {
crate::mod_api::dispatch_chunk_generate(
crate::mod_api::mods(),
dimension,
chunk,
seed,
flat,
chunk_out,
generate,
);
},
lod_column: |dimension, column, seed, flat, column_out| {
crate::mod_api::dispatch_lod_column(
crate::mod_api::mods(),
dimension,
column,
seed,
flat,
column_out,
);
},
},
)
.is_err()
{
log::warn!("Native generation hooks already installed; keeping the first callbacks");
}
if crate::native_client_hooks::install(crate::native_client_hooks::NativeClientHooks {
menu_slot_rules: |menu, slot, stack, may_place, max_stack| {
crate::mod_api::dispatch_menu_slot_rules(
@@ -168,6 +199,23 @@ pub fn main_with(mods: crate::mod_api::Mods) {
register_goals: |mob| {
crate::mod_api::dispatch_register_goals(crate::mod_api::mods(), mob);
},
player_pre_login: |player, name, address, defer| {
crate::mod_api::dispatch_player_pre_login(
crate::mod_api::mods(),
player,
name,
address,
defer,
);
},
player_profile: |player, name, properties| {
crate::mod_api::dispatch_player_profile(
crate::mod_api::mods(),
player,
name,
properties,
);
},
ingredient: |kind, data, items| {
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
},
@@ -180,6 +228,19 @@ pub fn main_with(mods: crate::mod_api::Mods) {
accept,
);
},
log_line: |level, thread, message, line| {
crate::mod_api::dispatch_log_line(crate::mod_api::mods(), level, thread, message, line);
},
handshake: |address, host, intent, cancel, reason| {
crate::mod_api::dispatch_handshake(
crate::mod_api::mods(),
address,
host,
intent,
reason,
cancel,
);
},
})
.is_err()
{
+63 -3
View File
@@ -78,9 +78,6 @@ impl FurnaceCooking for Furnace {
if lit || has_fuel && has_ingredient {
if let Some(ingredient) = self.items[0].clone() {
if let Some(recipe) = recipes.cook(self.kind.recipe_type(), &ingredient) {
if self.cooking_timer == 0 {
self.cooking_total = total_time(recipe.time, self.speed);
}
let max = items.get(&recipe.result.item).map_or(64, |i| i.max_stack);
let can_burn = match &self.items[2] {
None => true,
@@ -250,6 +247,69 @@ mod tests {
(items, recipes, providers)
}
#[test]
fn loaded_totals_survive_until_actual_completion() {
let (items, recipes, providers) = data("loaded-total");
assert_eq!(
recipes
.cook(
FurnaceKind::Furnace.recipe_type(),
&ItemStack::new("test:ore", 3)
)
.unwrap()
.time,
200
);
// Fresh Java's five-case Game fixture independently admits the loaded
// zero (including a missing saved field), five and normal totals.
for (total, timer, completion) in [(0, 0, 1), (5, 0, 5), (200, 0, 200), (5, 1, 4)] {
let mut f = Furnace {
kind: FurnaceKind::Furnace,
items: [Some(ItemStack::new("test:ore", 3)), None, None],
lit_remaining: 20,
lit_total: 20,
cooking_timer: timer,
cooking_total: total,
speed: 1.0,
recipes_used: HashMap::new(),
};
for elapsed in 1..=6 {
let mut drops = Vec::new();
assert!(!f.idle());
assert_eq!(f.tick(&recipes, &items, &providers, &mut drops), None);
assert!(drops.is_empty());
assert_eq!((f.lit_remaining, f.lit_total), (20 - elapsed, 20));
assert!(f.items[1].is_none());
let complete = elapsed >= completion;
assert_eq!(
f.items[0].as_ref().unwrap().count,
if complete { 2 } else { 3 },
"loaded {total}/{timer}, tick {elapsed}"
);
assert_eq!(
f.items[2].as_ref().map(|s| (&*s.item, s.count)),
if complete {
Some(("test:ingot", 1))
} else {
None
}
);
assert_eq!(f.cooking_total, if complete { 200 } else { total });
assert_eq!(
f.cooking_timer,
if complete {
elapsed - completion
} else {
timer + elapsed
}
);
}
// A changed input still goes through setItem's independent reset.
f.set_input(&recipes, Some(ItemStack::new("test:ingot", 1)));
assert_eq!((f.cooking_timer, f.cooking_total), (0, 200));
}
}
#[test]
fn cooking_requires_output_components_and_capacity() {
let (items, recipes, providers) = data("output-components");
+198 -30
View File
@@ -223,8 +223,10 @@ mod native_biome_colors;
pub mod native_block_entity_draw;
mod native_block_sprites;
mod native_bots;
mod native_chunk_snapshot;
mod native_chunk_tickets;
mod native_claimed_items;
mod native_client_entities;
mod native_client_particles;
mod native_client_particles_fixture;
pub mod native_client_services;
@@ -232,7 +234,9 @@ mod native_client_world;
mod native_components;
mod native_configs;
mod native_criteria;
mod native_damage;
mod native_data_maps;
mod native_diagnostics;
mod native_entities;
mod native_entity_data;
mod native_entity_motion;
@@ -256,8 +260,15 @@ mod native_mobs;
mod native_motion;
mod native_mounts;
mod native_net;
mod native_npcs;
mod native_packets;
mod native_panel;
mod native_panel_recipes;
mod native_permissions;
mod native_player_fx;
mod native_player_reads;
mod native_player_state;
mod native_profiles;
mod native_projectiles;
mod native_quick_move;
mod native_recipes;
@@ -267,11 +278,15 @@ mod native_server;
mod native_shaders;
mod native_structures;
mod native_ticks;
mod native_travel;
mod native_view_distance;
mod native_visuals;
mod native_world_border;
mod native_world_columns;
pub mod native_world_draw;
mod native_world_edit;
mod native_world_read;
mod native_world_seed;
mod natural_spawning;
mod nautili;
mod net_harness;
@@ -459,6 +474,7 @@ use horizon_world::{
pub use machines::native_fluids::NativeFluidTank;
pub use maps::{NativeMapDecoration, NativeMapInfo};
pub use native_block_entity_draw::BlockEntityDraw;
pub use native_chunk_snapshot::NativeChunkSnapshot;
pub use native_claimed_items::{NativeHeldItem, NativeHit, NativeModEvent};
pub use native_client_world::{NativeBiomeId, NativeClientBiomes, NativeClientChunks};
pub use native_components::NativeComponentError;
@@ -467,8 +483,10 @@ pub use native_criteria::NativeCriterion;
pub use native_data_maps::NativeDataMapType;
pub use native_falling::NativeFalling;
pub use native_hooks::{NativeClock, NativeItemEntity, NativePlayer, NativeShake};
pub use native_inventories::NativeInventoryKind;
pub use native_item_energy::ItemEnergyBuffer;
pub use native_mob_draw::{NativeMobRender, NativePlayerRender};
pub use native_player_state::NativeEffect;
pub use native_reload_listeners::NativeReloadListener;
pub use native_server::NativeExperienceOrb;
pub use native_structures::{NativeStructureBlock, NativeStructureTemplate};
@@ -1112,6 +1130,24 @@ impl EntityObstructionWorld for BuildingObstruction {
.collect())
}
}
/// Whether a block's successful use is `InteractionResult.SUCCESS_SERVER`,
/// which a client does not predict: `useWithoutItem` of a bed, a light
/// block, a charged respawn anchor or a sign. Every other block use the
/// level answers with SUCCESS is `SUCCESS` (`SwingSource.PREDICTED`).
fn server_only_block_use(
block: &horizon_items::blocks::StateFacts,
branch: horizon_world::level::UseBranch,
) -> bool {
branch == horizon_world::level::UseBranch::WithoutItem
&& [
"AbstractBedBlock",
"LightBlock",
"RespawnAnchorBlock",
"SignBlock",
]
.iter()
.any(|class| block.has_class(class))
}
/// An entity's box (`EntityDimensions.makeBoundingBox`): `width` wide and
/// `height` tall, standing on `feet`.
fn point_box(feet: [f64; 3], width: f64, height: f64) -> Aabb {
@@ -1252,6 +1288,11 @@ impl LevelAccess for GameLevel<'_> {
self.native_neighbor_changed(self.dimension, pos, state, changed, moved);
}
}
fn redstone_change(&self, pos: BlockPos, old: i32, new: &mut i32) {
if self.native_authoritative {
self.native_block_redstone(self.dimension, pos, old, new);
}
}
fn allow_spread(
&self,
kind: horizon_world::level::Spread,
@@ -2679,6 +2720,8 @@ struct Game<'a> {
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
/// Which entity this frame's entity vertices belong to (`Frame::entity_motion`).
entity_motion: std::cell::RefCell<Vec<(u32, u64)>>,
/// The GPU-posed vertex count at each of `entity_motion`'s marks (`Frame::entity_motion_posed`).
entity_motion_posed: std::cell::RefCell<Vec<u32>>,
/// Mob models posed on the GPU (`Frame::skin`, `mobs::skin`): the models stored there,
/// and this frame's draws.
skin_models: std::cell::RefCell<horizon_client::mobs::skin::SkinModels>,
@@ -2872,6 +2915,9 @@ struct Game<'a> {
/// The arm swing (`LivingEntity.SwingState`) and how many swings started.
swing: horizon_client::first_person::SwingState,
swings_started: u32,
/// Swings of uses whose client predicts them (`SwingSource.PREDICTED`):
/// a server does not send these back to the player who swung.
predicted_swings: u32,
/// The first-person equip heights and the items the hands show (with
/// the slot each came from).
heights: horizon_client::first_person::HandHeights,
@@ -2964,6 +3010,8 @@ struct Game<'a> {
map_frame_model: frames::FrameModel,
glow_map_frame_model: frames::FrameModel,
armor_stands: horizon_client::join_seq::JoinList<horizon_client::entities::ArmorStand>,
/// Worn armor as of the last `collectEquipmentChanges` look (frost walker).
location_armor: [Option<ItemStack>; 4],
/// `Cushion` decoration entities (game/cushions.rs).
cushions: Vec<horizon_client::entities::Cushion>,
/// Entities of types Horizon does not simulate.
@@ -2999,6 +3047,9 @@ struct Game<'a> {
carts: horizon_client::join_seq::JoinList<minecarts::Minecart>,
/// The boats and minecarts `native_vehicle_moved` follows (lan_vehicles.rs).
watched_vehicles: Vec<lan_vehicles::WatchedVehicle>,
/// What the mods asked for server players out of reach (`native_teleport_player`...),
/// done after the players' packets of the tick (native_travel.rs).
native_moves: Vec<(u128, native_travel::NativeMove)>,
/// Songs playing from jukeboxes (tracked sound handles).
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
bolts: Vec<lightning::Bolt>,
@@ -3133,6 +3184,10 @@ struct Game<'a> {
native_configs: native_configs::NativeConfigs,
/// Native mods' messages waiting (`game/native_net.rs`).
native_net: native_net::NativeNet,
/// What native mods send one player, waiting for the hub (`game/native_player_fx.rs`).
native_player_fx: native_player_fx::NativePlayerFx,
/// Native mods' kicks waiting for the tick's end (`game/native_packets.rs`).
native_packets: native_packets::NativePackets,
/// The mod whose own screen is open (`Screen::Mod`).
native_mod_screen: Option<String>,
/// Meshed sections waiting for an upload budget (Horizon settings).
@@ -3217,6 +3272,10 @@ struct Game<'a> {
debug_options: Option<debug_options::DebugOptionsUi>,
/// `DebugEntryMemory`'s readings (game/debug_lines.rs).
memory_meter: std::cell::RefCell<horizon_client::debug_overlay::MemoryMeter>,
/// The F3 overlay's vertices between rebuilds (`draw_debug_overlay_under`).
debug_paint: RefCell<horizon_client::debug_overlay::PaintCache>,
/// The F3 overlay's last reading of the refresh rate, and when it was taken.
debug_refresh: std::cell::Cell<Option<(Instant, Option<u32>)>>,
/// `Minecraft.gpuUtilization`: the last timed frame's GPU time over its
/// CPU time, in percent.
gpu_utilization: f64,
@@ -3275,6 +3334,9 @@ struct Game<'a> {
/// The last `use_hand` that ended the click did so with `FAIL`, which
/// does not consume the action (handleUseItemOn's ANY_BLOCK_USE).
use_failed: bool,
/// A mod canceled a use the acting guest's client predicted (a throw, a
/// placement): their inventory goes out whole when the turn ends.
guest_inventory_resend: bool,
/// Level events of this tick for the guests, and the guest acting now.
lan_events: Vec<(i32, [i32; 3], i32, Option<u32>)>,
/// Jukebox level events 1010 (song name) and 1011 (`None`) for the guests;
@@ -3327,6 +3389,9 @@ struct Game<'a> {
lan_acting_name: Option<String>,
/// Their UUID (`Player.getUUID`).
lan_acting_uuid: Option<u128>,
/// The player and the stack on their cursor as they clicked a native
/// menu, while `native_menu_clicked` hears it (`native_menu_cursor`).
native_click_cursor: Option<(u128, Option<horizon_client::items::ItemStack>)>,
/// Where the mods refused to prime TNT: the sound and game event the level
/// queued right after the prime are dropped (`prime_tnt_event`).
tnt_refused: Option<BlockPos>,
@@ -3335,6 +3400,9 @@ struct Game<'a> {
/// Their entity id, which the hub (often taken out of `lan` while they
/// act) would otherwise have to be asked for.
lan_acting_entity: Option<u32>,
/// The player `native_player_login` hears and their permission level, while it runs
/// (`native_player_permission`): the hub is out of reach there.
native_login_permission: Option<(u128, u8)>,
/// Command feedback to forward to a guest.
chat_capture: Option<Vec<horizon_client::text::Text>>,
/// Packets for the guest acting now, sent once its action returns.
@@ -3342,6 +3410,9 @@ struct Game<'a> {
/// The acting guest's cooldowns, as (item, ticks): their packet needs the
/// hub's item registry, which the guest's turn does not hold.
guest_cooldowns: Vec<(Arc<str>, i32)>,
/// Players whose command tree the mods asked to send again
/// (`native_resend_commands`), sent in the next server tick.
native_resend_trees: Vec<u128>,
/// The acting guest's own entity events (`ServerPlayer.connection.send`),
/// sent with their entity id, which the guest's turn does not hold.
guest_entity_events: Vec<i8>,
@@ -4368,6 +4439,15 @@ impl Game<'_> {
self.award_stat("dropped", &thrown.item, thrown.count as i32);
self.award_custom_stat("drop", 1);
let player = self.native_acting_id();
let mut cancel = false;
self.native_item_dropped_v2(player, &thrown, &mut cancel);
if cancel {
// The stack stays the player's: what doesn't fit is thrown after all.
if let Some(rest) = self.give(thrown) {
self.throw_from_eyes(rest, eye, true);
}
return;
}
self.native_item_dropped(player, &thrown.item, thrown.count);
self.throw_from_eyes(thrown, eye, true);
}
@@ -4644,7 +4724,7 @@ impl Game<'_> {
&& b.max_position().z > min[2];
// ItemEntity.playerTouch: no pickup delay, and the target (if any) is this player.
if touching && item.pickup_delay == 0 && item.target.is_none_or(|t| t == own) {
if self.native_pickup_refused(&item.stack) {
if self.native_pickup_refused(&item.stack, item.thrower, item.position) {
item.pickup_delay = 10;
kept.push(item);
continue;
@@ -6156,11 +6236,12 @@ impl Game<'_> {
/// One falling block's `tick`: places or drops its block in its own tick,
/// so a block falling onto one that just landed lands on top of it. `i` is
/// its place in the list; the caller has checked it is ticking.
fn tick_one_falling(&mut self, i: usize) -> Result<()> {
/// `entities` are the boxes that stop its move (`Game::item_entity_collisions`).
fn tick_one_falling(&mut self, i: usize, entities: &[Aabb]) -> Result<()> {
let entity_drops = self.ui.rules.bool("entity_drops");
let mut f = self.falling.remove(i);
let original = f.clone();
match self.fall_step(&mut f, entity_drops) {
match self.fall_step(&mut f, entity_drops, entities) {
Ok(FallStep::Falling) => self.falling.insert(i.min(self.falling.len()), f),
Ok(FallStep::Gone) => {}
Ok(FallStep::Dropped(at)) => self.drop_falling_block(f.state, at),
@@ -6173,7 +6254,12 @@ impl Game<'_> {
Ok(())
}
/// One `FallingBlockEntity.tick` move of `f`.
fn fall_step(&mut self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
fn fall_step(
&mut self,
f: &mut FallingBlock,
entity_drops: bool,
entities: &[Aabb],
) -> Result<FallStep> {
f.previous = f.position;
f.age = f.age.wrapping_add(1);
f.velocity.y -= horizon_client::entity_rules::EntityRules::get_gravity(f);
@@ -6207,10 +6293,11 @@ impl Game<'_> {
false,
);
// On an error the caller keeps the block as it was before this tick.
// Entity.collide: the world border's walls stop it too.
// Entity.collide: the world border's walls and the entities that
// `canBeCollidedWith` it (a boat, a shulker) stop it too.
let movement = collisions::EntityCollisions {
blocks: scene,
entities: &[],
entities,
border: Some((self.border.geometry(), [f.position.x, f.position.z])),
}
.resolve_with_context(
@@ -6723,6 +6810,7 @@ impl Game<'_> {
}
if !claimed {
self.native_interact_entity(id, &kind, stack.as_ref(), &mut claimed);
self.native_interact_entity_v2(id, &kind, self.hand, stack.as_ref(), &mut claimed);
}
if claimed {
self.swing_arm();
@@ -6821,6 +6909,8 @@ impl Game<'_> {
let mut canceled = false;
horizon_client::spans::time("use_mods", || {
self.native_right_click_block(hit.pos, hit.face, &mut canceled);
let hand = self.hand;
self.native_right_click_block_v2(hit.pos, hit.face, hand, &mut canceled);
});
if canceled {
// Nothing was used, so nothing consumed the action.
@@ -7006,7 +7096,13 @@ impl Game<'_> {
}
}
if result.should_swing() {
self.swing_arm();
// SUCCESS swings on the client too; the beds, light,
// respawn anchor and sign's own uses are SUCCESS_SERVER.
if server_only_block_use(self.res.facts().get(cake), branch) {
self.swing_arm();
} else {
self.swing_arm_predicted();
}
}
return Ok(true);
}
@@ -7246,6 +7342,13 @@ impl Game<'_> {
item: stack.item.to_string(),
can_use_game_master_blocks: self.can_use_game_master_blocks(),
};
// The mods, once the position and state are known: a canceled
// placement is a failed one, and the client's prediction is undone.
if self.block_place_canceled(&stack, &ctx) {
self.use_failed = true;
self.guest_inventory_resend = true;
return Ok(true);
}
let outcome = self.with_level(|b, level, ticks| b.place_block(level, ticks, &ctx));
let PlaceOutcome::Placed { pos, state } = outcome else {
self.process_level_events()?;
@@ -7284,7 +7387,8 @@ impl Game<'_> {
_ => {}
}
}
self.swing_arm();
// BlockItem.place's SUCCESS: the client swings with its own prediction.
self.swing_arm_predicted();
// BlockItem.place's sound is among the level events;
// SolidBucketItem.getPlaceSound replaces it with the bucket's own.
let solid_bucket = &*stack.item == "minecraft:powder_snow_bucket";
@@ -7311,6 +7415,30 @@ impl Game<'_> {
}
Ok(true)
}
/// Asks the mods whether the acting guest may place `stack` where and as
/// `BlockItem.place` would for `ctx` (`native_block_place`): true when
/// one canceled it. A game's own player is not asked, nor a placement
/// vanilla refuses.
fn block_place_canceled(&mut self, stack: &ItemStack, ctx: &PlaceContext) -> bool {
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
return false;
};
let Some((pos, state)) =
self.with_level(|b, level, ticks| b.placement_state(level, ticks, ctx))
else {
return false;
};
let mut cancel = false;
self.native_block_place(
uuid,
&name,
stack,
[pos.x, pos.y, pos.z],
state,
&mut cancel,
);
cancel
}
/// Asks the mods whether the acting guest may empty `stack` at `pos`
/// (`native_bucket_empty`): true when one cancelled it. A game's own
/// player is not asked.
@@ -7544,12 +7672,18 @@ impl Game<'_> {
.instrument(&stack)
.map(|i| i.use_ticks().max(0) as u32)
{
// InstrumentItem.getUseDuration: held up that long, nothing more.
self.using = (ticks + 1 < duration).then_some((slot, ticks + 1));
// InstrumentItem.getUseDuration: held up that long. When it runs
// out, LivingEntity.stopUsingItem ends the use and causes the
// interaction vibration; letting go of the button already does.
if ticks + 1 < duration {
self.using = Some((slot, ticks + 1));
} else {
self.stop_using_item(&stack);
}
return;
}
if &*stack.item == "minecraft:brush" {
self.tick_brush(slot, ticks);
self.tick_brush(&stack, slot, ticks);
return;
}
if info.bundle {
@@ -9053,9 +9187,6 @@ impl Game<'_> {
let spectator = self.spectator();
let (wi, hi) = (w as i32, h as i32);
let show_hud = !self.ui.hide_gui;
// max(Hud.leftHeight, Hud.rightHeight) after the bars: the item name and
// the action bar sit above it.
let mut bar_height = 39;
if show_hud {
self.draw_spyglass(&mut batch);
// Hud.extractCrosshair / extractItemHotbar / extractPlayerHealth /
@@ -9154,8 +9285,6 @@ impl Game<'_> {
} else {
self.draw_mount_hud(&mut batch, wi, hi)
};
// Without the survival bars only the mount's hearts raise it.
bar_height = 39 + 10 * ((vehicle_hearts + 9) / 10);
// The one bar above the hotbar: the mount's jump bar, the locator
// bar (with its background) or the experience bar, with the level
// number that a survival player always has.
@@ -9248,10 +9377,6 @@ impl Game<'_> {
self.gui_layer("minecraft:player_health", &mut batch, |g, batch| {
hud_layout::paint(batch, &g.gui.atlas, &draws);
});
bar_height = self
.health_hud
.left_height
.max(self.health_hud.right_height);
// Hud.playAirBubblePoppedSound: Player.playSound (PLAYERS), heard
// by this client only.
if let Some((volume, pitch)) = self.health_hud.bubble_pop_sound.take() {
@@ -9267,7 +9392,7 @@ impl Game<'_> {
}
if !spectator {
self.gui_layer("minecraft:selected_item_name", &mut batch, |g, batch| {
g.draw_selected_name(batch, bar_height, !g.creative);
g.draw_selected_name(batch, !g.creative);
});
}
}
@@ -9278,7 +9403,7 @@ impl Game<'_> {
});
if show_hud {
self.gui_layer("minecraft:overlay_message", &mut batch, |g, batch| {
g.draw_overlay_message(batch, bar_height);
g.draw_overlay_message(batch);
});
self.gui_layer("minecraft:title", &mut batch, |g, batch| {
g.draw_title(batch);
@@ -11393,6 +11518,10 @@ impl Game<'_> {
/// generation to follow its own motion. Each run lasts until the next mark.
pub(super) fn mark_motion(&self, start: usize, id: u64) {
self.entity_motion.borrow_mut().push((start as u32, id));
// Where its GPU-posed vertices start (`entity_skin`): a shader pack's `at_velocity`
// follows those too.
let posed = self.skin_frame.borrow().vertices;
self.entity_motion_posed.borrow_mut().push(posed);
}
/// ItemEntityRenderer: items bob and spin; blocks render as small cubes.
fn entity_geometry(
@@ -11414,6 +11543,7 @@ impl Game<'_> {
let mut static_block_entity_stats = StaticCacheFrameStats::default();
self.entity_spans.borrow_mut().clear();
self.entity_motion.borrow_mut().clear();
self.entity_motion_posed.borrow_mut().clear();
// LevelExtractor.extract: the transient blocks queued since the last frame.
self.drain_transient_block_queue();
self.prepare_entity_culling();
@@ -12276,8 +12406,12 @@ impl Game<'_> {
if let Some(hit) = self.tick_mob {
return hit;
}
self.mob_hit_within(self.entity_reach)
}
/// [`Game::mob_hit`] out to `reach` instead of the entity interaction range
/// (`ProjectileUtil.getHitResultOnViewVector` with another range).
pub(super) fn mob_hit_within(&self, reach: f64) -> Option<(usize, f64, [f64; 3])> {
let (eye, dir) = self.aim()?;
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64, [f64; 3])> = None;
for (i, m) in self.mobs.iter().enumerate() {
// ProjectileUtil.getEntityHitResult: the mob the player rides
@@ -13471,6 +13605,7 @@ impl Game<'_> {
self.shadow_only_vertices.get_mut().clear();
self.entity_spans.borrow_mut().clear();
self.entity_motion.borrow_mut().clear();
self.entity_motion_posed.borrow_mut().clear();
self.skin_models
.borrow_mut()
.restart(&mut self.skin_frame.borrow_mut());
@@ -15145,7 +15280,7 @@ impl Game<'_> {
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
shader_packs::picked_pack_shape(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
@@ -15186,7 +15321,7 @@ impl Game<'_> {
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
shader_packs::picked_pack_shape(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
@@ -16937,6 +17072,7 @@ impl Game<'_> {
hud.patches.append(&mut self.map_patches);
let entity_spans = std::mem::take(self.entity_spans.get_mut());
let entity_motion = std::mem::take(self.entity_motion.get_mut());
let entity_motion_posed = std::mem::take(self.entity_motion_posed.get_mut());
let glowing = std::mem::take(self.glowing_vertices.get_mut());
let shadow_only = std::mem::take(self.shadow_only_vertices.get_mut());
let skin = std::mem::take(self.skin_frame.get_mut());
@@ -17026,6 +17162,7 @@ impl Game<'_> {
visible_static_block_entity_sections: &visible_static_block_entity_sections,
static_block_entity_stats,
entity_motion: &entity_motion,
entity_motion_posed: &entity_motion_posed,
entity_spans: &entity_spans,
skin: skin.pending().then_some(&skin),
glowing: &glowing,
@@ -17100,9 +17237,16 @@ impl Game<'_> {
pr.lap("pr_frame");
// Renderer encoding and GPU waiting belong to render, not prepare.
drop(prepare);
let debug_passes = self.debug_lists_gpu_passes();
let result = match self.renderer.as_mut() {
Some(renderer) => {
renderer.set_pass_timing(self.debug || self.gpu_passes);
renderer.set_pass_timing(debug_passes || self.gpu_passes);
// `gpu_passes` is a measurement: it takes every sample it can.
renderer.set_pass_timing_gap(if self.gpu_passes {
Duration::ZERO
} else {
horizon_client::debug_overlay::GPU_TIMING_GAP
});
horizon_client::spans::time("renderer", || renderer.render(&frame))
}
None => Err("renderer".into()),
@@ -17114,6 +17258,7 @@ impl Game<'_> {
self.entity_vertices = entities;
*self.entity_spans.get_mut() = entity_spans;
*self.entity_motion.get_mut() = entity_motion;
*self.entity_motion_posed.get_mut() = entity_motion_posed;
*self.glowing_vertices.get_mut() = glowing;
*self.shadow_only_vertices.get_mut() = shadow_only;
let mut skin = skin;
@@ -17545,6 +17690,7 @@ impl Game<'_> {
_ if self.screen == Screen::Dead => {
self.death_key(key)?;
}
_ if self.screen == Screen::Stats => self.stats_key(raw),
_ => {}
}
Ok(())
@@ -18159,6 +18305,7 @@ impl ApplicationHandler for Game<'_> {
if self.window.is_some() {
return;
}
debug_keys::warm_debug_cpu();
// The Window constructor: created fullscreen when options.txt or --fullscreen says so.
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
let size = self.options.window_size.map(f64::from);
@@ -20085,6 +20232,7 @@ impl<'a> Game<'a> {
shadow_only_vertices: Default::default(),
entity_spans: Default::default(),
entity_motion: Default::default(),
entity_motion_posed: Default::default(),
skin_models: Default::default(),
skin_frame: Default::default(),
glowing_vertices: Default::default(),
@@ -20197,6 +20345,7 @@ impl<'a> Game<'a> {
level_up_sound: Default::default(),
swing: Default::default(),
swings_started: 0,
predicted_swings: 0,
heights: Default::default(),
sway: Default::default(),
visible_hands: [(0, None), (0, None)],
@@ -20249,6 +20398,7 @@ impl<'a> Game<'a> {
map_frame_model: prepared.map_frame_model,
glow_map_frame_model: prepared.glow_map_frame_model,
armor_stands: Default::default(),
location_armor: Default::default(),
cushions: Vec::new(),
others: Vec::new(),
armor_stand_assets: prepared.armor_stand_assets,
@@ -20268,6 +20418,7 @@ impl<'a> Game<'a> {
entity_models: prepared.entity_models,
carts: Default::default(),
watched_vehicles: Vec::new(),
native_moves: Vec::new(),
jukebox_sounds: HashMap::new(),
bolts: Vec::new(),
maps: maps::Maps::default(),
@@ -20366,6 +20517,8 @@ impl<'a> Game<'a> {
native_lifecycle: Default::default(),
native_configs: Default::default(),
native_net: Default::default(),
native_player_fx: Default::default(),
native_packets: Default::default(),
native_mod_screen: None,
sky_darken_memo: Default::default(),
water_plane_memo: Default::default(),
@@ -20396,6 +20549,8 @@ impl<'a> Game<'a> {
),
debug_options: None,
memory_meter: Default::default(),
debug_paint: Default::default(),
debug_refresh: Default::default(),
gpu_utilization: 0.0,
rendered_mobs: Default::default(),
screen_blur_requested: Default::default(),
@@ -20421,6 +20576,7 @@ impl<'a> Game<'a> {
block_reach: 4.5,
guest_use: None,
use_failed: false,
guest_inventory_resend: false,
lan_events: Vec::new(),
lan_songs: Vec::new(),
lan_cushion_breaks: Vec::new(),
@@ -20442,14 +20598,17 @@ impl<'a> Game<'a> {
lan_break_pos: None,
guest_packets: Vec::new(),
guest_cooldowns: Vec::new(),
native_resend_trees: Vec::new(),
guest_entity_events: Vec::new(),
guest_equipment_breaks: Vec::new(),
guest_player_sounds: Vec::new(),
lan_acting_name: None,
lan_acting_uuid: None,
native_click_cursor: None,
tnt_refused: None,
lan_acting_permission: None,
lan_acting_entity: None,
native_login_permission: None,
chat_capture: None,
admin_feedback: Vec::new(),
console_broadcast: None,
@@ -21867,7 +22026,7 @@ fn build_renderer(
// HDR with Smooth Gradients (`Graphics::wide_frame`): a 16-bit frame where HDR output is
// offered (`Renderer::new`).
wide_frame: bool,
shader_pack: bool,
shader_pack: shader_packs::PickedPack,
filtering: (i32, i32),
) -> Result<Renderer> {
let assets = &pack.render;
@@ -21906,7 +22065,8 @@ fn build_renderer(
api,
translucent_batching,
wide_frame,
shader_pack,
shader_pack.draws_flat,
shader_pack.linear,
))?;
renderer.hud.sprite_animations = atlas
.sprite_animations
@@ -21978,6 +22138,7 @@ fn loading_renderer(
// An 8-bit frame, which HDR output takes as well.
false,
false,
false,
))?;
if let Some(atlas) = atlas {
renderer.hud.sprite_animations = atlas
@@ -22051,6 +22212,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
visible_static_block_entity_sections: &[],
static_block_entity_stats: Default::default(),
entity_motion: &[],
entity_motion_posed: &[],
entity_spans: &[],
skin: None,
glowing: &[],
@@ -22594,7 +22756,7 @@ impl Loading {
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
self.settings.horizon.wide_frame(),
shader_packs::picked_pack_draws_flat(&self.settings),
shader_packs::picked_pack_shape(&self.settings),
(
self.settings.file.int("textureFiltering"),
self.settings.file.int("maxAnisotropyBit"),
@@ -22878,11 +23040,17 @@ pub fn run_with() -> Result<()> {
{
return Ok(());
}
// The packets the server sends its players reach the mods that ask (native_packets.rs).
let _ = horizon_client::lan::conn::SERVER_PACKET.set(Game::native_server_packet);
let _ = horizon_client::lan::conn::ENTITY_DATA_TO.set(Game::native_entity_data_to);
let _ = horizon_client::health::HURT_SERVER.set(native_hooks::hurt_server);
let _ = horizon_client::health::DAMAGE_PRE.set(native_hooks::damage_pre);
let _ = horizon_client::health::DAMAGE_POST.set(native_hooks::damage_post);
let _ = horizon_client::block_contact::HOOK.set(native_hooks::block_contact);
let _ = horizon_client::health::KNOCKBACK.set(native_hooks::knockback);
let _ = horizon_client::lan::hub::PLAYER_MOVE.set(Game::native_player_move);
let _ = horizon_client::lan::hub::PLAYER_TELEPORT.set(Game::native_player_teleport);
let _ = horizon_client::block_contact::TRAMPLE.set(Game::native_trample);
// Before any worker starts: on Linux they go behind the server thread
// and the render thread.
horizon_threads::put_helpers_behind();
@@ -22910,7 +23078,7 @@ pub fn run_with() -> Result<()> {
{
options.spikes = Some(dedicated::SLOW_TICK_MS);
}
horizon_client::spans::enable(options.spikes.is_some());
tick_profile::want_spans(tick_profile::SPANS_SPIKES, options.spikes.is_some());
// A player who allows performance statistics also says what their slow frames spent their
// time in: this thread, which draws the frames, times its steps (wall time only).
if options.execution_role.is_client()
+60 -41
View File
@@ -392,6 +392,15 @@ impl Game<'_> {
let tree = self.adv.tree.clone();
self.adv.progress = progress;
self.ui.registries.advancements = tree.list.iter().map(|a| a.id.clone()).collect();
// `PlayerAdvancements.checkForAutomaticTriggers`: an advancement with no
// criteria is never done (`AdvancementRequirements.test` is false), but
// its rewards are granted as the progress loads. Vanilla's `award(holder, "")` finds no `""`
// criterion to grant and so returns without a toast or an announcement;
// the reward grant is the whole effect, and it happens again on the next
// load because there is no criterion to record it.
for index in tree.automatic() {
self.grant_rewards(index);
}
}
pub(super) fn save_advancements(&self) {
if !self.adv.loaded || self.remote.is_some() || self.no_player_here() {
@@ -920,47 +929,7 @@ impl Game<'_> {
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_advancement_done(player, &name, &a.id);
}
// AdvancementRewards.grant
if a.rewards.experience > 0 {
let old_level = self.experience.level;
let points = self.xp_change(a.rewards.experience).map_or(0, |p| p.max(0));
let gained_level = self.experience.give(points);
if let Some(level) = gained_level {
self.play_level_up(1, level);
}
self.native_player_level_changed(old_level);
}
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
let rewards: Vec<String> = a
.rewards
.recipes
.iter()
.filter(|r| world_stations!(self).recipe_index.contains(r))
.cloned()
.collect();
let (_, unlocked) = self
.level
.recipe_book
.add_recipes(&world_stations!(self).recipe_index, &rewards);
for table in &a.rewards.loot {
let items = world_items!(self).loot_table(table, false, None, &mut self.world.random);
for s in items {
// Inventory.add returns false when anything is left, and
// AdvancementRewards then drops that remainder with no
// pickup sound. A stack the inventory takes whole plays
// entity.item.pickup at the player's feet.
if let Some(rest) = self.give(s) {
if let Some(eye) = self.eye_position() {
self.spawn_item(rest, eye, [0.0; 3], 0);
}
} else {
self.advancement_pickup_sound();
}
}
}
if let Some(function) = &a.rewards.function {
self.run_advancement_reward_function(function);
}
let unlocked = self.grant_rewards(index);
if let Some(d) = &a.display {
// A network player's client shows its own toast.
if d.show_toast
@@ -1012,6 +981,56 @@ impl Game<'_> {
}
self.recipe_unlocked(&unlocked);
}
/// `AdvancementRewards.grant`: the experience, recipes, loot and function of
/// a finished advancement, and the recipes it unlocked for the
/// `recipe_unlocked` trigger, which the caller runs after its toast and
/// announcement. Returns those recipes.
fn grant_rewards(&mut self, index: usize) -> Vec<String> {
let tree = self.adv.tree.clone();
let a = &tree.list[index];
// AdvancementRewards.grant
if a.rewards.experience > 0 {
let old_level = self.experience.level;
let points = self.xp_change(a.rewards.experience).map_or(0, |p| p.max(0));
let gained_level = self.experience.give(points);
if let Some(level) = gained_level {
self.play_level_up(1, level);
}
self.native_player_level_changed(old_level);
}
// ServerPlayer.awardRecipesByKey: the rewards that name a recipe.
let rewards: Vec<String> = a
.rewards
.recipes
.iter()
.filter(|r| world_stations!(self).recipe_index.contains(r))
.cloned()
.collect();
let (_, unlocked) = self
.level
.recipe_book
.add_recipes(&world_stations!(self).recipe_index, &rewards);
for table in &a.rewards.loot {
let items = world_items!(self).loot_table(table, false, None, &mut self.world.random);
for s in items {
// Inventory.add returns false when anything is left, and
// AdvancementRewards then drops that remainder with no
// pickup sound. A stack the inventory takes whole plays
// entity.item.pickup at the player's feet.
if let Some(rest) = self.give(s) {
if let Some(eye) = self.eye_position() {
self.spawn_item(rest, eye, [0.0; 3], 0);
}
} else {
self.advancement_pickup_sound();
}
}
}
if let Some(function) = &a.rewards.function {
self.run_advancement_reward_function(function);
}
unlocked
}
/// `InventoryChangeTrigger`, checked when the inventory changes.
pub(super) fn check_inventory_trigger(&mut self) {
use std::hash::{Hash, Hasher};
@@ -26,6 +26,9 @@ pub(super) struct BossState {
loaded: bool,
/// What each viewer was last sent, so a change becomes one packet.
sent: HashMap<u128, HashMap<u128, Offer>>,
/// The bars server mods show single players (`native_boss_bar`): by
/// player UUID, each bar's id and bar, in the order they were added.
pub(super) native: HashMap<u128, Vec<(u128, horizon_client::mod_api::NativeBossBar)>>,
}
impl BossState {
@@ -551,6 +554,20 @@ impl Game<'_> {
scope: Scope::Global,
});
}
// Server mods' bars for this player alone (`native_boss_bar`).
for (id, bar) in self.bosses.native.get(&viewer.uuid).into_iter().flatten() {
out.push(Offer {
id: *id,
name: horizon_client::lan::data::component_nbt(&bar.name.component()),
progress: bar.progress,
color: bar.color,
overlay: bar.overlay,
darken: bar.darken,
music: bar.music,
fog: bar.fog,
scope: Scope::Global,
});
}
if viewer.here
&& let Some(progress) = self.dragon_boss_progress(viewer.at, viewer.alive)
{
+35 -5
View File
@@ -40,19 +40,49 @@ pub(super) type Brushing = HashMap<[i32; 3], BrushState>;
impl Game<'_> {
/// `BrushItem.onUseTick` for a brush used `ticks` ticks so far, the
/// hand of `slot` acting.
pub(super) fn tick_brush(&mut self, slot: usize, ticks: u32) {
// No block under the crosshair: releaseUsingItem.
pub(super) fn tick_brush(
&mut self,
stack: &horizon_client::items::ItemStack,
slot: usize,
ticks: u32,
) {
// No block under the crosshair: vanilla calls releaseUsingItem, which
// stops the use and causes the interaction vibration.
let Some(hit) = self.pick() else {
self.using = None;
self.stop_using_item(stack);
return;
};
// `BrushItem.onUseTick` aims with `getHitResultOnViewVector` out to the
// block interaction range, for mobs too: a mob between the player and
// the block is nearer, so the hit is not a block and the use releases
// the same way. A guest acts on its client's targeted block, whose view
// we cannot trace here, so only the local aim runs the entity test.
if self.forced_hit.is_none()
&& let Some((eye, _)) = self.aim()
&& let Some((_, mob, _)) = self.mob_hit_within(self.block_reach)
{
let block = (
hit.point[0] - eye[0],
hit.point[1] - eye[1],
hit.point[2] - eye[2],
);
if mob <= (block.0 * block.0 + block.1 * block.1 + block.2 * block.2).sqrt() {
self.stop_using_item(stack);
return;
}
}
let elapsed = ticks + 1;
// The last tick before the backswing.
if elapsed % 10 == 5 {
self.brush_stroke(&hit, slot);
}
// The use ends after 200 ticks; the held button starts it again.
self.using = (elapsed < USE_DURATION).then_some((slot, elapsed));
// The use ends after USE_DURATION; stopUsingItem causes the vibration
// (vanilla's tickUsingItem), the held button starts it again.
if elapsed < USE_DURATION {
self.using = Some((slot, elapsed));
} else {
self.stop_using_item(stack);
}
}
/// One stroke on the block aimed at: dust (this window only), the brush
@@ -252,6 +252,7 @@ mod tests {
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
2,
[20.5, 64.0, 0.5],
+67 -11
View File
@@ -720,10 +720,13 @@ impl Game<'_> {
.as_ref()
.filter(|d| !command || d.command)
.map(|d| d.text.clone());
self.open_chat(draft.as_deref().unwrap_or(prefix));
self.open_chat(draft.as_deref().unwrap_or(prefix), draft.is_some());
self.ui.chat_draft_shown = draft.is_some() && self.ui.chat_screen.is_some();
}
pub(super) fn open_chat(&mut self, initial: &str) {
/// Open the chat box on `initial`; `is_draft` when that text is the saved
/// draft (`ChatComponent.createScreen`), which draws grey italic until it
/// is edited.
pub(super) fn open_chat(&mut self, initial: &str, is_draft: bool) {
if self.player.is_none() {
return;
}
@@ -743,6 +746,7 @@ impl Game<'_> {
&m,
&c,
initial,
is_draft,
self.ui.chat.recent.len(),
b.width as i32,
b.height as i32,
@@ -757,6 +761,36 @@ impl Game<'_> {
self.right = false;
self.capture(false);
}
/// The open chat box's text and its cursor, counted in characters; `None`
/// while the chat screen isn't open. API @API:chat_input@.
#[allow(dead_code)]
pub(super) fn native_chat_input(&self) -> Option<(String, usize)> {
let screen = self.ui.chat_screen.as_ref()?;
Some((screen.input.value.clone(), screen.input.cursor))
}
/// Replaces the open chat box's text and puts the cursor at `cursor`
/// (characters, clamped to the text), as an edit: completions refresh as
/// they do for typing. What typing can't put in the box isn't put there
/// either (`chat_box_text`), and the 256-character limit holds. An error
/// while the chat screen isn't open. API @API:chat_input@.
#[allow(dead_code)]
pub(super) fn native_set_chat_input(
&mut self,
text: &str,
cursor: usize,
) -> Result<(), String> {
let m = Measure(&self.gui.atlas.font);
let c = Commands {
dispatcher: chat_tree(&self.remote),
registries: &self.ui.registries,
};
let Some(screen) = self.ui.chat_screen.as_mut() else {
return Err("the chat isn't open".into());
};
screen.suggest_command(&m, &c, &chat_box_text(text));
screen.input.move_cursor_to(&m, cursor, false);
Ok(())
}
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`).
@@ -836,11 +870,13 @@ impl Game<'_> {
.map_or_else(String::new, |s| s.value().to_owned());
self.close_chat(ChatExit::Interrupted);
if !text.is_empty() {
self.open_chat(&text);
// InBedChatScreen.onPlayerWokeUp: a new ChatScreen with
// the typed text, not a draft.
self.open_chat(&text, false);
}
}
} else if self.sleeping.is_some() && self.screen == Screen::Playing {
self.open_chat("");
self.open_chat("", false);
self.ui.in_bed_chat = self.screen == Screen::Chat;
}
}
@@ -1627,23 +1663,23 @@ impl Game<'_> {
pub(super) fn toggle_hide_gui(&mut self) {
self.ui.hide_gui = !self.ui.hide_gui;
}
/// F2: ScreenShot.grab into `screenshots/` with vanilla's file name.
pub(super) fn take_screenshot(&mut self) {
/// F2: ScreenShot.grab into `screenshots/` with vanilla's file name. The
/// file it will write, `None` without a window to draw.
pub(super) fn take_screenshot(&mut self) -> Option<std::path::PathBuf> {
let dir = horizon_client::logging::player_dir().join("screenshots");
if std::fs::create_dir_all(&dir).is_err() {
return;
return None;
}
let name = screenshot_name(&dir);
let Some(renderer) = self.renderer.as_mut() else {
return;
};
let renderer = self.renderer.as_mut()?;
let path = dir.join(name);
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.clone(),
size: None,
scaled: true,
});
self.ui.screenshot_message = Some(path);
self.ui.screenshot_message = Some(path.clone());
Some(path)
}
/// `Minecraft.handleGlobalKeyPress`'s screenshot branch: the
/// `key.screenshot` mapping (F2 by default) takes the picture before a
@@ -1699,6 +1735,14 @@ impl Game<'_> {
}
}
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, in local time).
/// What a mod may write into the chat box: the characters `ChatScreen`'s
/// typing lets in (`StringUtil.isAllowedChatCharacter`: no control
/// characters, no DEL, no section sign).
fn chat_box_text(text: &str) -> String {
text.chars()
.filter(|&ch| ch >= ' ' && ch != '\u{7f}' && ch != '§')
.collect()
}
pub fn screenshot_name(dir: &std::path::Path) -> String {
// Util.getFilenameFormattedDateTime: the local date and time.
let base = chrono::Local::now().format("%Y-%m-%d_%H.%M.%S").to_string();
@@ -1730,6 +1774,17 @@ const LINK_COPY: usize = 1;
mod tests {
use super::*;
/// A mod's text reaches the chat box as typing would leave it: emoji and
/// accents stay, control characters, DEL and the section sign go.
#[test]
fn a_mods_chat_text_keeps_what_typing_allows() {
assert_eq!(
super::chat_box_text("hi :smile: → 😀 é"),
"hi :smile: → 😀 é"
);
assert_eq!(super::chat_box_text("a\nb\tc\u{7f}d§6e"), "abcd6e");
}
/// `ChatScreen.shouldDiscardDraft`.
#[test]
fn draft_discard_follows_the_exit_reason() {
@@ -2057,6 +2112,7 @@ mod tests {
&m,
&c,
"",
false,
0,
width as i32,
height as i32,
+52 -5
View File
@@ -906,7 +906,7 @@ impl Game<'_> {
/// the entity lists (a kind added there joins `@e` once it names its
/// `Target`), then the kinds that are not on the walk. An
/// unsorted selector lists them in that order.
fn level_candidates(&self) -> Vec<Target> {
pub(in crate::game) fn level_candidates(&self) -> Vec<Target> {
use super::entity::{Entity as _, entity_lists_of, for_each_entity};
let mut candidates = Vec::new();
for_each_entity!(entity_lists_of!(self), entity => {
@@ -1559,6 +1559,7 @@ impl Game<'_> {
// there at once: the arrival they were waiting for elsewhere
// in it (whose chunks lose their tickets) is abandoned.
if let Some(conn) = self.lan_acting {
horizon_client::lan::hub::teleport_cause(Some("command"));
self.server_trips.retain(|trip| !trip.is(conn));
}
let from = self
@@ -1816,6 +1817,7 @@ impl Game<'_> {
at: pos,
rotation,
vehicle: None,
cause: Some("command"),
});
Ok(true)
}
@@ -4009,6 +4011,7 @@ impl Game<'_> {
));
Ok(count as i32)
}
/// `SayCommand.register`: resolve the source name and broadcast unsigned announcements.
fn cmd_say(&mut self, id: &str, ctx: &Context) -> R<i32> {
let raw = match ctx.get(if id == "me" { "action" } else { "message" }) {
Some(Arg::Str(s)) => s.clone(),
@@ -4016,13 +4019,42 @@ impl Game<'_> {
};
let message = self.expand_selectors(&raw);
// CommandSourceStack.getDisplayName: the executing entity's name.
let name = match self.command_source.map(|s| s.entity) {
Some(Some(t)) => self.target_name(t),
Some(None) => Text::literal("Server"),
let name = match self.command_source {
Some(source) => match source.entity {
Some(target) => self.target_name(target),
None => source
.block
.and_then(|pos| match self.world.block_entities.get(&pos) {
Some(horizon_client::containers::BlockEntity::CommandBlock(block)) => {
Some(block.name())
}
_ => None,
})
.or_else(|| {
source.cart.and_then(|id| {
self.carts
.iter()
.find(|cart| cart.id == id)
.map(|cart| cart.command.name())
})
})
.unwrap_or_else(|| Text::literal("Server")),
},
None => self.player_display_name(),
};
let line = match id {
"say" => Text::translate("chat.type.announcement", &[name, message]),
"say" => {
// SayCommand broadcasts independently of the command source's feedback.
if let Some(host) = &self.lan {
use horizon_client::lan::data::component_nbt;
host.hub.broadcast_source_chat(
"say_command",
component_nbt(&name.component()),
component_nbt(&message.component()),
);
}
Text::translate("chat.type.announcement", &[name, message])
}
"me" => Text::translate("chat.type.emote", &[name, message]),
_ => {
let _ = self.entities(ctx, "targets", true)?;
@@ -4042,6 +4074,21 @@ impl Game<'_> {
.italic()
}
};
if id == "say" {
if self.lan.is_some() && self.options.dedicated.is_some() {
self.lan_log_event(horizon_client::lan::hub::HostEvent::Chat {
line: line.plain(),
secure: false,
});
}
// SayCommand sends no feedback: the broadcast above already reached
// the guest whose command is being captured.
if self.lan.is_some() && self.chat_capture.is_some() {
return Ok(1);
}
self.player_message(line);
return Ok(1);
}
if let Some(capture) = self.chat_capture.as_mut() {
capture.push(line.clone());
}
@@ -313,6 +313,7 @@ impl Game<'_> {
return Err(err("commands.data.entity.invalid", &[]));
}
Target::Mob(id) => {
Self::normalize_loaded_mob_tags(&mut tag);
let uuid = self.uuid_for(id);
let kind = self
.mobs
@@ -77,6 +77,14 @@ impl Game<'_> {
// `new MobEffectInstance(effect, duration, amplifier, false, particles)`.
let changed =
m.add_effect_with(name, duration, amplifier, false, particles);
// MobEffect.onEffectAdded: an omen's sound, at the mob's feet.
if new
&& changed
&& let Some(sound) = super::super::potions::sound_on_added(name)
{
let at = [m.position.x, m.position.y, m.position.z];
self.audio.play(sound, Some(at), 1.0, 1.0);
}
self.mod_effect_added(name, Some(id as i32), amplifier, new);
changed
}
@@ -1156,7 +1156,7 @@ impl Game<'_> {
Ok((true, count))
}
/// The biome name at a block position.
fn biome_name_at_block(&self, p: [i32; 3]) -> Option<String> {
pub(in crate::game) fn biome_name_at_block(&self, p: [i32; 3]) -> Option<String> {
let chunk = self.world.chunks.get(&(p[0] >> 4, p[2] >> 4))?;
// The world's table (`world_biomes!`): a packed biome's id is past
// the built-in list, and `Biomes::get` would clamp it to the last
@@ -195,9 +195,37 @@ impl Game<'_> {
_ => by.and_then(|t| self.target_position(t)),
};
// Destruction can remove the target before its success message is built.
let discarded_name = matches!(target, Target::Item(_) | Target::Boat(_) | Target::Cart(_))
let item_name = matches!(target, Target::Item(_) | Target::Boat(_) | Target::Cart(_))
.then(|| self.target_name(target));
let hurt = match target {
let hurt = self.damage_target(target, amount, &ty, cause, by, from);
if !hurt {
return Err(err("commands.damage.invulnerable", &[]));
}
let name = item_name.unwrap_or_else(|| self.target_name(target));
self.feedback(Text::translate(
"commands.damage.success",
&[
Text::literal(horizon_client::snbt::java_float(amount)),
name,
],
));
Ok(1)
}
/// `DamageCommand.damage`'s hurt: `target` takes `amount` of `ty` (`minecraft:mob_attack`)
/// from the causing entity `cause` and the direct one `by` (the causing one is either when
/// the other is none), from `from` (the knockback's direction): whether it was hurt. Also
/// `native_hurt_entity`'s.
pub(in crate::game) fn damage_target(
&mut self,
target: Target,
amount: f32,
ty: &str,
cause: Option<Target>,
by: Option<Target>,
from: Option<[f64; 3]>,
) -> bool {
match target {
// The causing mob is the one the hit is credited to and thorns
// answer; the source position gives the knockback its direction.
Target::Player | Target::Guest(_) => {
@@ -217,9 +245,9 @@ impl Game<'_> {
return false;
}
if let Some(name) = named {
g.note_direct_entity(&ty, name);
g.note_direct_entity(ty, name);
}
g.hurt_player_from(amount, from, &ty, attacker)
g.hurt_player_from(amount, from, ty, attacker)
})
.unwrap_or(false)
}
@@ -234,7 +262,7 @@ impl Game<'_> {
_ => None,
};
let damage = Damage {
kind: Self::damage_kind(&ty),
kind: Self::damage_kind(ty),
attacker,
from,
attacker_species,
@@ -264,15 +292,15 @@ impl Game<'_> {
self.command_damage_mob(id, amount, damage)
}
}
Target::Item(id) => self.command_damage_item(id, amount, &ty, cause.or(by)),
Target::Item(id) => self.command_damage_item(id, amount, ty, cause.or(by)),
Target::Boat(_) | Target::Cart(_) => {
self.command_damage_vehicle(target, amount, &ty, cause.or(by))
self.command_damage_vehicle(target, amount, ty, cause.or(by))
}
Target::Dragon(id) => self.command_damage_dragon(
id,
amount,
&ty,
Self::damage_kind(&ty),
ty,
Self::damage_kind(ty),
(
by,
cause.or(by),
@@ -280,22 +308,10 @@ impl Game<'_> {
),
),
Target::Crystal(id) => {
self.command_damage_crystal(id, &ty, Self::damage_kind(&ty), cause.or(by))
self.command_damage_crystal(id, ty, Self::damage_kind(ty), cause.or(by))
}
_ => false,
};
if !hurt {
return Err(err("commands.damage.invulnerable", &[]));
}
let name = discarded_name.unwrap_or_else(|| self.target_name(target));
self.feedback(Text::translate(
"commands.damage.success",
&[
Text::literal(horizon_client::snbt::java_float(amount)),
name,
],
));
Ok(1)
}
/// `VehicleEntity.hurtServer`: report acceptance even when the vehicle survives.
@@ -1509,6 +1525,20 @@ impl Game<'_> {
return Ok(0);
};
let vehicle_name = self.target_name(vehicle);
self.ride_mount(target, vehicle)?;
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
));
Ok(1)
}
/// `RideCommand.mount` once the targets are known: `target` (the player
/// in place or a mob) rides `vehicle`, as `startRiding(vehicle, true,
/// true)` forces it (also for `native_mount`).
pub(in crate::game) fn ride_mount(&mut self, target: Target, vehicle: Target) -> R<()> {
let name = self.target_name(target);
let vehicle_name = self.target_name(vehicle);
if let Some(current) = self.ride_vehicle(target) {
let current = self.target_name(current);
return Err(err(
@@ -1574,11 +1604,7 @@ impl Game<'_> {
// sendEventAndTriggers: START_RIDING_TRIGGER.
self.start_riding(id);
}
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
));
return Ok(1);
return Ok(());
}
Target::Cart(id) => {
if target != Target::Player
@@ -1588,11 +1614,7 @@ impl Game<'_> {
return Err(generic());
}
self.start_riding(id);
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
));
return Ok(1);
return Ok(());
}
_ => {}
}
@@ -1608,11 +1630,7 @@ impl Game<'_> {
if !self.try_mount_mob(rider, mob_id) {
return Err(generic());
}
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
));
return Ok(1);
return Ok(());
}
if !self.native_may_board(mob_id) {
return Err(generic());
@@ -1625,11 +1643,7 @@ impl Game<'_> {
self.mount.jump_ticks = 0;
self.mount.jump_scale = 0.0;
self.start_riding(mob_id);
self.feedback(Text::translate(
"commands.ride.mount.success",
&[name, vehicle_name],
));
Ok(1)
Ok(())
}
/// Entity.getVehicle: the player's mount, or a mob's.
@@ -386,6 +386,9 @@ impl TeleportConsumeWorld for Teleporter<'_, '_> {
let y = to[1].clamp(f64::from(shape.min_y), f64::from(high));
*target = Vec3::new(to[0], y, to[2]);
}
if !cancel {
horizon_client::lan::hub::teleport_cause(Some("chorus_fruit"));
}
Ok(!cancel)
}
fn random_teleport(&mut self, target: Vec3) -> Result<bool, Infallible> {
+245 -2
View File
@@ -17,7 +17,7 @@ use horizon_client::{
collisions::SceneCollisions,
crafting::Recipes,
graphics::EntityCullingMode,
health::{HurtSource, Hurting, Knock, knockback_canceled},
health::{HealthEvent, HurtSource, Hurting, Knock, knockback_canceled},
items::ItemStack,
mesher::Vertex,
mobs::{
@@ -108,6 +108,9 @@ pub(super) struct Creatures {
/// The snapshot's buffer between ticks, empty: ~400 bytes a mob, so
/// thousands of mobs would allocate and free megabytes every tick.
seen_buf: Vec<Seen>,
/// The targets the mobs had before their ticks, kept between ticks
/// (empty) as `seen_buf` is: `native_living_change_target` hears a change.
target_buf: Vec<Option<Who>>,
/// The mobs whose own tick ran in the last `tick_creature_entities`:
/// `ServerLevel.tick` ticks only the entities in entity-ticking range, so
/// what the passes after the loop do for a mob's `aiStep`, goals or brain
@@ -124,6 +127,17 @@ pub(super) struct Creatures {
/// Particles from a guest's turn, waiting for it to end.
pub lan_particles: Vec<super::lan_particles::QueuedParticle>,
}
/// Whether `p`'s next step goes from inside the world border (`bounds`: [minX, minZ, maxX,
/// maxZ]) to outside it, as `clip_including_border` would stop it.
fn border_crossing(p: &Projectile, bounds: Option<[f64; 4]>) -> bool {
let Some([min_x, min_z, max_x, max_z]) = bounds else {
return false;
};
let within = |x: f64, z: f64| x >= min_x && x < max_x && z >= min_z && z < max_z;
let (at, v) = (p.position, p.velocity);
within(at.x, at.z) && !within(at.x + v.x, at.z + v.z)
}
pub(super) struct ProjWorld<'a, 'w> {
pub w: &'a MobWorld<'w>,
pub targets: Vec<(Who, Aabb)>,
@@ -296,6 +310,53 @@ fn flame_geometry(
pub(super) fn seen(m: &Mob, player: Option<[i32; 4]>) -> Seen {
m.seen(player)
}
/// A mob's target as `native_living_change_target` names it: a player's UUID
/// or a mob's id, both None for no target; None for one it cannot name.
/// `Who::Player` is the guest the mob sees (`seen`, a connection) or, with
/// none, the host's own player (`host`); a guest's entity (`guest_ids`, by
/// connection) is that guest.
fn target_named(
target: Option<Who>,
seen: Option<u32>,
guests: &super::lan_mobs::GuestViews,
guest_ids: &[(u32, u32)],
host: Option<&PlayerView>,
) -> Option<(Option<u128>, Option<u32>)> {
let uuid = |v: &PlayerView| horizon_client::lan::playerdata::ints_uuid(v.uuid);
let guest = |conn: u32| guests.iter().find(|g| g.0 == conn).map(|g| uuid(&g.1));
match target {
None => Some((None, None)),
Some(Who::Player) => {
let player = match seen {
Some(conn) => guest(conn),
None => host.map(uuid),
};
Some((Some(player?), None))
}
Some(Who::Mob(id)) => match guest_ids.iter().find(|g| g.1 == id) {
Some(&(conn, _)) => Some((Some(guest(conn)?), None)),
None => Some((None, Some(id))),
},
}
}
/// Whether `e`, from a mob's tick, is a hit it deals `target`: a melee hit
/// (with its effect), a sting, a sonic boom.
fn hits_target(e: &MobEvent, want: Who) -> bool {
match (e, want) {
(
MobEvent::AttackPlayer(..)
| MobEvent::AttackPlayerWithEffect { .. }
| MobEvent::StingPlayer { .. },
Who::Player,
) => true,
(
MobEvent::AttackMob { target, .. } | MobEvent::AttackMobWithEffect { target, .. },
Who::Mob(id),
) => *target == id,
(MobEvent::SonicBoom { target, .. }, _) => *target == want,
_ => false,
}
}
/// Whether a mob a packet named may take the click: `mob_hit` skips a mob in
/// its death animation, the one the player rides (it shares the player's root
/// vehicle) and the camera entity the pick starts from, so a packet that names
@@ -1020,6 +1081,11 @@ impl Game<'_> {
snapshot.extend(self.mobs.iter().map(|m| seen(m, uuid)));
let mut events = Vec::new();
let mut mobs = std::mem::take(&mut self.mobs);
// Each mob's target as the mods last heard it, by its place in `mobs`
// (`native_living_change_target`).
let mut targets = std::mem::take(&mut self.creatures.target_buf);
targets.clear();
targets.extend(mobs.iter().map(|m| m.ai.target));
let mut dead = Vec::new();
let mut corpses = Vec::new();
let mut died = std::mem::take(&mut self.died);
@@ -1209,6 +1275,46 @@ impl Game<'_> {
} else if let Some(k) = lent {
spare = w.player.map(|v| std::mem::replace(&mut guests[k].1, v));
}
// Its tick changed its target: the mods may keep the one it had
// (`native_living_change_target`), and then what the tick did to
// the new one is not dealt.
if let Some(&before) = targets.get(i)
&& m.ai.target != before
{
let named = target_named(
m.ai.target,
seen_guest,
&guests,
&guest_ids,
base.player.as_ref(),
);
if let Some((player, mob)) = named {
let kind = super::native_hooks::mob_kind(m);
let mut cancel = false;
Self::native_living_change_target(
level_id,
m.id,
&kind,
player,
mob,
&mut cancel,
);
if cancel {
if let Some(new) = m.ai.target {
let mut k = from;
while k < events.len() {
if hits_target(&events[k], new) {
events.remove(k);
} else {
k += 1;
}
}
}
m.ai.target = before;
}
}
targets[i] = m.ai.target;
}
// A killed mob's body is removed: its effects that act on
// death run (`LivingEntity.remove` with KILLED).
if !kept && !m.alive() && !m.removed && !m.extra.effects.is_empty() {
@@ -1272,6 +1378,43 @@ impl Game<'_> {
grid.update(i, snapshot[i].bounds);
horizon_client::mobs::apply_brain_events(&mut mobs, &mut events, from);
}
// Targets changed after the mob's own tick (another mob's alert):
// heard now, and a cancel puts the old one back before its next tick.
for (i, m) in mobs.iter_mut().enumerate() {
let Some(&before) = targets.get(i) else {
break;
};
if m.ai.target == before {
continue;
}
let seen_guest = if guests.is_empty() {
None
} else {
bindings.get(&m.id).copied()
};
let named = target_named(
m.ai.target,
seen_guest,
&guests,
&guest_ids,
base.player.as_ref(),
);
if let Some((player, mob)) = named {
let kind = super::native_hooks::mob_kind(m);
let mut cancel = false;
Self::native_living_change_target(
level_id,
m.id,
&kind,
player,
mob,
&mut cancel,
);
if cancel {
m.ai.target = before;
}
}
}
laps.lap("creature_loop");
let mut flags = keep.into_iter();
mobs.retain(|_| flags.next().unwrap_or(true));
@@ -1303,6 +1446,8 @@ impl Game<'_> {
snapshot.clear();
self.creatures.seen_buf = snapshot;
self.creatures.ticked = ticked;
targets.clear();
self.creatures.target_buf = targets;
self.native_tick_post_batch(round);
// ... and entity event 60 goes out as it is: the poof.
// 🤖 Agent: the split follows the poof, as `LivingEntity.tickDeath`
@@ -3844,6 +3989,7 @@ impl Game<'_> {
// The hit's own push, held back until after the shield's (`hurtServer`
// runs `applyItemBlocking` before `dealDefaultKnockback`).
let mut default_push: Option<([f64; 2], HurtSource<'_>)> = None;
let events_before = self.player.as_ref().map_or(0, |p| p.events.len());
if let Some(player) = self.player.as_mut()
&& let Some(id) = self.health_resources.damage.id(damage_type)
&& let Ok(o) = player.hurt_caused_by(
@@ -3879,15 +4025,36 @@ impl Game<'_> {
default_push = Some(([xd, zd], source));
}
// The thorns hurt the entity that caused the hit, if there is one.
// Thorns' own hit is no attack, so nothing answers it
// (`doPostAttackEffects` runs from the attacks, not `hurtServer`).
let killed = self.player.as_ref().is_some_and(|p| {
super::wither::killed(true, p.health.dead_or_dying(), p.health.totem_used)
});
if let Some(a) = attacker.or(owner)
&& !(post_attack_if_alive && killed)
&& damage_type != "minecraft:thorns"
{
self.thorns(a);
}
}
// LivingEntity.playSecondaryHurtSound: a thorns hit that landed whole
// sounds where the player stands, for everyone around.
if let Some(p) = self.player.as_ref()
&& p.events.get(events_before..).is_some_and(|new| {
new.iter().any(|e| {
matches!(
e,
horizon_client::motion::MotionEvent::Health(HealthEvent::Phase(
"thorns-sound"
))
)
})
})
{
let at = [p.position.x, p.position.y, p.position.z];
self.audio
.play("minecraft:enchant.thorns.hit", Some(at), 1.0, 1.0);
}
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned
// early (invulnerable, or inside the hurt cooldown).
// A hit a native mod canceled records no damage (hurtServer never ran).
@@ -4113,6 +4280,7 @@ impl Game<'_> {
Vec::new()
};
let border = Some(self.border.bounds());
let dimension = self.dim.id();
let pw = ProjWorld {
w: &w,
targets,
@@ -4158,6 +4326,23 @@ impl Game<'_> {
keep_flags[i] = false;
continue;
}
// MC-310476: the mods may let a thrown projectile through the world border.
let through = p.arrow.is_none() && !p.stuck && border_crossing(p, border) && {
let (at, v) = (p.position, p.velocity);
let (mut pos, mut velocity) = ([at.x, at.y, at.z], [v.x, v.y, v.z]);
let mut collide = true;
let kind = p.kind.id();
let (vel, c) = (&mut velocity, &mut collide);
Self::native_projectile_world_border(dimension, p.id, kind, &mut pos, vel, c);
if pos.iter().chain(&velocity).all(|v| v.is_finite())
&& (pos != [at.x, at.y, at.z] || velocity != [v.x, v.y, v.z])
{
p.position = Vec3::new(pos[0], pos[1], pos[2]);
p.velocity = Vec3::new(velocity[0], velocity[1], velocity[2]);
p.needs_sync = true;
}
!collide
};
// The projectile as it was, to fly on if a mod cancels its impact.
let before = (hear && !p.stuck).then(|| p.clone());
let mob_shot = !matches!(p.owner, Some(Who::Player));
@@ -4188,6 +4373,16 @@ impl Game<'_> {
targets.extend(winds);
horizon_client::arrows::tick(p, &scene, &targets)
}
// Through the border: this tick's ray does not stop on it.
(false, with_guests, true) if through => p.tick(&ProjWorld {
w: pw.w,
targets: if with_guests {
pw_guests.targets.clone()
} else {
pw.targets.clone()
},
border: None,
}),
(false, false, true) => p.tick(&pw),
(false, true, true) => p.tick(&pw_guests),
(false, pvp_shot, false) => {
@@ -4200,7 +4395,7 @@ impl Game<'_> {
p.tick(&ProjWorld {
w: if pvp_shot { pw_guests.w } else { pw.w },
targets,
border,
border: border.filter(|_| !through),
})
}
};
@@ -4440,6 +4635,23 @@ impl Game<'_> {
None => self.projectile_hit(p, hit),
}
}
/// `EnchantmentHelper.doPostAttackEffects` after the player's shot `p`
/// (its `direct` entity, a hit of `damage_type`) hurt the mob `who`: the
/// victim's own post-attack enchantments answer the player (Thorns).
pub(super) fn shot_post_attack(
&mut self,
p: &Projectile,
who: Who,
direct: &str,
damage_type: &str,
) {
if p.owner == Some(Who::Player)
&& let Who::Mob(id) = who
&& let Some(index) = self.mobs.iter().position(|m| m.id == id)
{
self.victim_post_attack(index, direct, Some(damage_type));
}
}
/// What an arrow or a thrown trident hits an entity of type `victim`
/// for (`AbstractArrow.onHitEntity`, `ThrownTrident.onHitEntity`): a
/// trident's 8 through `EnchantmentHelper.modifyDamage` of the trident
@@ -4746,6 +4958,12 @@ impl Game<'_> {
}
}
pub(super) fn projectile_hit(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
let result = self.projectile_hit_inner(p, hit);
// LivingEntity.actuallyHurt credits the shooter (a ServerPlayer) with the absorbed damage.
self.settle_dealt_absorbed(p.owner == Some(Who::Player));
result
}
fn projectile_hit_inner(&mut self, p: &Projectile, hit: Hit) -> Result<()> {
let _attacker = self.knockback_scope_for(p.owner);
// Projectile.onHitBlock: BlockState.onProjectileHit, for every
// projectile (a wind charge out of the world's top has no face).
@@ -4949,6 +5167,7 @@ impl Game<'_> {
}),
};
let outcome = if hurt {
self.shot_post_attack(p, who, "minecraft:trident", "minecraft:trident");
EntityHit::Hurt
} else {
EntityHit::Refused
@@ -5123,6 +5342,16 @@ impl Game<'_> {
}])?;
}
if hurt {
let spectral = p
.arrow
.as_ref()
.is_some_and(|a| &*a.pickup_item.item == "minecraft:spectral_arrow");
let direct = if spectral {
"minecraft:spectral_arrow"
} else {
"minecraft:arrow"
};
self.shot_post_attack(p, who, direct, "minecraft:arrow");
self.arrow_post_hurt_effects(p, who);
}
match (hurt, who) {
@@ -5327,6 +5556,9 @@ impl Game<'_> {
pl.fall_distance = 0.0;
pl.teleported = true;
}
if self.lan_acting.is_some() {
horizon_client::lan::hub::teleport_cause(Some("ender_pearl"));
}
self.previous_position = to;
if damage > 0.0 {
self.hurt_player_by("minecraft:ender_pearl", damage);
@@ -6711,6 +6943,17 @@ impl Game<'_> {
let Some(eye) = self.eye_position() else {
return Ok(false);
};
// The mods, before the projectile exists: a canceled throw is a
// failed use, and the guest's client gets back the item it took.
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
let mut cancel = false;
self.native_player_launch_projectile_v2(player, &name, stack, &mut cancel);
if cancel {
self.use_failed = true;
self.guest_inventory_resend = true;
return Ok(true);
}
}
let Some(p) = self.player.as_ref() else {
return Ok(false);
};
+61 -12
View File
@@ -15,12 +15,27 @@ use horizon_client::{
use horizon_core::geometry::{Axis, Vec3};
use horizon_protocol::packets::play::{BlockEntityTagQuery, EntityTagQuery, Serverbound};
use horizon_world::nbt::Tag;
use std::time::{Duration, Instant};
use winit::keyboard::KeyCode;
/// `ChatFormatting.YELLOW` and `RED`.
const YELLOW: u32 = 0xFF_FF55;
const RED: u32 = 0xFF_5555;
/// The overlay's CPU name. Reading it can start a process (`reg`, `sysctl`), which the render
/// thread shouldn't wait for, so [`warm_debug_cpu`] reads it ahead.
static DEBUG_CPU: std::sync::OnceLock<String> = std::sync::OnceLock::new();
/// Reads the overlay's CPU name in the background when the window opens, for F3's first frame.
pub(super) fn warm_debug_cpu() {
// A thread that can't start only leaves the read to that first frame.
let _ = std::thread::Builder::new()
.name("debug-cpu-name".into())
.spawn(|| {
DEBUG_CPU.get_or_init(horizon_client::crash::cpu);
});
}
/// The game as `KeyboardHandler` sees it while it runs one key event.
struct Host<'g, 'a> {
game: &'g mut Game<'a>,
@@ -289,7 +304,8 @@ impl Game<'_> {
}
/// `DebugScreenOverlay.extractRenderState`: the enabled entries' lines
/// (`debug-profile.json`), at the debug GUI scale, and the charts while
/// the overlay is open. F1 hides it unless a screen is open.
/// the overlay is open. F1 hides it unless a screen is open. Horizon paints
/// it 20 times a second and copies those vertices in between.
pub(super) fn draw_debug_overlay_under(&self, batch: &mut HudBatch, size: [u32; 2]) {
let screen_open = self.screen != Screen::Playing;
let reduced = self.reduced_debug_info();
@@ -297,8 +313,19 @@ impl Game<'_> {
|| !self.debug_entries.any_enabled(self.debug, reduced)
|| (self.ui.hide_gui && !screen_open)
{
// Nothing reads the server's lines now: it stops computing them each tick.
if let Some(server) = &self.integrated {
server.request_debug(None);
}
return;
}
let now = Instant::now();
let layout = (size, batch.scale.to_bits(), self.gui.atlas.font.epoch());
if let Some(vertices) = self.debug_paint.borrow().fresh(now, layout) {
batch.vertices.extend_from_slice(vertices);
return;
}
let first_vertex = batch.vertices.len();
let framebuffer = size.map(|v| v as i32);
let (scale, [width, height]) = debug_overlay::scale(
framebuffer,
@@ -343,6 +370,33 @@ impl Game<'_> {
draws.push(hud_layout::GuiDraw::Pop);
}
hud_layout::paint(batch, atlas, &draws);
self.debug_paint
.borrow_mut()
.store(now, layout, &batch.vertices[first_vertex..]);
}
/// Whether the overlay is listing GPU pass times (its `system_specs` lines), the only reason
/// it has to time passes.
pub(super) fn debug_lists_gpu_passes(&self) -> bool {
self.debug
&& self.title.is_none()
&& !(self.ui.hide_gui && self.screen == Screen::Playing)
&& self.debug_entry("system_specs")
}
/// The monitor's refresh rate in millihertz, asked of the OS at most once a second (on
/// Windows, winit calls `EnumDisplaySettingsExW` each time).
fn debug_refresh_rate(&self) -> Option<u32> {
if let Some((at, rate)) = self.debug_refresh.get()
&& at.elapsed() < Duration::from_secs(1)
{
return rate;
}
let rate = self
.window
.as_ref()
.and_then(|w| w.current_monitor())
.and_then(|m| m.refresh_rate_millihertz());
self.debug_refresh.set(Some((Instant::now(), rate)));
rate
}
/// `DebugEntryFps`.
pub(super) fn debug_fps_line(&self, d: &mut debug_overlay::Displayer) {
@@ -357,14 +411,9 @@ impl Game<'_> {
wgpu::PresentMode::FifoRelaxed => " (fifo relaxed)",
_ => "",
});
let refresh = self
.window
.as_ref()
.and_then(|w| w.current_monitor())
.and_then(|m| m.refresh_rate_millihertz())
.map_or("0".into(), |mhz| {
debug_overlay::refresh_rate_label(f64::from(mhz) / 1000.0)
});
let refresh = self.debug_refresh_rate().map_or("0".into(), |mhz| {
debug_overlay::refresh_rate_label(f64::from(mhz) / 1000.0)
});
// Horizon: with frame generation running, the frames reaching the screen too.
let generated = self
.renderer
@@ -420,12 +469,11 @@ impl Game<'_> {
framebuffer: [i32; 2],
) {
if let Some(r) = self.renderer.as_ref() {
static CPU: std::sync::OnceLock<String> = std::sync::OnceLock::new();
d.group(
"system",
[format!(
"CPU: {}",
CPU.get_or_init(horizon_client::crash::cpu)
DEBUG_CPU.get_or_init(horizon_client::crash::cpu)
)],
);
let info = &r.info;
@@ -445,7 +493,8 @@ impl Game<'_> {
);
if let Some(w) = self.window.as_ref() {
let density = w.scale_factor();
let logical = w.inner_size().to_logical::<f64>(density);
let logical = winit::dpi::PhysicalSize::new(framebuffer[0], framebuffer[1])
.to_logical::<f64>(density);
d.group(
"system",
[format!(
@@ -168,6 +168,16 @@ pub(super) fn player_joined(id: u128, name: &str) {
}
}
/// A player in the user cache by name (`UserCache::find_by_name`), on a dedicated server.
pub(super) fn cached_user_by_name(name: &str) -> Option<NameAndId> {
USER_CACHE.lock().unwrap().as_ref()?.find_by_name(name)
}
/// A player in the user cache by id, on a dedicated server.
pub(super) fn cached_user_by_id(id: u128) -> Option<NameAndId> {
USER_CACHE.lock().unwrap().as_mut()?.get_by_id(id)
}
/// `server.toml`'s `[anticheat]`, beside server.properties when horizon-server runs the game; the
/// default (on) without the file or the section, off (with an error logged) when the section is wrong.
fn load_anticheat() -> horizon_anticheat::Config {
@@ -1616,6 +1626,15 @@ impl Game<'_> {
/// server (permission level 4). The dedicated-only commands are here;
/// everything else goes to the game's dispatcher.
fn console_command(&mut self, state: &mut ServerState, line: &str) {
let (mut cancel, mut replies) = (false, Vec::new());
let source = if self.rcon_source { "rcon" } else { "console" };
self.native_console_line(source, line, &mut cancel, &mut replies);
for reply in replies {
self.console_reply(reply);
}
if cancel {
return;
}
let line = line.strip_prefix('/').unwrap_or(line);
let mut words = line.split_whitespace();
let Some(command) = words.next() else {
@@ -51,7 +51,7 @@ pub(super) struct Recording {
impl Recording {
pub(super) fn start(seconds: u32, wire: Vec<(u32, (u64, u64))>) -> Self {
let spans_were_on = spans::enabled();
spans::enable(true);
tick_profile::want_spans(tick_profile::SPANS_REPORT, true);
job_stats::enable(true);
job_stats::take();
tick_profile::take();
@@ -355,9 +355,8 @@ impl Game<'_> {
pub(super) fn finish_report(&mut self, r: Recording, view: (u32, u32), max_players: usize) {
let span_ns = span_cost_nanos();
let mob_ns = tick_profile::sample_cost_nanos();
if !r.spans_were_on {
spans::enable(false);
}
// Off unless `HORIZON_SPIKES` or a mod (`native_spans`) still wants them.
tick_profile::want_spans(tick_profile::SPANS_REPORT, false);
job_stats::enable(false);
let window_s = r.started.elapsed().as_secs_f64();
let (ticks, spans, worst) = tick_summary(&r, window_s);
@@ -655,6 +655,8 @@ impl Game<'_> {
v[2].as_f64().unwrap_or(0.0),
)
};
// A new player: no combat of an earlier case's.
self.combat = Default::default();
let mut player = PlayerMotion::new(self.motion, v3(&op["player"]))?;
player.velocity = v3(&op["motion"]);
player.fall_distance = op["fall"].as_f64().unwrap_or(0.0);
@@ -670,6 +672,7 @@ impl Game<'_> {
let used_before = stack
.as_ref()
.map_or(0, |s| self.stats.get("used", &s.item));
let taken_before = self.stats.get("custom", "minecraft:damage_taken");
let base = op["base"].as_f64().unwrap_or(0.0) as f32;
let source = self.attack_source(feet);
let (bonus, hurt) = match targets.first() {
@@ -707,12 +710,39 @@ impl Game<'_> {
};
self.process_level_events()?;
let mut out = Vec::new();
// What the player's tick makes of its own hurt (the victim's Thorns):
// the combat record with its statistic, the hurt sound where it
// stands and the damage game event of the health it lost.
let events = self
.player
.as_mut()
.map(|p| std::mem::take(&mut p.events))
.unwrap_or_default();
self.record_combat(&events);
for event in &events {
if let horizon_client::motion::MotionEvent::HurtSound(sound) = event {
out.push(format!("S {} {sound}", floor_pos(feet)));
}
}
if self
.player
.as_ref()
.is_some_and(|p| p.health.health() < 20.0)
{
self.post_entity_event("minecraft:entity_damage", horizon_client::mobs::Who::Player);
}
if let Some(s) = &stack {
let used = self.stats.get("used", &s.item) - used_before;
if used != 0 {
out.push(format!("stat {} minecraft:used {used}", s.item));
}
}
let taken = self.stats.get("custom", "minecraft:damage_taken") - taken_before;
if taken != 0 {
out.push(format!(
"stat minecraft:damage_taken minecraft:custom {taken}"
));
}
let source = match source.kind {
horizon_client::mobs::DamageKind::MaceSmash => "minecraft:mace_smash",
horizon_client::mobs::DamageKind::Spear => "minecraft:spear",
@@ -743,6 +773,25 @@ impl Game<'_> {
num(v.z)
));
}
// What the first target wears, worn by its Thorns.
if let Some(&Target::Mob(id)) = targets.first()
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
{
use horizon_client::mobs::finalize as f;
for (slot, name) in [
(f::FEET, "feet"),
(f::LEGS, "legs"),
(f::CHEST, "chest"),
(f::HEAD, "head"),
] {
if let Some(s) = f::item_stack(m, slot) {
out.push(format!("W {name} {}", canon_stack(Some(&s))));
}
}
}
if let Some(p) = self.player.as_ref() {
out.push(format!("H {}", num(f64::from(p.health.health()))));
}
if let Some(p) = self.player.take() {
let impact = p.impulse.impact_position.map_or("-".to_owned(), |i| {
format!("{},{},{}", num(i.x), num(i.y), num(i.z))
@@ -1044,12 +1093,20 @@ impl Game<'_> {
if flag("brown") && s == Species::Mooshroom {
mob.extra.variant = 1;
}
for (slot, snbt) in e["equip"].as_object().into_iter().flatten() {
for (name, snbt) in e["equip"].as_object().into_iter().flatten() {
if let (Some(slot), Some(stack)) = (
Slot::from_name(slot),
Slot::from_name(name),
parse_stack(snbt.as_str().unwrap_or("-"))?,
) {
mob_set_slot(&mut mob, slot, stack);
// `setItemSlot` keeps the whole stack: a piece's enchantments and damage.
let kept = ["mainhand", "offhand", "feet", "legs", "chest", "head"]
.iter()
.position(|s| s == name)
.filter(|_| stack.extra.is_some() || stack.damage > 0);
match kept {
Some(at) => horizon_client::mobs::finalize::set_item_stack(&mut mob, at, stack),
None => mob_set_slot(&mut mob, slot, stack),
}
}
}
self.mobs.push(mob);
@@ -17,6 +17,9 @@ use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::{fluid::FluidKind, level::LevelAccess, registry::BlockStateId};
use serde_json::Value;
/// `EquipmentSlot.getName` of each `mobs::finalize` slot (hands, feet to head).
const MOB_SLOTS: [&str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
#[derive(Clone, Copy)]
enum MendSlot {
@@ -178,8 +181,12 @@ impl Game<'_> {
let _ = p.set_sprinting(false);
}
}
/// After a successful melee hit: fire aspect and wind burst.
/// `EnchantmentHelper.doPostAttackEffectsWithItemSource` after a successful
/// melee hit (`Player.itemAttackInteraction`, the sweep's
/// `doPostAttackEffects`): the victim's equipment first (Thorns), then the
/// attacker's weapon on the victim (fire aspect and wind burst).
pub(super) fn post_attack(&mut self, mob: usize) {
self.victim_post_attack(mob, "minecraft:player", None);
let Some(held) = self.held().cloned() else {
return;
};
@@ -519,6 +526,152 @@ impl Game<'_> {
}
hits
}
/// `EnchantmentHelper.doPostAttackEffectsWithItemSource`'s `VICTIM` loop
/// after the player's hit hurt the mob at `victim` (`direct` is the hit's
/// direct entity: the player, or its arrow or trident):
/// `runIterationOnEquipment` over what the mob holds and wears, in
/// `EquipmentSlot` order, runs the `minecraft:post_attack` effects with
/// `enchanted: victim` of each enchantment on the player (Thorns).
pub(super) fn victim_post_attack(
&mut self,
victim: usize,
direct: &str,
damage_type: Option<&str>,
) {
use horizon_client::mobs::finalize as f;
let Some(m) = self.mobs.get(victim) else {
return;
};
let (id, kind) = (m.id, m.kind.id.clone());
// EquipmentSlot.VALUES: the hands, then the feet up to the head (the
// body and the saddle keep no enchantments here).
for slot in [f::MAINHAND, f::OFFHAND, f::FEET, f::LEGS, f::CHEST, f::HEAD] {
let Some(stack) = self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| f::item_stack(m, slot))
else {
continue;
};
let name = MOB_SLOTS[slot];
let hand = slot == f::MAINHAND || slot == f::OFFHAND;
let mut effects = Vec::new();
for (enchantment, level) in stack.enchantments() {
let Some(def) = world_stations!(self).enchantments.get(enchantment) else {
continue;
};
// Enchantment.matchingSlot
if !def.slots.iter().any(|s| {
s == name || s == "any" || (s == "armor" && !hand) || (s == "hand" && hand)
}) {
continue;
}
for effect in def.effects["minecraft:post_attack"]
.as_array()
.into_iter()
.flatten()
{
if effect["enchanted"] == "victim" {
effects.push((effect.clone(), *level));
}
}
}
for (effect, level) in effects {
// Enchantment.damageContext: random_chance draws from the level's random.
let ctx = EffectContext {
this: Some(&kind),
attacker: Some("minecraft:player"),
direct_attacker: Some(direct),
is_direct: direct == "minecraft:player",
damage_type,
items: Some(world_items!(self)),
..Default::default()
};
let ok = effect.get("requirements").is_none_or(|r| {
world_stations!(self).enchantments.condition(
r,
level,
&ctx,
&mut self.world.random,
)
});
// The player is the attacker; with no attacker nothing applies.
if ok && effect["affected"] == "attacker" && self.player.is_some() {
self.victim_effect_on_player(&effect["effect"], level, id, slot);
}
}
}
}
/// `DamageEntity.apply`, `ChangeItemDamage.apply` (on the piece) and
/// `AllOf` for one `post_attack` effect of the piece the mob `wearer` has
/// in `slot`, on the player that hit it.
fn victim_effect_on_player(&mut self, effect: &Value, level: u32, wearer: u32, slot: usize) {
match effect["type"]
.as_str()
.map(|t| t.trim_start_matches("minecraft:"))
{
Some("all_of") => {
for e in effect["effects"].as_array().into_iter().flatten() {
self.victim_effect_on_player(e, level, wearer, slot);
}
}
// Mth.randomBetween from the hurt entity's own random (the
// player's, which no level draw shares), then its hurtServer by a
// source the wearer caused: from where the wearer stands.
Some("damage_entity") => {
let min = level_value(&effect["min_damage"], level);
let max = level_value(&effect["max_damage"], level);
let damage = super::dispenser::entity_random().next_float() * (max - min) + min;
let Some(m) = self.mobs.iter().find(|m| m.id == wearer) else {
return;
};
let from = [m.position.x, m.position.y, m.position.z];
let kind = effect["damage_type"].as_str().unwrap_or("minecraft:thorns");
self.mob_hits_player_caused(damage, from, kind, Some(wearer));
}
Some("change_item_damage") => {
let amount = level_value(&effect["amount"], level) as u32;
self.wear_mob_piece(wearer, slot, amount);
}
_ => {}
}
}
/// `ItemStack.hurtAndBreak` with no player for what the mob `id` has in
/// `slot`: Unbreaking takes its share from the level's random, and a
/// piece worn out leaves the slot with its break event
/// (`LivingEntity.onEquippedItemBroken`).
fn wear_mob_piece(&mut self, id: u32, slot: usize, amount: u32) {
use horizon_client::mobs::finalize as f;
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
let Some(stack) = f::item_stack(m, slot).filter(|s| {
amount > 0 && horizon_client::stations::is_damageable(world_items!(self), s)
}) else {
return;
};
let loss = self.durability_loss(&stack, amount);
if loss == 0 {
return;
}
let damage = stack.damage + loss;
let max = world_items!(self)
.get(&stack.item)
.map_or(0, |i| i.max_damage);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
if damage < max {
f::set_slot_damage(m, slot, damage);
return;
}
horizon_client::mobs::drops::clear_slot(m, MOB_SLOTS[slot]);
// LivingEntity.entityEventForEquipmentBreak
let event = [47, 48, 52, 51, 50, 49][slot];
self.lan_entity_event(id as i32, event, at);
}
/// `ExperienceOrb.repairPlayerItems`: returns the experience left over.
pub(super) fn mending(&mut self, amount: u32) -> u32 {
let mut amount = amount;
@@ -840,16 +993,26 @@ impl Game<'_> {
let at = [p.position.x, p.position.y, p.position.z];
let on_ground = p.ground.on_ground;
let cell = |v: [f64; 3]| v.map(|c| c.floor() as i32);
if cell(from) == cell(at) && !(on_ground && !was_on_ground) {
// LivingEntity.collectEquipmentChanges: newly worn armor runs its
// location_changed effects at once, even when standing still.
let changed = armor_changes(&mut self.location_armor, &self.armor);
let moved = cell(from) != cell(at) || (on_ground && !was_on_ground);
if !moved && !changed.iter().any(|c| *c) {
return;
}
let riding = self.riding.is_some();
let disks = location_disks(
&self.stations().enchantments,
self.armor.iter().zip(["head", "chest", "legs", "feet"]),
on_ground,
riding,
);
let slots = ["head", "chest", "legs", "feet"];
let mut disks = Vec::new();
for i in 0..4 {
if moved || changed[i] {
disks.extend(location_disks(
&self.stations().enchantments,
std::iter::once((&self.armor[i], slots[i])),
on_ground,
riding,
));
}
}
for (effect, level) in disks {
self.replace_disk(&effect, level, at, Who::Player);
}
@@ -1182,9 +1345,43 @@ fn location_requirements(req: &Value, on_ground: bool, riding: bool) -> bool {
}
}
/// `LivingEntity.collectEquipmentChanges`: which armor slots differ from the
/// `snapshot` taken at the last look, which is then brought up to date.
fn armor_changes(
snapshot: &mut [Option<ItemStack>; 4],
armor: &[Option<ItemStack>; 4],
) -> [bool; 4] {
let changed: [bool; 4] = std::array::from_fn(|i| armor[i] != snapshot[i]);
// Only a changed slot is copied: the check runs every tick for every player.
for ((kept, worn), changed) in snapshot.iter_mut().zip(armor).zip(changed) {
if changed {
kept.clone_from(worn);
}
}
changed
}
#[cfg(test)]
mod tests {
use super::block_break_speed_factor;
use super::{armor_changes, block_break_speed_factor};
use horizon_client::items::ItemStack;
/// The snapshot reports a newly worn piece once, then settles.
#[test]
fn armor_changes_settle_after_one_look() {
let worn = [
None,
None,
None,
Some(ItemStack::new("minecraft:iron_boots", 1)),
];
let mut snapshot: [Option<ItemStack>; 4] = Default::default();
assert_eq!(
armor_changes(&mut snapshot, &worn),
[false, false, false, true]
);
assert_eq!(armor_changes(&mut snapshot, &worn), [false; 4]);
}
/// #4094: `BLOCK_BREAK_SPEED` is a multiplier on the destroy speed.
#[test]
+79 -2
View File
@@ -408,6 +408,8 @@ impl Game<'_> {
};
let mut cancel = false;
self.native_player_fish(uuid, &name, state, &mut cancel);
let hand = self.hand;
self.native_player_fish_v2(uuid, &name, state, hand, &mut cancel);
if cancel {
return true;
}
@@ -766,6 +768,17 @@ impl Game<'_> {
{
let mut cancel = false;
self.native_player_fish(uuid, &name, 1, &mut cancel);
// A bite has no hand of its own: the one holding the rod, the main
// hand first, as `FishingHook.shouldStopFishing` looks.
let hand = if self
.stack_in(self.selected)
.is_some_and(|s| &*s.item == "minecraft:fishing_rod")
{
horizon_client::first_person::Hand::Main
} else {
horizon_client::first_person::Hand::Off
};
self.native_player_fish_v2(uuid, &name, 1, hand, &mut cancel);
if cancel {
h.time_until_lured = 0;
h.time_until_hooked = 0;
@@ -896,6 +909,10 @@ impl Game<'_> {
];
let liquid = self.water_height(b[0], b[1], b[2]).map_or(0.0, |w| w.0);
let in_water = liquid > 0.0;
// The block a flying bobber meets or rests on this tick (and the face), for
// `native_fishing_hook_landed` once the tick is done.
let rested = h.on_ground;
let mut landed: Option<([i32; 3], [i32; 3])> = None;
match h.state {
State::Flying => {
if in_water {
@@ -908,7 +925,8 @@ impl Game<'_> {
// checkCollision: find the first block and entity on the movement
// segment so a fast hook cannot tunnel or catch through a wall.
let to = h.position.add_vector(h.velocity);
let block_hit = self.hook_block_hit(h.position, to);
let block_hit_at = self.hook_block_hit_at(h.position, to);
let block_hit = block_hit_at.map(|hit| hit.location);
let entity_end = block_hit.unwrap_or(to);
let owner = self.lan_acting;
let own_vehicle = self.own_vehicle();
@@ -979,6 +997,8 @@ impl Game<'_> {
}
}
} else if let Some(hit) = block_hit {
landed = block_hit_at
.map(|at| ([at.block.x, at.block.y, at.block.z], at.direction.normal()));
// Projectile.onHit on a block: PROJECTILE_LAND.
self.projectile_event(
"minecraft:projectile_land",
@@ -1101,7 +1121,24 @@ impl Game<'_> {
h.velocity.y * 0.92,
h.velocity.z * 0.92,
);
// A bobber lying on a block (on the ground as the tick began or ends) rests on
// the block under its centre, on that block's top.
if landed.is_none() && h.state == State::Flying && (rested || h.on_ground) {
let below = [
h.position.x.floor() as i32,
(h.position.y - 1.0e-4).floor() as i32,
h.position.z.floor() as i32,
];
landed = Some((below, [0, 1, 0]));
}
let id = h.id;
self.hook = Some(h);
// The mods hear a server player's bobber on a block with the bobber in place, so
// `native_remove_entity` can take it.
if let (Some((block, face)), Some(player)) = (landed, self.lan_acting_uuid) {
let dimension = self.dim.id();
self.native_fishing_hook_landed(player, id, dimension, block, face);
}
}
/// The players a hook can catch: everyone but spectators and the dead
/// (`Projectile.canHitEntity`), the host and the guests in the level.
@@ -1601,6 +1638,10 @@ impl Game<'_> {
/// `FishingHook.checkCollision` clips blocks on both sides of the connection.
fn hook_block_hit(&self, from: Vec3, to: Vec3) -> Option<Vec3> {
self.hook_block_hit_at(from, to).map(|hit| hit.location)
}
/// [`Self::hook_block_hit`] with the block and the face it meets.
fn hook_block_hit_at(&self, from: Vec3, to: Vec3) -> Option<horizon_core::geometry::BlockHit> {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
@@ -1618,7 +1659,6 @@ impl Game<'_> {
})
.ok()
.flatten()
.map(|hit| hit.location)
}
/// `FishingHook.tick`: one tick of movement without the fishing (the
@@ -1726,6 +1766,43 @@ impl Game<'_> {
(position, velocity, state, on_ground)
}
/// Takes the server's fishing bobber `id` out of the level in place, for
/// `native_remove_entity`: the bobber of the player in place, or another of this
/// level's players' (out of reach during a guest's turn, when the hub is taken out).
/// Its tracking removes it from the clients; the rod is left as when the bobber goes
/// by itself. Whether it was there.
pub(super) fn remove_server_hook(&mut self, id: u32) -> bool {
if self.remote.is_some() || id == 0 {
return false;
}
if self.hook.as_ref().is_some_and(|h| h.id == id) {
self.hook = None;
return true;
}
let Some(host) = self.lan.as_mut() else {
return false;
};
let owner = host
.hub
.guests
.iter()
.filter(|g| host.hub.here(g))
.map(|g| g.conn)
.find(|conn| {
host.play
.get(conn)
.and_then(|p| p.hook.as_ref())
.is_some_and(|h| h.id == id)
});
match owner.and_then(|conn| host.play.get_mut(&conn)) {
Some(play) => {
play.hook = None;
true
}
None => false,
}
}
pub(super) fn remove_remote_hook(&mut self, id: u32) {
if self.remote.is_some() && self.hook.as_ref().is_some_and(|h| h.id == id) {
self.hook = None;
+81 -6
View File
@@ -116,12 +116,7 @@ impl Game<'_> {
));
let supported = behind.legacy_solid || (s[1] == 0 && behind.has_class("DiodeBlock"));
// canCoexist(true): only hanging entities facing the same way block it.
let overlaps = |o: ([f64; 3], [f64; 3])| (0..3).all(|i| o.0[i] < hi[i] && lo[i] < o.1[i]);
supported
&& !self
.item_frames
.iter()
.any(|o| o.id != f.id && o.facing == f.facing && overlaps(o.bounds()))
supported && super::paintings::frame_coexists(f, &self.item_frames, &self.paintings)
}
/// Asks the mods whether the acting guest may place a hanging entity
/// of type `kind` at `pos` (`native_hanging_place`): true when one
@@ -288,6 +283,13 @@ impl Game<'_> {
let Some(held) = self.held().cloned() else {
return false;
};
// A map tracking more than 256 counted markers stays in the hand
// (InteractionResult.FAIL): nothing else is tried, no swing (#11383).
if let Some(id) = super::maps::map_id(&held)
&& self.map(id).is_some_and(|m| m.tracked_over_limit(256))
{
return true;
}
self.item_frames[i].item = Some(ItemStack { count: 1, ..held });
action = "put";
self.audio
@@ -709,3 +711,76 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod map_limit_tests {
use super::super::maps::{Decoration, Key, MapData};
use super::super::native_machines::tests::server_game;
use super::*;
fn frame(id: u32) -> ItemFrame {
ItemFrame {
id,
pos: [0, 64, id as i32],
facing: 3,
glow: false,
item: None,
rotation: 0,
drop_chance: 1.0,
fixed: false,
invisible: false,
check_interval: 0,
custom_name: None,
}
}
/// A map whose `count` frame markers all count toward the limit.
fn map_tracking(count: i32) -> MapData {
let mut map = MapData::fresh(0.0, 0.0, 0, "minecraft:overworld");
map.decorations = (0..count)
.map(|i| Decoration {
sprite: "map/decorations/frame",
x: 0,
y: 0,
rot: 0,
name: None,
key: Key::Frame(1000 + i),
})
.collect();
map
}
fn filled_map(id: i32) -> ItemStack {
let mut stack = ItemStack::new("minecraft:filled_map", 1);
stack.data_mut().map_id = Some(id);
stack
}
fn at(id: u32) -> Option<InteractTarget> {
Some(InteractTarget {
id: id as i32,
location: [0.0; 3],
})
}
#[test]
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
fn a_map_tracking_more_than_256_markers_stays_in_the_hand() {
let mut game = server_game();
game.item_frames = vec![frame(1), frame(2)];
game.maps.data.insert(7, map_tracking(257));
game.maps.data.insert(8, map_tracking(256));
// ItemFrame.interact: FAIL, the frame stays empty and the map is kept.
*game.held_mut() = Some(filled_map(7));
assert!(
game.interact_frame(at(1)),
"the refusal ends the interaction"
);
assert!(game.item_frames[0].item.is_none());
assert_eq!(game.held().and_then(|s| s.data()?.map_id), Some(7));
// At the limit (not over it) the map goes in.
*game.held_mut() = Some(filled_map(8));
assert!(game.interact_frame(at(2)));
let framed = game.item_frames[1].item.as_ref();
assert_eq!(framed.and_then(|s| s.data()?.map_id), Some(8));
}
}
+89 -4
View File
@@ -37,9 +37,10 @@ impl Game<'_> {
.collect()
}
/// `EntityGetter.getEntityCollisions` for a dropped item: the entities whose
/// `canBeCollidedWith(item)` is true. A boat and a minecart always, a living shulker,
/// and a happy ghast only while it is held still (`isOnStillTimeout`): a player on its
/// back keeps it still, so an item dropped there stays on top.
/// `canBeCollidedWith(item)` is true. A boat always, a living shulker, and a happy
/// ghast only while it is held still (`isOnStillTimeout`): a player on its back keeps
/// it still, so an item dropped there stays on top. A minecart keeps `Entity`'s false
/// (`AbstractMinecart` overrides only `canCollideWith`), so an item falls through it.
pub(super) fn item_entity_collisions(&self) -> Vec<Aabb> {
self.mobs
.iter()
@@ -52,7 +53,6 @@ impl Game<'_> {
})
.map(|m| m.bounds())
.chain(self.boats.iter().map(super::boats::Boat::bounds))
.chain(self.carts.iter().map(|c| c.bounds()))
.collect()
}
/// `HappyGhast.mobInteract`. Returns whether the click was used.
@@ -179,3 +179,88 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod item_collision_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_core::position::ChunkPos;
use std::sync::Arc;
/// An item dropped onto a minecart falls through it to the floor, and one
/// dropped onto a boat rests on the boat (`canBeCollidedWith`).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_dropped_item_rests_on_a_boat_but_falls_through_a_minecart() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.game_dir = Some(
std::env::temp_dir().join(format!("horizon-item-cart-unit-{}", std::process::id())),
);
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap()
.0;
let mut chunk = Chunk::empty(0, 0);
for x in 0..16 {
for z in 0..16 {
chunk.set(x, 199, z, stone);
}
}
game.world.chunks.insert((0, 0), Arc::new(chunk));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("summon minecart 4.5 200 4.5"));
assert!(game.run_command("summon oak_boat 10.5 200 10.5"));
for (x, z) in [(4.5, 4.5), (10.5, 10.5)] {
assert!(game.run_command(&format!(
"summon item {x} 201.5 {z} {{Item:{{id:\"minecraft:stick\",count:1}},Motion:[0.0d,0.0d,0.0d]}}"
)));
}
for _ in 0..60 {
game.tick_world(true).unwrap();
}
let rest = |x: f64| {
game.dropped
.iter()
.find(|d| (d.position.x - x).abs() < 0.5)
.map(|d| d.position.y)
.unwrap()
};
assert_eq!(rest(4.5), 200.0, "through the minecart to the stone");
let boat_top = game.boats[0].bounds().max_position().y;
assert_eq!(rest(10.5), boat_top, "on the boat");
}
}
+9
View File
@@ -200,6 +200,15 @@ impl Game<'_> {
pub(super) fn swing_arm(&mut self) {
self.swing_hand(self.hand, SwingAnimation::DEFAULT);
}
/// `swing_arm` for a use whose result is `SwingSource.PREDICTED`: the
/// player's client swings by itself, so a server keeps it to the others.
pub(super) fn swing_arm_predicted(&mut self) {
let before = self.swings_started;
self.swing_arm();
if self.swings_started != before {
self.predicted_swings += 1;
}
}
/// `Player.resetAttackStrengthTicker`.
pub(super) fn reset_attack_strength(&mut self) {
self.attack_ticks = 0;
+3 -1
View File
@@ -561,6 +561,7 @@ impl Game<'_> {
self.server_packs_world_pack(&folder);
self.connect_local(pipe, &player)?;
if let Some(r) = self.remote.as_mut() {
r.save_folder = Some(folder.clone());
// `new LevelLoadTracker(newWorld ? 500L : 0L)`.
r.level_load = horizon_client::level_loading::LevelLoadTracker::new(if new_world {
horizon_client::level_loading::LEVEL_LOAD_CLOSE_DELAY_MS
@@ -971,7 +972,8 @@ impl Game<'_> {
g.chat_capture = Some(Vec::new());
// A command a native mod registered goes to the mods
// first, as the owner's own chat does (the console is
// level 4, so every registered usage is allowed).
// level 4, so every registered usage is allowed; one with
// a permission node asks the mods for the owner).
if !g.perform_native_command(conn, 4, command.trim()) {
g.run_command(&command);
}
+54 -11
View File
@@ -262,6 +262,11 @@ pub(super) struct LanHost {
/// Native mods' commands and the level each needs, once asked
/// (game/native_server.rs).
pub native_commands: Option<HashMap<String, u8>>,
/// The permission node of each native mod's command that has one
/// (`NativeCommand::permission`), in registration order.
pub native_command_nodes: Vec<(String, String)>,
/// The usages behind them, as the mods registered them.
pub native_usages: Vec<horizon_client::mod_api::NativeCommand>,
/// The players a native mod leaves out of the sleep count when they
/// aren't in bed (`native_sleep_ignored`).
pub sleep_ignored: std::collections::HashSet<u128>,
@@ -1450,6 +1455,8 @@ impl Game<'_> {
quiet: std::collections::VecDeque::new(),
far,
native_commands: None,
native_command_nodes: Vec::new(),
native_usages: Vec::new(),
sleep_ignored: Default::default(),
states: Default::default(),
states_next_entity: None,
@@ -2082,12 +2089,16 @@ impl Game<'_> {
}
HostEvent::Joined(conn) => {
// The mods may refuse the login before the player is placed.
if let Some((uuid, name)) =
host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
if let Some((uuid, name, permission)) = host
.hub
.guest(conn)
.map(|g| (g.uuid, g.name.clone(), g.permission))
{
let mut cancel = false;
let mut reason = None;
self.native_login_permission = Some((uuid, permission));
self.native_player_login(uuid, &name, &mut cancel, &mut reason);
self.native_login_permission = None;
if cancel {
let reason = match reason {
Some(text) => {
@@ -2158,6 +2169,8 @@ impl Game<'_> {
{
self.lan = Some(host);
self.native_player_logged_in(uuid, &name);
// What the client said in its configuration (native_packets.rs).
self.configuration_heard(uuid, &name);
// A hook's command can close the hub.
host = self.lan.take()?;
}
@@ -2172,6 +2185,11 @@ impl Game<'_> {
let conn = guest.conn;
let at = host.hub.guests.len();
host.hub.guests.push(*guest);
// Bukkit's PlayerQuitEvent: the mods hear them leave while
// they are still here.
if !host.players_told_left {
host = self.tell_mods_logging_out(host, conn)?;
}
self.guest_disconnected(&mut host, conn);
*guest = host.hub.guests.remove(at);
}
@@ -2404,7 +2422,7 @@ impl Game<'_> {
.take()
.expect("the hub is back after the advancement grants");
}
if !host.hub.native_menu_events.is_empty() {
if !host.hub.native_menu_events.is_empty() || !host.hub.native_menu_returns.is_empty() {
self.lan = Some(host);
horizon_client::spans::time("lan_menu_events", || self.run_native_menu_events());
host = self
@@ -2412,6 +2430,14 @@ impl Game<'_> {
.take()
.expect("the hub is back after the menu events");
}
if !self.native_moves.is_empty() {
self.lan = Some(host);
horizon_client::spans::time("lan_native_moves", || self.run_native_moves());
host = self
.lan
.take()
.expect("the hub is back after the mods' moves");
}
host.states = entities;
self.level_pass.packets_drained = true;
drop(holding);
@@ -2549,6 +2575,7 @@ impl Game<'_> {
// What the tick sends a guest goes to its connection at the end.
let holding = lan::Holding::start();
self.register_native_commands(&mut host);
self.resend_native_trees(&mut host);
// The host's own commands' (and command blocks') players moving
// to another level, and their broadcastToAdmins lines.
if !self.level_teleports.is_empty() {
@@ -2591,13 +2618,7 @@ impl Game<'_> {
let mut states = std::mem::take(&mut host.states);
if has_guests {
self.lan_assign_ids();
let guests: Vec<(u32, i32)> = host
.hub
.guests
.iter()
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
.collect();
self.lan_riding(&guests, &mut host.hub.riding);
self.lan_riding(&host.hub.guests, &mut host.hub.riding);
// Idle states are kept only when the players' moves are in:
// the clients that could pair are known. The range scale is
// the one the tracker gets below.
@@ -2765,7 +2786,7 @@ impl Game<'_> {
.take()
.expect("the hub is back after the advancement grants");
}
if !host.hub.native_menu_events.is_empty() {
if !host.hub.native_menu_events.is_empty() || !host.hub.native_menu_returns.is_empty() {
self.lan = Some(host);
self.run_native_menu_events();
host = self
@@ -2803,6 +2824,7 @@ impl Game<'_> {
drop(holding);
self.flush_attachment_sync(&mut host);
self.flush_native_net(&mut host);
self.flush_native_player_fx(&mut host);
self.flush_far_terrain(&mut host);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
@@ -2870,6 +2892,27 @@ impl Game<'_> {
g.food = 20;
g.dimension = super::Dim::Overworld.id().to_owned();
}
// Spigot's PlayerSpawnLocationEvent: the mods may place them elsewhere.
let (mut dimension, mut position, mut rotation) =
(g.dimension.clone(), g.position, [g.yaw, g.pitch]);
self.native_player_spawn_location(
g.uuid,
&g.name,
&mut dimension,
&mut position,
&mut rotation,
);
if dimension != g.dimension && super::Dim::from_id(&dimension).id() == dimension {
g.dimension = dimension;
}
if position != g.position && position.iter().all(|v| v.is_finite()) {
g.position = position;
g.previous_position = position;
}
if rotation != [g.yaw, g.pitch] && rotation.iter().all(|v| v.is_finite()) {
(g.yaw, g.pitch) = (rotation[0], rotation[1]);
g.head_yaw = g.yaw;
}
// A server places each player in their own level (`PlayerList.placeNewPlayer`).
let back = self.dim;
let level = super::Dim::from_id(&g.dimension);
+565 -47
View File
@@ -137,6 +137,9 @@ pub(super) struct GuestPlay {
/// What `ServerPlayer` last recorded for the guest's scoreboard criteria
/// (`lastRecordedHealthAndAbsorption` and the others).
score_recorded: super::scoreboard::Recorded,
/// The window slots of the drag the guest is making, for
/// `native_inventory_drag` at its end.
drag_heard: Option<Vec<i32>>,
}
/// A guest's command waiting for the hub (`LanHost::commands`).
@@ -309,6 +312,25 @@ fn direction(face: i32) -> [i32; 3] {
}
}
/// The windows a guest has open besides their inventory: the block or mod
/// container, the crafting table, a station, a lectern, a merchant, a mount.
fn guest_open_windows(host: &LanHost, conn: u32) -> Vec<i32> {
let mut windows: Vec<i32> = host
.hub
.guest(conn)
.and_then(|g| g.menu.as_ref().map(|m| m.window))
.into_iter()
.collect();
if let Some(p) = host.play.get(&conn) {
windows.extend(p.crafting.table.as_ref().map(|t| t.0));
windows.extend(p.station.as_ref().map(|s| s.window));
windows.extend(p.lectern.as_ref().map(|l| l.window));
windows.extend(p.merchant.as_ref().map(|m| m.window));
windows.extend(p.mount_window.as_ref().map(|m| m.window));
}
windows
}
/// The hub's current bodies, across dimensions. A borrowed ancestor's
/// live body takes precedence over this saved state until its turn returns.
fn building_players(host: &LanHost) -> Vec<BuildingPlayer> {
@@ -497,14 +519,15 @@ impl Game<'_> {
motion.yaw = g.yaw;
motion.pitch = g.pitch;
motion.sprinting = g.sprinting;
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
let mode_flies = g.creative() || g.game_mode == SPECTATOR;
motion.may_fly = mode_flies || g.allow_flight;
motion.flying = g.flying;
motion.flying_speed = g.flying_speed;
// FireworkRocketItem.use boosts only a gliding player.
motion.fall_flying = g.fall_flying;
// A player frozen for a hand-over takes no damage (`Abilities.invulnerable`, which the
// void and `/kill` still pass), so its record stays true until the release.
motion.invulnerable = motion.may_fly || g.handover.is_some();
motion.invulnerable = mode_flies || g.handover.is_some();
// `ServerPlayer.isInvulnerableTo`'s connection term, as of now.
motion.client_loaded = g.client_loaded();
let _ = motion.set_health(g.health, false);
@@ -519,6 +542,7 @@ impl Game<'_> {
self.pitch = g.pitch;
std::mem::swap(&mut self.inventory, &mut g.inventory);
std::mem::swap(&mut self.armor, &mut g.armor);
std::mem::swap(&mut self.location_armor, &mut g.location_armor);
std::mem::swap(&mut self.offhand, &mut g.offhand);
std::mem::swap(&mut self.carried, &mut g.carried);
let selected = std::mem::replace(&mut self.selected, g.selected);
@@ -620,6 +644,8 @@ impl Game<'_> {
self.lan_acting_permission.replace(g.permission),
self.lan_acting_entity.replace(g.entity_id as u32),
);
// The damage hooks name the guest by UUID from here on.
super::native_damage::note_player(g.entity_id, g.uuid);
let packets = std::mem::take(&mut self.guest_packets);
let entity_events = std::mem::take(&mut self.guest_entity_events);
let equipment_breaks = std::mem::take(&mut self.guest_equipment_breaks);
@@ -1021,6 +1047,7 @@ impl Game<'_> {
g.selected = std::mem::replace(&mut self.selected, selected);
std::mem::swap(&mut self.inventory, &mut g.inventory);
std::mem::swap(&mut self.armor, &mut g.armor);
std::mem::swap(&mut self.location_armor, &mut g.location_armor);
std::mem::swap(&mut self.offhand, &mut g.offhand);
std::mem::swap(&mut self.carried, &mut g.carried);
// A teleport with a rotation (commands) turns the guest. `/rotate`
@@ -1074,7 +1101,15 @@ impl Game<'_> {
passengers: Vec::new(),
}));
}
// What moved them in their turn, for `native_player_teleport`.
let cause = horizon_client::lan::hub::teleport_cause(None);
if g.vehicle.is_none() && (moved > 0.0001 || turned || teleported) {
let other = if left_vehicle.is_some() {
"dismount"
} else {
"unknown"
};
horizon_client::lan::hub::teleport_cause(Some(cause.unwrap_or(other)));
host.hub.teleport(i, Some(at));
} else if teleported {
// ServerPlayer.startRiding and an asPassenger transition also
@@ -1149,6 +1184,10 @@ impl Game<'_> {
if let Some(mob) = guest_mount {
self.lan_open_mount(host, conn, mob);
}
// A use a mod canceled: the client's prediction of it is taken back.
if std::mem::take(&mut self.guest_inventory_resend) {
host.hub.send_inventory(i);
}
// The turn's teleports are in the hub now: the mods hear them.
self.flush_teleport_notes(host);
// An inner turn may move, kill or change a player's mode. Once its
@@ -1654,6 +1693,8 @@ impl Game<'_> {
}
host.hub.container_closed(conn, window);
}
// A mod's menu with input slots gives them back (`clearContainer`).
self.native_menu_returned(host, conn, removal);
self.lan_merchant_closed(host, conn, None);
self.lan_crafting_removed(host, conn, None, removal);
if removal == MenuRemoval::Disconnected {
@@ -1806,19 +1847,7 @@ impl Game<'_> {
if self.guest_menu_still_valid(host, conn) {
return;
}
let mut windows: Vec<i32> = host
.hub
.guest(conn)
.and_then(|g| g.menu.as_ref().map(|m| m.window))
.into_iter()
.collect();
if let Some(p) = host.play.get(&conn) {
windows.extend(p.crafting.table.as_ref().map(|t| t.0));
windows.extend(p.station.as_ref().map(|s| s.window));
windows.extend(p.lectern.as_ref().map(|l| l.window));
windows.extend(p.merchant.as_ref().map(|m| m.window));
windows.extend(p.mount_window.as_ref().map(|m| m.window));
}
let windows = guest_open_windows(host, conn);
self.guest_close_all(host, conn);
for container in windows {
host.hub
@@ -1826,6 +1855,76 @@ impl Game<'_> {
}
}
/// The mods hear a guest's click in their own inventory or a vanilla
/// menu they have open (`native_inventory_click`, and a drag's end
/// `native_inventory_drag`) before any handler does. A canceled click
/// becomes a drag start of no type, which every menu takes as
/// `resetQuickCraft` and nothing else, so it moves nothing and the
/// client's predicted slots and cursor are answered as for any click the
/// server refused.
fn lan_inventory_click_hooks(
&mut self,
host: &mut LanHost,
conn: u32,
p: &mut play::ContainerClick,
) {
let Some(g) = host.hub.guest(conn).filter(|g| !g.spectator()) else {
return;
};
// A mod's own menu is `native_menu_clicked`'s.
if g.menu
.as_ref()
.is_some_and(|m| m.window == p.container && m.slots == Slots::Native)
|| (p.container != 0 && !guest_open_windows(host, conn).contains(&p.container))
{
return;
}
let (uuid, name) = (g.uuid, g.name.clone());
let play = host.play.entry(conn).or_default();
let mut cancel = false;
if p.input == 5 {
// A drag is heard once, at its end, with the slots it went over.
match p.button & 3 {
0 => play.drag_heard = Some(Vec::new()),
1 => {
let slot = i32::from(p.slot);
if let Some(slots) = play.drag_heard.as_mut()
&& !slots.contains(&slot)
{
slots.push(slot);
}
}
_ => {
if let Some(slots) = play.drag_heard.take() {
let kind = i32::from(p.button >> 2) & 3;
self.native_inventory_drag(
uuid,
&name,
p.container,
kind,
&slots,
&mut cancel,
);
}
}
}
} else {
play.drag_heard = None;
self.native_inventory_click(
uuid,
&name,
p.container,
i32::from(p.slot),
i32::from(p.button),
p.input,
&mut cancel,
);
}
if cancel {
(p.slot, p.button, p.input) = (-999, 3 << 2, 5);
}
}
/// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest
/// boat is still there and within the guest's entity interaction range
/// plus 4 of its box.
@@ -1952,6 +2051,27 @@ impl Game<'_> {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
// Plugin channels a client registers in play (minecraft:register), from a dead player
// too; the packet goes on as before.
if let S::CustomPayload(p) = &packet
&& let Some(register) = horizon_client::lan::channel_registration(&p.channel)
{
self.guest_channels(host, gi, &p.data, register);
}
// A plugin channel's message on a channel that isn't one of Horizon's own (the match below
// takes those) is for the mods, from a dead player too: vanilla's handleCustomPayload doesn't
// look at health.
let packet = match packet {
S::CustomPayload(p)
if p.channel != horizon_protocol::quiet::CHANNEL
&& p.channel != horizon_protocol::mod_net::CHANNEL
&& p.channel != horizon_client::lod_net::CHANNEL =>
{
self.guest_custom_payload(host, conn, p.channel, p.data);
return;
}
packet => packet,
};
// A dead player acts no more, but the death screen still respawns and chats: its
// commands run as anyone's (ServerGamePacketListenerImpl.handleChatCommand doesn't
// look at the player's health).
@@ -1976,6 +2096,13 @@ impl Game<'_> {
) {
self.guest_validate_menu(host, conn);
}
let mut packet = packet;
if let S::ContainerClick(p) = &mut packet {
self.lan_inventory_click_hooks(host, conn, p);
}
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let spectator = host.hub.guests[gi].spectator();
match packet {
S::PlayerAction(a) => match a.action {
@@ -2300,6 +2427,9 @@ impl Game<'_> {
.border
.contains(f64::from(p.pos[0]), f64::from(p.pos[2]))
{
if spectator && self.spectator_use_portal(host, conn, gi, p.pos) {
return;
}
let logged = self.world.block_log.as_ref().map_or(0, Vec::len);
let heard = self.audio.capture.as_ref().map_or(0, Vec::len);
let used = self.as_guest(host, conn, |g| {
@@ -2307,6 +2437,7 @@ impl Game<'_> {
g.forced_hit = Some(hit);
g.guest_use = Some(GuestUse::OnBlock(hand));
let swings = g.swings_started;
let predicted = g.predicted_swings;
// InteractionResult.consumesAction: a FAIL (`use_failed`,
// as BlockItem.place's failed placement) does not count,
// even though the click ended on it.
@@ -2324,7 +2455,8 @@ impl Game<'_> {
reset_use_attack_strength(&mut g.attack_ticks, swings, g.swings_started);
g.forced_hit = None;
g.guest_use = None;
(g.swings_started != swings, consumed, attempt)
let echo = g.swings_started != swings && g.predicted_swings == predicted;
(echo, consumed, attempt)
});
let swung = used.map(|u| u.0);
self.lan_swing_back(host, conn, swung);
@@ -2551,6 +2683,7 @@ impl Game<'_> {
S::ResourcePack(r) => {
let (uuid, name) = (host.hub.guests[gi].uuid, host.hub.guests[gi].name.clone());
self.as_guest(host, conn, |g| {
g.native_resource_pack_status_v2(uuid, &name, r.id, r.action);
g.native_resource_pack_status(uuid, &name, r.action);
});
}
@@ -2606,6 +2739,8 @@ impl Game<'_> {
}
}
S::ContainerClick(p) => {
// A mod's menu on an away player's file: read before the click.
let away = self.away_menu_click_start(host, gi, p.container);
let rules = ItemRules(world_items!(self));
let slots = host.hub.guests[gi]
.menu
@@ -2614,6 +2749,10 @@ impl Game<'_> {
.map(|m| m.slots);
let before = slots.and_then(|s| s.read(&self.world, &host.hub.guests[gi]));
let drops = host.hub.container_click(conn, &p, &mut self.world, &rules);
let drops = match away {
Some(click) => self.away_menu_click_end(host, gi, click, drops),
None => drops,
};
// A click that changed the container's slots: BlockEntity.setChanged,
// so comparators read it again (a double chest's other half follows).
if let Some(Slots::Block(key) | Slots::Double(key, _)) = slots
@@ -2650,6 +2789,7 @@ impl Game<'_> {
self.close_guest_vehicle_menu(host, conn, key);
}
host.hub.container_closed(conn, p.container);
self.native_menu_returned(host, conn, MenuRemoval::Live);
self.guest_carried_back(host, gi);
}
S::ChatCommand(c) => self.guest_chat_command(host, conn, &c.command),
@@ -2952,7 +3092,10 @@ impl Game<'_> {
let (item, count) = stack.map_or(("minecraft:air", 0), |s| (&*s.item, s.count as i32));
let mut cancel = false;
let mut hook = |game: &mut Self| {
game.native_creative_slot(uuid, &name, slot, item, count, &mut cancel)
game.native_creative_slot_v2(uuid, &name, slot, stack, &mut cancel);
if !cancel {
game.native_creative_slot(uuid, &name, slot, item, count, &mut cancel);
}
};
// The guest whose turn it is is in place already.
if self.lan_acting == Some(conn) {
@@ -3019,6 +3162,7 @@ impl Game<'_> {
at,
rotation: Some([yaw, pitch]),
vehicle: None,
cause: Some("spectate"),
});
return;
}
@@ -3027,6 +3171,7 @@ impl Game<'_> {
// A new body there: no fall distance or motion carried over, but the
// player's attributes (ServerPlayer.teleport keeps the entity).
g.new_body(true);
horizon_client::lan::hub::teleport_cause(Some("spectate"));
host.hub.teleport(gi, Some(at));
}
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
@@ -3440,12 +3585,24 @@ impl Game<'_> {
self.guest_tempban_command(host, conn, &words);
return;
}
// A native mod's command, for every player its level allows,
// whether the world allows commands or not (game/native_server.rs).
// A native mod's command, for every player its level allows (or,
// for one with a permission node, whom the node lets: asked when it
// runs), whether the world allows commands or not
// (game/native_server.rs).
let integrated = host.hub.config.integrated;
if let Some(level) = Self::native_command_level(host, command.trim_start_matches('/'))
&& let Some(permission) = host.hub.guest(conn).map(|g| g.permission)
&& permission >= level
let line = command.trim_start_matches('/');
// The level this one command runs at, which the mods may change.
let Some((uuid, name, mut permission)) = host
.hub
.guest(conn)
.map(|g| (g.uuid, g.name.clone(), g.permission))
else {
return;
};
self.native_player_command_level(uuid, &name, line, &mut permission);
let permission = permission.min(4);
if let Some(level) = Self::native_command_level(host, line)
&& (permission >= level || Self::native_command_node(host, line).is_some())
{
host.commands.push(GuestCommand {
conn,
@@ -3472,10 +3629,8 @@ impl Game<'_> {
return;
}
// The guest's own level (Commands.performCommand with the player's
// source): an operator's on a dedicated server, never the host's.
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
return;
};
// source): an operator's on a dedicated server, never the host's,
// unless a mod changed it for this command.
host.commands.push(GuestCommand {
conn,
command: command.trim_start_matches('/').to_owned(),
@@ -3503,12 +3658,19 @@ impl Game<'_> {
/// One guest's command as that guest, its feedback sent to them; false
/// when it failed (or the guest is gone).
pub(super) fn run_guest_command(&mut self, c: GuestCommand) -> bool {
let Some(mut host) = self.lan.take() else {
return false;
};
self.run_guest_command_told(c).is_some_and(|(ok, _)| ok)
}
/// `run_guest_command`, with the lines it sent the guest; None when the
/// guest is gone.
pub(super) fn run_guest_command_told(
&mut self,
c: GuestCommand,
) -> Option<(bool, Vec<horizon_client::text::Text>)> {
let mut host = self.lan.take()?;
let Some(swap) = self.enter_guest(&mut host, c.conn, self.dim) else {
self.lan = Some(host);
return false;
return None;
};
self.lan = Some(host);
self.chat_capture = Some(Vec::new());
@@ -3536,7 +3698,7 @@ impl Game<'_> {
}
// What the command earned is announced before its feedback.
self.announce_guest_advancements(&mut host);
for line in lines {
for line in &lines {
host.hub.send_to(
c.conn,
&C::SystemChat(play::SystemChat {
@@ -3548,7 +3710,7 @@ impl Game<'_> {
// The other operators hear of it; never the player whose command it was.
self.send_admin_feedback(&mut host, Some(c.conn));
self.lan = Some(host);
ok
Some((ok, lines))
}
/// The `broadcastToAdmins` lines a command queued (`source`: the guest
@@ -3653,8 +3815,31 @@ impl Game<'_> {
let own_banned_ips = std::mem::replace(&mut reg.banned_ips, banned_ips);
let own_whitelisted = std::mem::replace(&mut reg.whitelisted, whitelisted);
let parse = d.parse(command, &self.ui.registries);
let ((start, end), mut suggestions) =
let ((mut start, mut end), mut suggestions) =
d.suggestions(&parse, command.len(), &self.ui.registries);
let (label, rest) = command.split_once(' ').unwrap_or((command, ""));
let arguments: Vec<String> = if command.contains(' ') {
rest.split(' ').map(str::to_owned).collect()
} else {
Vec::new()
};
// A mod's command, which vanilla's tree doesn't know: the word being typed.
if let Some(typed) = arguments.last()
&& Self::native_command_level(host, command).is_some_and(|l| l <= permission)
{
(start, end) = (command.len() - typed.len(), command.len());
let words: Vec<&str> = arguments.iter().map(String::as_str).collect();
if super::native_server::completes_player_name(
&host.native_usages,
permission,
label,
&words,
) {
let mut names = horizon_client::command::SuggestionsBuilder::new(command, start);
names.suggest_matching(host.hub.guests.iter().map(|g| g.name.as_str()));
suggestions.extend(names.out);
}
}
self.native_tab_complete(
player_uuid,
&player_name,
@@ -3662,6 +3847,14 @@ impl Game<'_> {
[start, end],
&mut suggestions,
);
self.native_tab_complete_v2(
player_uuid,
&player_name,
label,
&arguments,
[start, end],
&mut suggestions,
);
let reg = &mut self.ui.registries;
reg.permission = level;
reg.players = own_players;
@@ -3693,7 +3886,7 @@ impl Game<'_> {
);
}
fn guest_respawn(&mut self, host: &mut LanHost, conn: u32) {
pub(super) fn guest_respawn(&mut self, host: &mut LanHost, conn: u32) {
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
@@ -3737,11 +3930,11 @@ impl Game<'_> {
.respawn_elsewhere
.as_ref()
.map(RespawnConfig::from_saved);
let elsewhere = own.filter(|_| server);
let mut elsewhere = own.filter(|_| server);
// In the window's world a guest has no level of its own to wait in:
// its bed is resolved at once when its chunk is there, else it
// comes back on the bed.
let in_frame_bed = own.filter(|c| !server && c.dim == super::Dim::Overworld);
let mut in_frame_bed = own.filter(|c| !server && c.dim == super::Dim::Overworld);
let spawn = in_frame_bed.map_or(spawn, |c| {
[
f64::from(c.pos[0]) + 0.5,
@@ -3749,14 +3942,34 @@ impl Game<'_> {
f64::from(c.pos[2]) + 0.5,
]
});
let level = elsewhere.map_or(super::Dim::Overworld, |c| c.dim);
let spawn = elsewhere.map_or(spawn, |c| {
let mut level = elsewhere.map_or(super::Dim::Overworld, |c| c.dim);
let mut spawn = elsewhere.map_or(spawn, |c| {
[
f64::from(c.pos[0]) + 0.5,
f64::from(c.pos[1]),
f64::from(c.pos[2]) + 0.5,
]
});
// PlayerRespawnPositionEvent: a point the mods choose is taken as it is.
let (mut dimension, mut at, mut rotation) = (level.id().to_owned(), spawn, [0.0f32; 2]);
let (uuid, name) = (host.hub.guests[i].uuid, host.hub.guests[i].name.clone());
let [yaw, pitch] = &mut rotation;
self.native_player_respawn_position(uuid, &name, &mut dimension, &mut at, yaw, pitch);
let known = super::Dim::from_id(&dimension).id() == dimension;
let moved = at != spawn && at.iter().all(|v| v.is_finite());
let chosen = (known && dimension != level.id()) || moved;
if chosen {
(elsewhere, in_frame_bed) = (None, None);
if known && server {
level = super::Dim::from_id(&dimension);
}
if moved {
spawn = at;
}
}
if !rotation.iter().all(|v| v.is_finite()) {
rotation = [0.0; 2];
}
let back = self.dim;
let left = super::Dim::from_id(&host.hub.guests[i].dimension);
let away = server && host.hub.guests[i].dimension != level.id();
@@ -3812,7 +4025,7 @@ impl Game<'_> {
border: &self.border,
streaming: Some(&self.streaming),
};
host.hub.respawn(i, spawn, &ctx);
host.hub.respawn(i, spawn, rotation, &ctx);
// ServerGamePacketListenerImpl.handleClientCommand: a hardcore world's
// respawned player only watches.
if self.level.hardcore {
@@ -3831,7 +4044,7 @@ impl Game<'_> {
}
if let Some(config) = elsewhere {
self.respawn_trip(conn, config);
} else if in_frame_bed.is_none() && host.hub.guests[i].spawn.is_none() {
} else if !chosen && in_frame_bed.is_none() && host.hub.guests[i].spawn.is_none() {
// TeleportTransition.createDefault: no point of its own, so the
// world spawn's free column once its chunks have loaded.
self.world_spawn_trip(conn);
@@ -4047,6 +4260,30 @@ impl Game<'_> {
}
}
/// `native_player_logging_out` for guest `conn`, who has left but is still in the hub
/// and in their level (`HostEvent::Left`): the hub goes back into `self.lan` for the
/// hook, so its helpers and commands reach them, and a death it caused (`kill`) is
/// died at once, as they get no tick to die in. None when a hook's command closed the hub.
pub(super) fn tell_mods_logging_out(&mut self, host: LanHost, conn: u32) -> Option<LanHost> {
let Some((uuid, name, alive)) = host
.hub
.guest(conn)
.map(|g| (g.uuid, g.name.clone(), g.health > 0.0 && !g.dead))
else {
return Some(host);
};
self.lan = Some(host);
self.native_player_logging_out(uuid, &name);
let mut host = self.lan.take()?;
if alive
&& let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn)
&& host.hub.guests[gi].health <= 0.0
{
self.guest_die(&mut host, gi, conn);
}
Some(host)
}
/// `ServerPlayer.die` for a guest whose health has run out: the death
/// message, the loot and experience, the death screen.
fn guest_die(&mut self, host: &mut LanHost, gi: usize, conn: u32) {
@@ -5346,9 +5583,10 @@ impl Game<'_> {
let Some(item) = self.dropped.iter().find(|d| d.id == item_id) else {
continue;
};
let stack = item.stack.clone();
let refused =
self.as_guest(host, conn, |g| g.native_pickup_refused(&stack)) == Some(true);
let (stack, thrower, at) = (item.stack.clone(), item.thrower, item.position);
let refused = self
.as_guest(host, conn, |g| g.native_pickup_refused(&stack, thrower, at))
== Some(true);
// The hook can remove or change any item entity.
let Some(i) = self.dropped.iter().position(|d| d.id == item_id) else {
continue;
@@ -5711,10 +5949,10 @@ impl Game<'_> {
}
/// A use the server ran swung the guest's hand: their own client is told
/// too (`swing(hand, true)` for a SwingSource.SERVER result). Horizon's
/// client does not run uses itself, so it does not predict the swing; a
/// client that did (a block placed) is still in the first half of its
/// swing and ignores it (`SwingState.startIfAble`).
/// too (`swing(hand, true)` for a result that is not SwingSource.PREDICTED).
/// A predicted swing (a block placed, a block's own SUCCESS) is the
/// client's own: sent back, it arrived after the first half of that swing
/// on a slow connection and swung the arm again (#9887).
fn lan_swing_back(&mut self, host: &mut LanHost, conn: u32, swung: Option<bool>) {
if swung != Some(true) {
return;
@@ -6208,6 +6446,7 @@ mod respawn_world_point_tests {
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
2,
[0.5, 100.0, 0.5],
@@ -6260,4 +6499,283 @@ mod respawn_world_point_tests {
drop(game);
std::fs::remove_dir_all(scratch).unwrap();
}
/// A mod's health 0 for a guest dies as `/kill` does: the death message
/// and the death screen, not only a zero in the record.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_mods_zero_health_kills_a_guest_as_kill_does() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let scratch = std::env::temp_dir().join(format!(
"horizon-status-death-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&scratch).unwrap();
game.world_root = scratch.clone();
game.level.world_spawn = Some([0, 100, 0]);
game.spawn = Some(Vec3::new(0.5, 100.0, 0.5));
game.lan_host(
super::super::lan::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "status-death-test".into(), 2)
},
HubConfig {
default_game_mode: 0,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 2,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let (tx, _rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn: 2,
uuid: 331,
name: "StatusGuest".into(),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
2,
[0.5, 100.0, 0.5],
2,
);
guest.dimension = "minecraft:overworld".into();
guest.placed = true;
guest.loaded = true;
let mut host = game.lan.take().unwrap();
host.hub.guests.push(guest);
// Inside the player's own turn, as a tick hook sets it.
let set = game.as_guest(&mut host, 2, |g| {
g.native_set_player_status(331, Some(0.0), None, None)
});
assert_eq!(set, Some(true));
assert_eq!(host.hub.guests[0].health, 0.0);
game.guest_die(&mut host, 0, 2);
assert!(host.hub.guests[0].dead, "health 0 must kill");
drop(host);
drop(game);
std::fs::remove_dir_all(scratch).unwrap();
}
}
#[cfg(test)]
mod swing_echo_tests {
use super::ItemStack;
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_client::lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig};
use horizon_core::position::ChunkPos;
use horizon_protocol::packets::{
configuration::ClientInformation,
play::{self, Clientbound, Serverbound},
};
use std::sync::{Arc, atomic::AtomicI32, mpsc};
/// handleUseItemOn swings the player's arm, but sends the swing to the
/// player's own client only when the client did not predict it: a block
/// placed (SUCCESS) is the client's own swing, a bed's use
/// (SUCCESS_SERVER) is not (#9887).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn only_a_server_only_use_swings_back_to_the_player() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.game_dir = Some(
std::env::temp_dir().join(format!("horizon-swing-echo-unit-{}", std::process::id())),
);
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap()
.0;
let mut chunk = Chunk::empty(0, 0);
for x in 0..16 {
for z in 0..16 {
chunk.set(x, 199, z, stone);
}
}
game.world.chunks.insert((0, 0), Arc::new(chunk));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("setblock 6 200 4 red_bed[facing=north,part=foot]"));
assert!(game.run_command("setblock 6 200 3 red_bed[facing=north,part=head]"));
game.lan_host(
super::super::lan::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "swing-echo-test".into(), 2)
},
HubConfig {
default_game_mode: 0,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 2,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let (tx, rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn: 2,
uuid: 331,
name: "SwingGuest".into(),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
2,
[4.5, 200.0, 4.5],
2,
);
guest.dimension = "minecraft:overworld".into();
guest.placed = true;
guest.loaded = true;
guest.inventory[0] = Some(ItemStack::new("minecraft:stone", 16));
guest.selected = 0;
let mut host = game.lan.take().unwrap();
host.hub.guests.push(guest);
let id = host.hub.guests[0].entity_id;
let swings_back = || {
let mut n = 0;
for out in rx.try_iter() {
let bodies = match out {
Out::Packet(body) => vec![body],
Out::Packets(bodies) => bodies,
Out::Disconnect(_) => panic!("unexpected disconnect"),
};
for body in bodies {
let mut r = horizon_protocol::buf::Reader::new(&body);
let packet_id = r.varint().unwrap();
if let Ok(Clientbound::SwingAnimation(p)) =
Clientbound::decode(packet_id, r.rest())
&& p.id == id
{
n += 1;
}
}
}
n
};
let use_on = |pos: [i32; 3], cursor: [f32; 3], sequence: i32| {
Serverbound::UseItemOn(play::UseItemOn {
hand: 0,
pos,
face: 1,
cursor,
inside: false,
world_border: false,
sequence,
})
};
// BlockItem.place: SUCCESS, the client's own swing.
game.lan_guest_packet(&mut host, 2, use_on([4, 199, 6], [0.5, 1.0, 0.5], 1));
assert_eq!(game.world.block(4, 200, 6).0, stone, "the stone is placed");
assert_eq!(swings_back(), 0, "a placement is not swung back");
// The swing ends before the next use can start one.
for _ in 0..8 {
game.lan_swings(&mut host);
}
host.hub.guests[0].selected = 1;
// BedBlock.useWithoutItem: SUCCESS_SERVER, the server's swing only.
game.lan_guest_packet(&mut host, 2, use_on([6, 200, 4], [0.5, 0.5625, 0.5], 2));
assert_eq!(swings_back(), 1, "a bed's use is swung back");
}
}
+12 -4
View File
@@ -6,6 +6,7 @@
use super::Game;
use horizon_client::lan::{
entity::{EntityState, Riding, States},
guest::Guest,
hub::Hub,
playerdata::ints_uuid,
};
@@ -157,10 +158,17 @@ impl Game<'_> {
/// Who rides what (`Entity.getPassengers`): the players in their vehicles and
/// mobs on mobs.
pub(super) fn lan_riding(&self, guests: &[(u32, i32)], riding: &mut Riding) {
pub(super) fn lan_riding(&self, guests: &[Guest], riding: &mut Riding) {
riding.clear();
// The server's players in their boats and minecarts.
for &(vehicle, guest) in guests {
// The server's players in their boats and minecarts, in the order they
// got in: the first one drives (`getControllingPassenger`), and its
// client is the one that moves the vehicle.
let mut seated: Vec<(u64, u32, i32)> = guests
.iter()
.filter_map(|g| Some((g.vehicle_order, g.vehicle?, g.entity_id)))
.collect();
seated.sort_by_key(|&(order, ..)| order);
for (_, vehicle, guest) in seated {
riding.add(vehicle as i32, guest);
}
for m in self.mobs.iter().filter(|m| !m.removed) {
@@ -462,7 +470,7 @@ impl Game<'_> {
/// The synched data of a text, block or item display kept unsimulated,
/// from its compound (`Display.readAdditionalSaveData`), with the
/// defaults to send when a value goes back to one.
fn lan_display_data(&self, tag: &Tag, hub: &mut Hub, e: &mut EntityState) {
pub(super) fn lan_display_data(&self, tag: &Tag, hub: &mut Hub, e: &mut EntityState) {
use horizon_client::text_display::{display_data, text_display_data};
let empty = Tag::Compound(Default::default());
let (meta, defaults) = match e.kind.as_str() {
@@ -159,6 +159,26 @@ fn guest_view(mut v: PlayerView, uuid: u128, moved: [f64; 3]) -> PlayerView {
}
impl Game<'_> {
/// How mobs see the guest `conn`, as `lan_guest_views` gives each: None
/// for no such guest in play here, or while the hub is taken out.
pub(super) fn lan_guest_view(&mut self, conn: u32) -> Option<PlayerView> {
let mut host = self.lan.take()?;
let found = host
.hub
.guests
.iter()
.find(|g| g.conn == conn && host.hub.here(g) && g.loaded && !g.dead)
.map(|g| {
let moved = [0, 1, 2].map(|k| g.position[k] - g.previous_position[k]);
(g.uuid, moved)
});
let view = found.and_then(|(uuid, moved)| {
let v = self.as_guest(&mut host, conn, |g| g.player_view())??;
Some(guest_view(v, uuid, moved))
});
self.lan = Some(host);
view
}
/// How mobs see each guest in play (`player_view` with the guest swapped
/// in).
pub(super) fn lan_guest_views(&mut self) -> GuestViews {
@@ -978,6 +978,7 @@ mod tests {
latency: std::sync::Arc::new(std::sync::atomic::AtomicI32::new(0)),
address: format!("127.0.0.1:{}", 25565 + conn).parse().unwrap(),
expected: false,
configured: Default::default(),
},
1,
at,
+113 -2
View File
@@ -30,6 +30,42 @@ pub(super) fn meets_players(kind: &ProjectileKind) -> bool {
)
}
/// The player-versus-player hit being landed, which `native_player_hurt_by_player`
/// hears at the victim's `hurtServer` (`native_hooks::hurt_server`).
#[derive(Clone, Copy)]
pub(super) struct PvpHit {
pub(super) attacker: u128,
pub(super) attacker_entity: i32,
pub(super) victim: u128,
pub(super) victim_entity: i32,
/// `melee`, `sweep` or the projectile's entity type.
pub(super) source: &'static str,
}
thread_local! {
static PVP_HIT: std::cell::Cell<Option<PvpHit>> = const { std::cell::Cell::new(None) };
}
/// The hit being landed on the player `victim_entity`, if one is.
pub(super) fn pvp_hit(victim_entity: i32) -> Option<PvpHit> {
PVP_HIT
.get()
.filter(|hit| hit.victim_entity == victim_entity)
}
/// Names `hit` as the one being landed until it drops.
struct PvpScope(Option<PvpHit>);
impl Drop for PvpScope {
fn drop(&mut self) {
PVP_HIT.set(self.0);
}
}
fn pvp_scope(hit: Option<PvpHit>) -> PvpScope {
PvpScope(PVP_HIT.replace(hit))
}
/// Who a player's attack lands on: a player by connection.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(super) enum Victim {
@@ -259,10 +295,25 @@ impl Game<'_> {
at,
};
let shooter = self.player_attacker(&host, p.guest);
let spectral = p
.arrow
.as_ref()
.is_some_and(|a| a.spectral || &*a.pickup_item.item == "minecraft:spectral_arrow");
let source = if spectral {
"minecraft:spectral_arrow"
} else {
p.kind.id()
};
let hit = self.pvp_hit_of(&host, p.guest, victim, source);
let zero = matches!(p.kind, ProjectileKind::Snowball | ProjectileKind::Egg);
let result = match victim {
Victim::Guest(conn) => self.guest_hit_by(&mut host, conn, |g| {
g.combat.refuse_player_hit = refused;
g.combat.projectile_attacker = shooter;
let _hit = pvp_scope(hit);
if zero && !refused {
g.pvp_zero_hurt(hit, "minecraft:thrown");
}
let r = g.projectile_hit(p, on_player);
g.combat.refuse_player_hit = false;
g.combat.projectile_attacker = None;
@@ -273,6 +324,58 @@ impl Game<'_> {
result.unwrap_or(Ok(()))
}
/// The hit of `attacker` (a guest; none: this game's player) on `victim` as
/// `native_player_hurt_by_player` names it, none when either has gone.
fn pvp_hit_of(
&mut self,
host: &LanHost,
attacker: Option<u32>,
victim: Victim,
source: &'static str,
) -> Option<PvpHit> {
let Victim::Guest(conn) = victim;
let (victim, victim_entity) = host.hub.guest(conn).map(|g| (g.uuid, g.entity_id))?;
let (attacker, attacker_entity) = match attacker {
Some(conn) => host.hub.guest(conn).map(|g| (g.uuid, g.entity_id))?,
None => (self.native_acting_id(), self.player.as_ref()?.entity_id),
};
Some(PvpHit {
attacker,
attacker_entity,
victim,
victim_entity,
source,
})
}
/// `Player.hurtServer` of a snowball's or egg's zero damage on the player in place: past
/// its guards (not creative, a spectator or dead) the mods hear it, then it stops on zero.
fn pvp_zero_hurt(&mut self, hit: Option<PvpHit>, damage_type: &str) {
let Some(hit) = hit else {
return;
};
if self.creative
|| self.spectator()
|| self
.player
.as_ref()
.is_none_or(|p| p.health.dead_or_dying())
{
return;
}
let (mut amount, mut canceled) = (0.0, false);
Self::native_player_hurt_by_player(
hit.attacker,
hit.attacker_entity,
hit.victim,
hit.victim_entity,
hit.source,
damage_type,
&mut amount,
&mut canceled,
);
}
pub(super) fn player_attacker(&self, host: &LanHost, conn: Option<u32>) -> Option<(u32, Text)> {
match conn {
Some(conn) => {
@@ -314,6 +417,7 @@ impl Game<'_> {
.map(|a| (a.entity_id, self.player_text(&a.name, a.uuid)))
});
let attacker_id = self.player.as_ref().map_or(0, |p| p.entity_id);
let hit = self.pvp_hit_of(host, s.attacker, victim, "melee");
let land = |g: &mut Self| {
let _attacker =
g.knockback_scope(attacker_note.as_ref().map_or(attacker_id, |(id, _)| *id));
@@ -321,7 +425,10 @@ impl Game<'_> {
if let Some((id, name)) = attacker_note.clone() {
g.note_player_attacker("minecraft:player_attack", id as u32, name, s.item.clone());
}
let hurt = g.mob_hits_player(s.amount, s.from, "minecraft:player_attack");
let hurt = {
let _hit = pvp_scope(hit);
g.mob_hits_player(s.amount, s.from, "minecraft:player_attack")
};
g.combat.player_attacker = None;
let (strength, xd, zd) = s.knockback;
if hurt && strength > 0.0 {
@@ -537,8 +644,12 @@ impl Game<'_> {
{
continue;
}
let hit = self.pvp_hit_of(host, sweep.attacker, victim, "sweep");
let Victim::Guest(conn) = victim;
self.guest_struck_by(host, conn, land);
self.guest_struck_by(host, conn, |g| {
let _hit = pvp_scope(hit);
land(g)
});
}
}
}
+246 -23
View File
@@ -38,11 +38,13 @@ fn corners(b: Aabb) -> ([f64; 3], [f64; 3]) {
([lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z])
}
/// A boat or minecart a server player rode, followed for
/// `native_vehicle_moved`.
/// A boat, minecart or mount a server player rode, followed for
/// `native_vehicle_moved` (a mount only for its v2).
pub(super) struct WatchedVehicle {
id: u32,
dim: Dim,
/// A mob (a horse, a pig), which v1 does not hear.
mount: bool,
/// The server player it carries now.
rider: Option<u128>,
/// Where the hook last heard of it.
@@ -52,18 +54,25 @@ pub(super) struct WatchedVehicle {
}
impl Game<'_> {
/// Where the boat or minecart `id` of this level is.
fn watched_vehicle_position(&self, id: u32) -> Option<[f64; 3]> {
/// Where the boat, minecart or mount `id` of this level is, and whether
/// it is a mount.
fn watched_vehicle_position(&self, id: u32) -> Option<([f64; 3], bool)> {
let at = |v: Vec3| [v.x, v.y, v.z];
self.boats
.iter()
.find(|b| b.id == id)
.map(|b| at(b.position))
.map(|b| (at(b.position), false))
.or_else(|| {
self.carts
.iter()
.find(|c| c.id == id)
.map(|c| at(c.position))
.map(|c| (at(c.position), false))
})
.or_else(|| {
self.mobs
.iter()
.find(|m| m.id == id && !m.removed)
.map(|m| (at(m.position), true))
})
}
@@ -82,7 +91,7 @@ impl Game<'_> {
}
let dim = self.dim;
for &(id, _, uuid) in &riders {
let Some(at) = self.watched_vehicle_position(id) else {
let Some((at, mount)) = self.watched_vehicle_position(id) else {
continue;
};
match self.watched_vehicles.iter_mut().find(|w| w.id == id) {
@@ -90,6 +99,7 @@ impl Game<'_> {
None => self.watched_vehicles.push(WatchedVehicle {
id,
dim,
mount,
rider: Some(uuid),
at,
still: 0,
@@ -97,7 +107,7 @@ impl Game<'_> {
}
}
let mut kept = Vec::new();
// (rider's connection and UUID, vehicle type, from, to)
// (rider's connection and UUID, vehicle, type, mount, from, to)
let mut moved = Vec::new();
for mut w in std::mem::take(&mut self.watched_vehicles) {
// Another level's vehicle is told of on that level's tick.
@@ -106,7 +116,7 @@ impl Game<'_> {
continue;
}
// Broken, or gone from this level.
let Some(now) = self.watched_vehicle_position(w.id) else {
let Some((now, _)) = self.watched_vehicle_position(w.id) else {
continue;
};
let rider = riders.iter().find(|r| r.0 == w.id);
@@ -118,7 +128,7 @@ impl Game<'_> {
self.level_entity(w.id as i32)
.unwrap_or(super::commands::Target::Boat(w.id)),
);
moved.push((rider.map(|r| (r.1, r.2)), kind, from, now));
moved.push((rider.map(|r| (r.1, r.2)), w.id, kind, w.mount, from, now));
} else {
w.still += 1;
if w.rider.is_none() && w.still > 200 {
@@ -130,21 +140,66 @@ impl Game<'_> {
self.watched_vehicles = kept;
// The rider's state is swapped in for the call, so the helpers reach
// them; a vehicle nobody rides is told of with nobody swapped in.
for (rider, kind, from, to) in moved {
let Some((conn, uuid)) = rider else {
self.native_vehicle_moved(None, &kind, from, to);
continue;
for (rider, id, kind, mount, from, to) in moved {
let (mut dest, mut cancel) = (to, false);
let mut hook = |game: &mut Self| {
if !mount {
game.native_vehicle_moved(rider.map(|r| r.1), &kind, from, to);
}
let player = rider.map(|r| r.1);
game.native_vehicle_moved_v2(
player,
dim.id(),
id,
&kind,
from,
&mut dest,
&mut cancel,
);
};
let Some(mut host) = self.lan.take() else {
continue;
};
let hook = |game: &mut Self| game.native_vehicle_moved(Some(uuid), &kind, from, to);
if self.lan_acting == Some(conn) {
hook(self);
} else {
self.as_guest(&mut host, conn, hook);
match rider {
None => hook(self),
Some((conn, _)) => {
let Some(mut host) = self.lan.take() else {
continue;
};
if self.lan_acting == Some(conn) {
hook(self);
} else {
self.as_guest(&mut host, conn, hook);
}
self.lan = Some(host);
}
}
self.lan = Some(host);
self.native_vehicle_move_answer(id, from, to, dest, cancel);
}
}
/// `native_vehicle_moved_v2`'s answer: a canceled move puts the vehicle
/// back at `from`, a changed `to` puts it there, with its riders
/// (`native_teleport_vehicle`), and the watch hears it from there.
fn native_vehicle_move_answer(
&mut self,
id: u32,
from: [f64; 3],
to: [f64; 3],
dest: [f64; 3],
cancel: bool,
) {
let at = if cancel {
from
} else if dest != to && dest.iter().all(|v| v.is_finite()) {
dest
} else {
return;
};
if !self.native_teleport_vehicle(self.dim.id(), id, at) {
return;
}
if let Some((now, _)) = self.watched_vehicle_position(id)
&& let Some(w) = self.watched_vehicles.iter_mut().find(|w| w.id == id)
{
w.at = now;
}
}
@@ -387,6 +442,13 @@ impl Game<'_> {
if g.awaiting_teleport.is_some() || !g.client_loaded() {
return;
}
// Only the vehicle's controlling passenger moves it: the move of a
// player in a boat's second seat is ignored (#11541).
if g.vehicle
.is_some_and(|v| !self.guest_controls_vehicle(host, conn, v))
{
return;
}
let owner = host.hub.singleplayer_owner == Some(g.uuid);
let (vehicle, good) = (g.vehicle, g.vehicle_good);
let back = |host: &mut LanHost, position: Vec3, yaw: f32| {
@@ -563,3 +625,164 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod controller_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::chunk::Chunk;
use horizon_client::lan::{Joined, NetConfig, PlayerSender, guest::Guest, hub::HubConfig};
use horizon_core::position::ChunkPos;
use horizon_protocol::packets::{configuration::ClientInformation, play};
use std::sync::{Arc, atomic::AtomicI32, mpsc};
fn guest(conn: u32, uuid: u128) -> Guest {
let (tx, _rx) = mpsc::channel();
let mut g = Guest::new(
Joined {
conn,
uuid,
name: format!("Rider{conn}"),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
1000 + conn as i32,
[4.5, 200.0, 4.5],
2,
);
g.dimension = "minecraft:overworld".into();
g.placed = true;
g.loaded = true;
// PlayerLoaded came in: its moves count.
g.load_timeout = 0;
g
}
/// Two players in one boat: the one that got in first drives it
/// (`getControllingPassenger`), whichever joined the server first. Its
/// client hears it sits first, and only its moves count; the second
/// seat's client, which still sends where it sees the boat, used to put
/// the boat back every tick (#11541).
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn the_first_rider_in_drives_the_boat() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
options.game_dir = Some(
std::env::temp_dir().join(format!("horizon-boat-driver-unit-{}", std::process::id())),
);
options.execution_role = crate::game::execution_role::ExecutionRole::IntegratedServer;
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
game.build_registries();
game.set_allow_commands(true);
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap()
.0;
let mut chunk = Chunk::empty(0, 0);
for x in 0..16 {
for z in 0..16 {
chunk.set(x, 199, z, stone);
}
}
game.world.chunks.insert((0, 0), Arc::new(chunk));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
.unwrap();
assert!(game.run_command("forceload add 0 0"));
assert!(game.run_command("summon minecraft:oak_boat 4.5 200 4.5"));
let boat = game.boats[0].id;
let start = game.boats[0].position;
game.lan_host(
super::super::lan::LanSettings {
port: 0,
guest_commands: true,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "boat-driver-test".into(), 3)
},
HubConfig {
default_game_mode: 0,
guest_commands: true,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 3,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let mut host = game.lan.take().unwrap();
// The second seat's player joined the server first; the driver got
// into the boat first.
let (mut passenger, mut driver) = (guest(2, 21), guest(3, 22));
(driver.vehicle, driver.vehicle_order) = (Some(boat), 1);
(passenger.vehicle, passenger.vehicle_order) = (Some(boat), 2);
let (passenger_id, driver_id) = (passenger.entity_id, driver.entity_id);
host.hub.guests.push(passenger);
host.hub.guests.push(driver);
game.lan_riding(&host.hub.guests, &mut host.hub.riding);
assert_eq!(
host.hub.riding.passengers(boat as i32),
[driver_id, passenger_id],
"the driver sits first"
);
let move_to = |x: f64| play::MoveVehicleRequest {
position: [x, start.y, start.z],
yaw: 0.0,
pitch: 0.0,
on_ground: true,
};
game.guest_move_vehicle(&mut host, 2, &move_to(start.x + 0.3));
assert_eq!(
game.boats[0].position, start,
"the second seat moves nothing"
);
game.guest_move_vehicle(&mut host, 3, &move_to(start.x + 0.3));
assert!(
(game.boats[0].position.x - (start.x + 0.3)).abs() < 1.0e-9,
"the driver moves the boat: {:?}",
game.boats[0].position
);
}
}
+15 -1
View File
@@ -144,7 +144,6 @@ impl Levels {
self.parked.get(&dim).map(|level| &level.world)
}
/// The worlds of the levels not in place.
/// A parked level's world border.
pub(super) fn parked_border(
&self,
@@ -153,6 +152,7 @@ impl Levels {
self.parked.get(&dim).map(|level| &level.border)
}
/// The worlds of the levels not in place.
pub(super) fn parked_worlds(&self) -> impl Iterator<Item = &World> {
self.parked.values().map(|level| &level.world)
}
@@ -162,6 +162,11 @@ impl Levels {
self.parked.get_mut(&dim).map(|level| &mut level.world)
}
/// The streaming of the level not in place, in `dim`, to change.
pub(super) fn parked_streaming_mut(&mut self, dim: Dim) -> Option<&mut Streaming> {
self.parked.get_mut(&dim).map(|level| &mut level.streaming)
}
/// The level not in place, in `dim`.
pub(super) fn parked_mut(&mut self, dim: Dim) -> Option<&mut Parked> {
self.parked.get_mut(&dim)
@@ -413,6 +418,8 @@ impl Game<'_> {
if opened {
// The new streaming's radius and meshing options.
self.apply_settings();
// A mod's tickets held for the level while it was closed.
self.native_chunk_tickets_opened();
self.native_level_opened(target);
}
}
@@ -578,6 +585,10 @@ impl Game<'_> {
// `ServerTickEnd`: an errored tick or an idle-close failure (at
// the `?` above) never reaches this site.
self.native_lifecycle_server_tick_end(paused);
// The packets counted for `native_server_packets` (only while a mod counts them).
if server {
self.diagnostics_packets_tick();
}
Ok(())
}
@@ -628,6 +639,7 @@ impl Game<'_> {
slowest_steps(&notes)
);
}
self.native_server_tick_timed(wall, cpu, &spans);
record(wall, cpu, spans, in_tick);
result
}
@@ -702,6 +714,8 @@ impl Game<'_> {
|| self.mod_container_viewers.keys().any(|(k, _)| *k == d.id())
|| self.player_bed_levels().any(|b| b == *d)
|| forced
// A native mod's tickets for a level not open yet (`native_force_chunk_in`).
|| self.mod_chunk_tickets.get(d).is_some_and(|l| !l.is_empty())
// A game test runs in its own level, players or not.
|| self.game_tests.has_tests_in(*d)
|| self.pregen_level() == Some(*d)
+56 -1
View File
@@ -300,7 +300,8 @@ impl Game<'_> {
});
}
/// `spawnFire`: fire at the strike point and `extra` random neighbours,
/// wherever `BaseFireBlock.getState` fire can survive. Returns how many
/// wherever `BaseFireBlock.getState` fire can survive, when a player is
/// within the fire-spread radius. Returns how many
/// blocks it set on fire.
fn bolt_fire(&mut self, at: [f64; 3], extra: i32) -> i32 {
let mut set = 0;
@@ -309,6 +310,14 @@ impl Game<'_> {
at[1].floor() as i32,
at[2].floor() as i32,
];
// ServerLevel.canSpreadFireAround at the bolt: no fire at all, and no
// draws for the extra sources, away from every player (#11376).
let origin = BlockPos::new(base[0], base[1], base[2]);
if !self.with_level(|_, level, _| {
horizon_world::level::LevelAccess::can_spread_fire_around(level, origin)
}) {
return 0;
}
let mut spots = vec![base];
for _ in 0..extra {
let r = &mut self.world.random;
@@ -756,6 +765,52 @@ mod tests {
use horizon_core::geometry::Vec3;
use std::sync::Arc;
/// `LightningBolt.spawnFire` places nothing, and draws no extra sources,
/// unless `canSpreadFireAround` passes at the bolt (#11376).
#[test]
#[ignore = "needs prepared resources (HORIZON_TEST_RESOURCES)"]
fn a_bolt_away_from_players_starts_no_fire() {
use horizon_client::gamerules::Value;
let mut game = super::super::native_machines::tests::server_game();
let stone = game
.pack
.assets
.registry
.default_state("minecraft:stone")
.unwrap();
let air = game.res.facts().air;
game.world.set(8, 64, 8, stone).unwrap();
let bolt = [8.5, 65.0, 8.5];
// (radius, player's x): fires expected at the strike.
for (radius, player_x, fires) in [
(128, 12.5, true),
(1, 12.5, false),
(0, 8.5, false),
(-1, 1000.0, true),
] {
game.world.set(8, 65, 8, air).unwrap();
game.ui
.rules
.set("fire_spread_radius_around_player", Value::Int(radius));
game.player = Some(
horizon_client::motion::PlayerMotion::new(
game.motion,
Vec3::new(player_x, 65.0, 8.5),
)
.unwrap(),
);
let random = game.world.random.clone();
let set = game.bolt_fire(bolt, 4);
let burning = game.world.block(8, 65, 8) != air;
assert_eq!((set > 0, burning), (fires, fires), "radius {radius}");
if !fires {
// No offset draws either.
let mut before = random;
assert_eq!(game.world.random.next_long(), before.next_long());
}
}
}
#[test]
fn viewer_box_reaches_15_around_and_down_and_21_up() {
let bolt = [0.0, 64.0, 0.0];
+15
View File
@@ -280,12 +280,27 @@ impl Game<'_> {
}
hurt
}
/// Takes the absorbed tenths a projectile's hit left on the mobs; the shooter gets them
/// as `damage_dealt_absorbed` when it is the player (`LivingEntity.actuallyHurt`).
pub(super) fn settle_dealt_absorbed(&mut self, by_player: bool) {
let absorbed: i32 = self
.mobs
.iter_mut()
.map(|m| std::mem::take(&mut m.dealt_absorbed))
.fold(0, i32::saturating_add);
if by_player && absorbed > 0 {
self.award_custom_stat("damage_dealt_absorbed", absorbed);
}
}
/// `Player.itemAttackInteraction` after the player's hit landed on the mob
/// at `index`: the item's `hurtEnemy` (the "used" statistic of a weapon),
/// the post-attack enchantments, then `postHurtEnemy` with the weapon's
/// wear.
pub(super) fn weapon_hit(&mut self, index: usize) {
let Some(stack) = self.held().cloned() else {
// An empty hand still meets the victim's own post-attack
// enchantments (Thorns).
self.post_attack(index);
return;
};
let weapon = self
@@ -646,6 +646,7 @@ impl Game<'_> {
let was_empty = c.is_empty(view);
let max = c.max_stack(&stack);
let mut success = false;
let mut filled_empty = false;
let Some(target) = self.slot_mut_at(view, slot) else {
return (Some(stack), false);
};
@@ -663,6 +664,7 @@ impl Game<'_> {
});
stack.count = 0;
success = true;
filled_empty = true;
}
Some(current)
// HopperBlockEntity.canMergeItems
@@ -676,6 +678,17 @@ impl Game<'_> {
}
Some(_) => {}
}
// tryMoveInItem's setItem on an empty slot: AbstractFurnaceBlockEntity.setItem restarts
// the cooking for a new input (a merge only grows the stack, and keeps it).
let recipes = world_recipes!(self);
if filled_empty
&& let Some((pos, 0)) = view.locate(slot)
&& let Some(BlockEntity::Furnace(furnace)) = self.world.block_entities.get_mut(&pos)
{
use horizon_client::furnace::FurnaceCooking;
let input = furnace.items[0].take();
furnace.set_input(recipes, input);
}
if success && was_empty && view.kind == Kind::Hopper {
let dest = view.parts[0].0;
let from_ticked = from_hopper.and_then(|p| self.hoppers.get(&p).map(|h| h.ticked));
+16 -1
View File
@@ -252,6 +252,21 @@ impl MapData {
texture_dirty: true,
}
}
/// The whole picture replaced by a mod (`native_paint_map`): saved with
/// the maps and sent in full to everyone carrying it or seeing it framed,
/// as `update_color` would over every pixel (`setColorsDirty`).
pub(super) fn paint(&mut self, colors: &[u8]) {
if self.colors.as_slice() == colors {
return;
}
self.colors.clear();
self.colors.extend_from_slice(colors);
self.dirty = true;
self.texture_dirty = true;
for h in &mut self.holders {
h.dirty = Some([0, 0, 127, 127]);
}
}
fn update_color(&mut self, x: i32, y: i32, color: u8) -> bool {
let i = (x + y * 128) as usize;
if self.colors[i] == color {
@@ -428,7 +443,7 @@ impl MapData {
}
/// `MapItemSavedData.isTrackedCountOverLimit`: more than `limit`
/// decorations count (`MapDecorationType.trackCount`).
fn tracked_over_limit(&self, limit: usize) -> bool {
pub(super) fn tracked_over_limit(&self, limit: usize) -> bool {
self.decorations
.iter()
.filter(|d| tracks_count(d.sprite))
+4
View File
@@ -966,8 +966,12 @@ mod tests {
living_fall: |_, _, _, _, _| {},
living_jump: |_, _| {},
register_goals: |mob| dispatch_register_goals(&MODS, mob),
player_pre_login: |_, _, _, _| {},
player_profile: |_, _, _| {},
ingredient: |_, _, _| {},
ingredient_test: |_, _, _, _| {},
log_line: |_, _, _, _| {},
handshake: |_, _, _, _, _| {},
});
assert!(installed.is_ok());
@@ -1,7 +1,10 @@
//! Where blocks' textures are in the block sprite arrays, for a client mod drawing blocks with the player's own
//! textures (resource packs included) in its own pass: with `WorldDrawn::block_sprites`, the arrays themselves.
//! Also every block state's facts and looks, and the resources' files, for a mod working on them off the main thread.
use super::Game;
use horizon_client::block_sprites::{BlockSprites, Lookup, SpriteRef};
use horizon_client::block_states::BlockStates;
use horizon_client::client_resources::ClientResources;
use horizon_core::geometry::Direction;
use horizon_world::registry::BlockStateId;
@@ -56,12 +59,30 @@ impl Game<'_> {
pub(super) fn native_block_sprites_version(&self) -> u64 {
self.pack.context.render.generation
}
/// For a client mod: every block state by its id, with its facts, what its model draws on each side and the
/// texture behind each sprite, as the resources loaded now; a copy to keep and use on any thread. Ask again when
/// `native_block_sprites_version` changes. API @API:block_appearance@.
pub(super) fn native_block_states(&self) -> BlockStates {
BlockStates::new(
self.res.facts(),
self.res.collision(),
self.pack.context.clone(),
self.native_sprite_split(),
)
}
/// For a client mod: the files of the resources loaded now, resource packs' over vanilla's, to read textures
/// and other resources on any thread. Ask again when `native_block_sprites_version` changes. API
/// @API:block_appearance@.
pub(super) fn native_client_resources(&self) -> ClientResources {
ClientResources::new(self.options.resources.clone())
}
fn native_sprite_lookup(&self) -> Lookup<'_> {
Lookup::new(&self.pack.context, self.native_sprite_split())
}
fn native_sprite_split(&self) -> u32 {
// (no renderer, no arrays: every layer counts as array 0's)
let split = self
.renderer
self.renderer
.as_ref()
.map_or(u32::MAX, |r| r.sprite_split());
Lookup::new(&self.pack.context, split)
.map_or(u32::MAX, |r| r.sprite_split())
}
}
@@ -0,0 +1,149 @@
//! Read-only chunk snapshots for native server mods (docs/native-mods.md,
//! "Server hooks for plugin ports: world"): a web map or a backup tool takes
//! a loaded chunk as it is now and reads it later, off the server thread,
//! with the block map colours and biome tints to draw it.
use super::{Dim, Game};
use horizon_client::{biome::BiomeColors, blocks::MAP_COLORS, chunk::Chunk};
use horizon_core::{biome::BiomeZoom, position::BlockPos};
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
/// A loaded chunk as it was when `native_chunk_snapshot` took it: its block
/// states and biomes, shared with the level until the level changes them
/// (copy on write), so taking one is cheap and holding it doesn't stop the
/// level. It can be sent to another thread. Coordinates are the level's
/// block coordinates.
#[derive(Clone)]
pub struct NativeChunkSnapshot {
chunk: Arc<Chunk>,
pos: [i32; 2],
min_y: i32,
top_y: i32,
ceiling: bool,
zoom: BiomeZoom,
/// Air, cave air and void air (`BlockState.isAir`).
air: [Option<BlockStateId>; 3],
}
impl NativeChunkSnapshot {
/// The chunk's position, `[x, z]` (block coordinates `>> 4`).
pub fn chunk(&self) -> [i32; 2] {
self.pos
}
/// The level's lowest block `y` and one above its highest
/// (`getMinY`, `getMaxY` + 1).
pub fn height(&self) -> (i32, i32) {
(self.min_y, self.top_y)
}
/// Whether the level has a ceiling (`DimensionType.hasCeiling`: the Nether).
pub fn has_ceiling(&self) -> bool {
self.ceiling
}
/// The block state at `x, y, z` (`getBlockState`), as the state helpers
/// take it (`native_state_block`); None outside this chunk or the level's
/// height.
pub fn block_state(&self, x: i32, y: i32, z: i32) -> Option<BlockStateId> {
let inside =
(x >> 4, z >> 4) == (self.pos[0], self.pos[1]) && (self.min_y..self.top_y).contains(&y);
inside.then(|| self.chunk.block(x & 15, y, z & 15))
}
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)`: one above the
/// highest block that isn't air, the level's lowest `y` in an empty
/// column. None outside this chunk.
pub fn surface(&self, x: i32, z: i32) -> Option<i32> {
let air = self.air;
((x >> 4, z >> 4) == (self.pos[0], self.pos[1])).then(|| {
self.chunk
.world_surface(x & 15, z & 15, |v| air.contains(&Some(BlockStateId(v))))
})
}
/// The biome of quart cell `qx, qy, qz` (block coordinates `>> 2`,
/// `getNoiseBiome`), the `y` clamped to the level, as a biome id of
/// `native_biome_name` and `native_biome_tint`. None for a cell outside
/// this chunk.
pub fn noise_biome(&self, qx: i32, qy: i32, qz: i32) -> Option<u16> {
let (x, z) = (qx << 2, qz << 2);
((x >> 4, z >> 4) == (self.pos[0], self.pos[1])).then(|| self.chunk.biome(x, qy << 2, z))
}
/// The quart cell whose biome is the biome at block `x, y, z`
/// (`BiomeManager.getBiome`'s fuzzy zoom with the world's seed): it can
/// lie in a neighbouring chunk, so read it with `noise_biome` of that
/// chunk's snapshot.
pub fn biome_cell(&self, x: i32, y: i32, z: i32) -> [i32; 3] {
self.zoom.quart_at_block(BlockPos::new(x, y, z))
}
}
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// A snapshot of loaded chunk `chunk` (`[x, z]`) of the level `dimension`
/// (`minecraft:the_nether`), in place or open with nobody in it. None off
/// a server, for a level not open and for a chunk not loaded; nothing is
/// loaded or generated for it. API @API:chunk_snapshot@.
pub(super) fn native_chunk_snapshot(
&self,
dimension: &str,
chunk: [i32; 2],
) -> Option<NativeChunkSnapshot> {
if !self.native_authoritative() {
return None;
}
let world = if dimension == self.dim.id() {
&self.world
} else {
let dim = [Dim::Overworld, Dim::Nether, Dim::End]
.into_iter()
.find(|d| d.id() == dimension)?;
self.levels.parked_world(dim)?
};
Some(NativeChunkSnapshot {
chunk: world.chunks.get(&(chunk[0], chunk[1]))?.clone(),
pos: chunk,
min_y: world.shape.min_y,
top_y: world.shape.max_y(),
ceiling: world.shape.has_ceiling,
zoom: self.streaming.biome_zoom,
air: ["minecraft:air", "minecraft:cave_air", "minecraft:void_air"]
.map(|name| self.pack.assets.registry.default_state(name)),
})
}
/// The map colour of block state `state` (`BlockState.getMapColor`):
/// `(id, colour)`, the `MapColor` id and its `col` (0xRRGGBB), `(0, 0)`
/// for `MapColor.NONE` (air, glass). None for an unknown state. API
/// @API:chunk_snapshot@.
pub(super) fn native_block_map_color(&self, state: BlockStateId) -> Option<(u8, u32)> {
let known = usize::try_from(state.0).is_ok_and(|s| s < self.pack.assets.registry.len());
if !known {
return None;
}
let id = self.res.facts().get(state).map_color;
Some((id, MAP_COLORS.get(usize::from(id)).copied().unwrap_or(0)))
}
/// The name of biome id `biome` (`minecraft:plains`), as a snapshot's
/// `noise_biome` gives it. None for an unknown id. API
/// @API:chunk_snapshot@.
pub(super) fn native_biome_name(&self, biome: u16) -> Option<&str> {
let biomes = world_biomes!(self);
biomes.list.get(usize::from(biome)).map(|b| b.name.as_str())
}
/// The tints of biome id `biome` at block column `x, z`, each 0xRRGGBB:
/// grass (`Biome.getGrassColor`, with the dark forest's and the swamp's
/// modifiers), foliage, dry foliage and water, after
/// `native_biome_colors`. The biome's own, not blended over the biome
/// blend radius as the client draws them. None for an unknown id. API
/// @API:chunk_snapshot@.
pub(super) fn native_biome_tint(&self, biome: u16, x: i32, z: i32) -> Option<BiomeColors> {
let biomes = world_biomes!(self);
let b = biomes.list.get(usize::from(biome))?;
Some(BiomeColors {
grass: biomes.grass(biome, f64::from(x), f64::from(z)),
foliage: b.foliage,
dry_foliage: b.dry_foliage,
water: b.water,
})
}
}
@@ -3,7 +3,7 @@
//! working (docs/native-mods.md, "Chunk tickets"). A held chunk is as a
//! `/forceload`ed one, its level stays open, but `/forceload` neither lists
//! nor releases it and it isn't saved.
use super::Game;
use super::{Dim, Game};
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
@@ -21,6 +21,34 @@ impl Game<'_> {
/// set of owners changed; false for an owner without a namespace, a chunk
/// past the world's edge (as `/forceload` refuses it) and off a server. API @API:chunk_tickets@.
pub(super) fn native_force_chunk(&mut self, owner: &str, chunk: [i32; 2], hold: bool) -> bool {
self.native_chunk_ticket(self.dim, owner, chunk, hold)
}
/// `native_force_chunk` for the level `dimension` (`minecraft:the_nether`),
/// whether it is in place, open with nobody in it, or not open yet: a
/// level held this way opens at the next server tick and ticks from then
/// on, so a mod can generate or run the Nether and the End from any
/// server hook. False also for a dimension other than the Overworld, the
/// Nether and the End. API @API:chunk_tickets_in_level@.
pub(super) fn native_force_chunk_in(
&mut self,
owner: &str,
dimension: &str,
chunk: [i32; 2],
hold: bool,
) -> bool {
let dim = match dimension {
"minecraft:overworld" => Dim::Overworld,
"minecraft:the_nether" => Dim::Nether,
"minecraft:the_end" => Dim::End,
_ => return false,
};
self.native_chunk_ticket(dim, owner, chunk, hold)
}
/// Hold or release `owner`'s ticket on `chunk` of `dim`'s level, and
/// hand the level's held chunks to its streaming if it is open.
fn native_chunk_ticket(&mut self, dim: Dim, owner: &str, chunk: [i32; 2], hold: bool) -> bool {
let namespaced = owner
.split_once(':')
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
@@ -32,7 +60,7 @@ impl Game<'_> {
return false;
}
let key = (chunk[0], chunk[1]);
let level = self.mod_chunk_tickets.entry(self.dim).or_default();
let level = self.mod_chunk_tickets.entry(dim).or_default();
let changed = if hold {
level.entry(key).or_default().insert(owner.to_owned())
} else {
@@ -45,14 +73,34 @@ impl Game<'_> {
if !changed {
return false;
}
if level.contains_key(&key) {
let held = level.contains_key(&key);
// A level not open yet gets its tickets when it opens
// (`native_chunk_tickets_opened`).
let streaming = if dim == self.dim {
Some(&mut self.streaming)
} else {
self.levels.parked_streaming_mut(dim)
};
if let Some(streaming) = streaming {
if !held {
streaming.mod_forced.remove(&key);
} else if streaming.mod_forced.insert(key) {
streaming.rank_forced(key.0, key.1, key.0, key.1);
}
}
true
}
/// The level just opened in place takes the tickets mods hold for it.
pub(super) fn native_chunk_tickets_opened(&mut self) {
let Some(level) = self.mod_chunk_tickets.get(&self.dim) else {
return;
};
for &key in level.keys() {
if self.streaming.mod_forced.insert(key) {
self.streaming.rank_forced(key.0, key.1, key.0, key.1);
}
} else {
self.streaming.mod_forced.remove(&key);
}
true
}
/// The chunks of the level in place `owner` holds (`native_force_chunk`),
@@ -0,0 +1,270 @@
//! Entities shown to one player's client alone, for native server mods
//! (docs/native-mods.md, "Server hooks for plugin ports: vanish and per-viewer
//! visuals"): a hologram or a board only its owner sees. No level holds them:
//! they are never simulated, saved or tracked, nobody else sees them, and the
//! client forgets them when its player changes dimension or leaves. Their
//! synched data comes from a compound as `/summon` reads it.
use super::Game;
use horizon_client::lan::entity::EntityState;
use horizon_protocol::{
buf::pack_degrees,
entity_data::{Value, serializer},
packets::play::{self, Clientbound as C},
};
use horizon_world::nbt::{Compound, Tag};
/// A synched data entry: its id, serializer and value.
type Entry = (u8, i32, Value);
/// `Entity`'s own synched data from a compound as `Entity.load` reads it:
/// `Invisible` and `Glowing` (DATA_SHARED_FLAGS_ID 0x20 and 0x40),
/// `CustomName`, `CustomNameVisible`, `Silent` and `NoGravity` (ids 2 to 5);
/// then the defaults of all of them.
fn entity_data(tag: &Tag) -> (Vec<Entry>, Vec<Entry>) {
let flag = |key: &str| match tag.get(key) {
Some(Tag::Byte(v)) => *v != 0,
Some(Tag::Int(v)) => *v != 0,
_ => false,
};
let mut meta = Vec::new();
let flags = i8::from(flag("Invisible")) << 5 | i8::from(flag("Glowing")) << 6;
if flags != 0 {
meta.push((0, serializer::BYTE, Value::Byte(flags)));
}
if let Some(name) = tag.get("CustomName") {
meta.push((
2,
serializer::OPTIONAL_COMPONENT,
Value::OptText(Some(name.clone())),
));
}
for (id, key) in [(3, "CustomNameVisible"), (4, "Silent"), (5, "NoGravity")] {
if flag(key) {
meta.push((id, serializer::BOOLEAN, Value::Bool(true)));
}
}
let defaults = vec![
(0, serializer::BYTE, Value::Byte(0)),
(2, serializer::OPTIONAL_COMPONENT, Value::OptText(None)),
(3, serializer::BOOLEAN, Value::Bool(false)),
(4, serializer::BOOLEAN, Value::Bool(false)),
(5, serializer::BOOLEAN, Value::Bool(false)),
];
(meta, defaults)
}
/// An entity type's id with its namespace (`text_display` is
/// `minecraft:text_display`).
fn namespaced(kind: &str) -> String {
if kind.contains(':') {
kind.to_owned()
} else {
["minecraft:", kind].concat()
}
}
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// The synched data `nbt` gives an entity of type `kind`: a display's
/// (`Display`, `TextDisplay`, `BlockDisplay`, `ItemDisplay`) and every
/// entity's own (`entity_data`). With `all`, also the defaults of what
/// `nbt` leaves out. None where the server is out of reach.
fn native_client_entity_data(
&mut self,
kind: &str,
nbt: &Compound,
all: bool,
) -> Option<Vec<Entry>> {
let tag = Tag::Compound(nbt.clone());
let mut e = EntityState {
kind: kind.to_owned(),
..Default::default()
};
// `lan_display_data` reads the hub's item ids; the hub goes back at once.
let mut host = self.lan.take()?;
self.lan_display_data(&tag, &mut host.hub, &mut e);
self.lan = Some(host);
let (meta, defaults) = entity_data(&tag);
e.meta.extend(meta);
e.defaults.extend(defaults);
if !all {
return Some(e.meta);
}
let mut values = e.defaults;
for v in &mut values {
if let Some(m) = e.meta.iter().find(|m| m.0 == v.0) {
v.clone_from(m);
}
}
let rest: Vec<Entry> = e
.meta
.iter()
.filter(|m| !values.iter().any(|v| v.0 == m.0))
.cloned()
.collect();
values.extend(rest);
Some(values)
}
/// The type of `viewer`'s client-side entity `id`, if it is one.
fn native_client_entity_kind(&self, viewer: u128, id: i32) -> Option<String> {
self.lan
.as_ref()?
.hub
.guests
.iter()
.find(|g| g.placed && g.uuid == viewer)?
.client_entities
.get(&id)
.cloned()
}
/// An entity id no entity of this server will take
/// (`Entity.nextEntityId`'s counter), for a mod's own packets to a
/// client. API @API:client_entities@.
pub(super) fn native_reserve_entity_id(&mut self) -> i32 {
self.take_entity_id() as i32
}
/// Shows an entity of type `kind` (`minecraft:text_display`, `text_display`)
/// to the player `viewer` alone, at `pos` (the feet) facing `yaw`, with the
/// synched data `nbt` gives it as `/summon` reads it: all of a display's
/// (`text`, `background`, `billboard`, `transformation`, `item`,
/// `block_state`, `teleport_duration`...), and of any type `CustomName`,
/// `CustomNameVisible`, `Invisible`, `Glowing`, `Silent` and `NoGravity`;
/// other types' own data is not read. No level holds it: it is not
/// simulated, saved, tracked or seen by anyone else, and the client
/// forgets it when the player changes dimension or leaves. Returns its id
/// (one the server's entities never take), for `native_client_entity_set`,
/// `_move` and `_remove`. None when `viewer` isn't on, for an unknown
/// type, a player, a position or yaw that isn't finite, and inside a
/// player's hook where the server is out of reach. Bukkit has no such API
/// (plugins send the packets). API @API:client_entities@.
pub(super) fn native_client_entity_spawn(
&mut self,
viewer: u128,
kind: &str,
pos: [f64; 3],
yaw: f32,
nbt: &Compound,
) -> Option<i32> {
if !pos.iter().all(|v| v.is_finite()) || !yaw.is_finite() {
return None;
}
let kind = namespaced(kind);
// A player's entity needs a player list entry the server doesn't have.
if kind == "minecraft:player" {
return None;
}
let hub = &self.lan.as_ref()?.hub;
hub.guests.iter().find(|g| g.placed && g.uuid == viewer)?;
let type_id = hub.registry_id("minecraft:entity_type", &kind)?;
let values = self.native_client_entity_data(&kind, nbt, false)?;
let id = self.take_entity_id();
let add = C::AddEntity(play::AddEntity {
id: id as i32,
uuid: super::lan::entity_uuid(id),
kind: type_id,
position: pos,
velocity: [0.0; 3],
pitch: 0,
yaw: pack_degrees(yaw),
head_yaw: pack_degrees(yaw),
data: 0,
});
let id = id as i32;
let host = self.lan.as_mut()?;
let g = host
.hub
.guests
.iter_mut()
.find(|g| g.placed && g.uuid == viewer)?;
g.client_entities.insert(id, kind);
self.native_send(viewer, add);
if !values.is_empty() {
self.native_send(viewer, C::SetEntityData(play::SetEntityData { id, values }));
}
Some(id)
}
/// Gives `viewer`'s client-side entity `id` the synched data `nbt` gives
/// its type, as `native_client_entity_spawn` reads it: what `nbt` leaves
/// out goes back to its default, so pass the whole of what it shows. A
/// display's `start_interpolation` is sent with it, so a changed
/// transformation eases over `interpolation_duration`. False when it is
/// not their client-side entity, and where the server is out of reach.
/// API @API:client_entities@.
pub(super) fn native_client_entity_set(
&mut self,
viewer: u128,
id: i32,
nbt: &Compound,
) -> bool {
let Some(kind) = self.native_client_entity_kind(viewer, id) else {
return false;
};
let Some(values) = self.native_client_entity_data(&kind, nbt, true) else {
return false;
};
self.native_send(viewer, C::SetEntityData(play::SetEntityData { id, values }))
}
/// Moves `viewer`'s client-side entity `id` to `pos` facing `yaw`
/// (`ClientboundTeleportEntityPacket`, absolute; a display eases over its
/// `teleport_duration`). False when it is not their client-side entity,
/// for a position or yaw that isn't finite, and where the server is out
/// of reach. API @API:client_entities@.
pub(super) fn native_client_entity_move(
&mut self,
viewer: u128,
id: i32,
pos: [f64; 3],
yaw: f32,
) -> bool {
if !pos.iter().all(|v| v.is_finite()) || !yaw.is_finite() {
return false;
}
if self.native_client_entity_kind(viewer, id).is_none() {
return false;
}
let packet = C::TeleportEntity(play::TeleportEntity {
id,
position: pos,
delta: [0.0; 3],
yaw,
pitch: 0.0,
relatives: 0,
on_ground: false,
});
self.native_send(viewer, packet)
}
/// Takes `viewer`'s client-side entities `ids` away from their client
/// (`ClientboundRemoveEntitiesPacket`); ids that are not theirs are left
/// alone (an entity the server tracks is hidden with
/// `native_hide_entity_from`). False when none of them was theirs, and
/// where the server is out of reach. API @API:client_entities@.
pub(super) fn native_client_entity_remove(&mut self, viewer: u128, ids: &[i32]) -> bool {
let Some(host) = &mut self.lan else {
return false;
};
let Some(g) = host
.hub
.guests
.iter_mut()
.find(|g| g.placed && g.uuid == viewer)
else {
return false;
};
let ids: Vec<i32> = ids
.iter()
.copied()
.filter(|id| g.client_entities.remove(id).is_some())
.collect();
if ids.is_empty() {
return false;
}
self.native_send(viewer, C::RemoveEntities(play::RemoveEntities { ids }))
}
}
@@ -1,13 +1,18 @@
//! Client services for trait mods, without exposing `Game` or the atlas.
use super::Game;
use super::{Game, Screen};
use horizon_client::{
client_resources::TexturePixels,
collisions::SceneCollisions,
hud::{HudBatch, rgba},
hud_layout::GuiDraw,
items::ItemStack,
mod_api::Text,
net::FRIEND,
};
use horizon_core::geometry::Vec3;
use horizon_friends::invite::{self, Parsed};
use std::borrow::Cow;
use std::net::SocketAddr;
use winit::keyboard::KeyCode;
/// This window's state; unavailable on a server or before a window is attached.
@@ -25,6 +30,89 @@ pub struct NativeClientState {
pub yaw: f32,
pub pitch: f32,
pub menu_blur: f32,
/// What covers the world. API @API:session_state@.
pub screen: NativeScreenKind,
/// F3's debug screen is showing (`DebugScreenOverlay.showDebugScreen`).
/// API @API:session_state@.
pub debug_screen: bool,
/// The world joined, None before one is. API @API:session_state@.
pub world: Option<NativeWorldId>,
/// The head of the bed the player sleeps in, as the server says
/// (`LivingEntity.getSleepingPos`). API @API:session_state@.
pub sleeping_pos: Option<[i32; 3]>,
}
/// What covers the world, in the kinds a HUD tells apart (vanilla's
/// `Minecraft.screen`).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum NativeScreenKind {
/// No screen: the world is played.
#[default]
Playing,
/// The chat box (`ChatScreen`), which a minimap still draws under.
Chat,
/// The death screen (`DeathScreen`).
Death,
/// Any other: the pause menu, an inventory or container, a sign, book,
/// dialog or prompt, the loading screen, the title's screens.
Other,
}
impl NativeScreenKind {
/// `covered`: a screen Horizon keeps outside `Screen` is up over it.
fn of(screen: Screen, covered: bool) -> Self {
match screen {
_ if covered => Self::Other,
Screen::Playing => Self::Playing,
Screen::Chat => Self::Chat,
Screen::Dead => Self::Death,
_ => Self::Other,
}
}
}
/// The world this window plays, for a mod that keeps data per world, as
/// Xaero's minimap and JourneyMap do. A later Horizon may add kinds.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum NativeWorldId {
/// Single player, its LAN host and splitscreen seats: the save's folder
/// in `saves/`, not its shown name.
Singleplayer(String),
/// A server, a LAN world joined included: its address as the player
/// joined it (`mc.example.com:25565`). A friend's world is `friend:` and
/// what its invite shows anyone (its host's addresses and its relay
/// nameplate), never the invite's secret.
Server(String),
}
impl NativeWorldId {
/// `save` is set for single player's world joined through memory.
fn of(save: Option<&str>, address: &str) -> Self {
match (save, address.strip_prefix(FRIEND)) {
(Some(folder), _) => Self::Singleplayer(folder.to_owned()),
(None, Some(code)) => Self::Server(format!("{FRIEND}{}", invite_public_part(code))),
(None, None) => Self::Server(address.to_owned()),
}
}
}
/// The part of a friend's invite anyone may see. A host's code is fresh each
/// time it shares, so a friend's world is known for that sharing only. The
/// relay is kept beside the addresses: two hosts on other networks can share
/// a private address and the fixed direct port.
fn invite_public_part(code: &str) -> String {
match invite::parse(code) {
Some(Parsed::Short(code)) => code.nameplate,
Some(Parsed::Long(invite)) => {
let relay = invite
.relay
.map(|r| format!("{}/{}", r.address, r.code.nameplate));
let endpoints = invite.endpoints.iter().map(SocketAddr::to_string);
endpoints.chain(relay).collect::<Vec<_>>().join(",")
}
None => String::new(),
}
}
/// A living entity known to the client, before interpolation. No authoritative writes.
@@ -42,6 +130,51 @@ pub struct NativeClientEntity {
pub head_pitch: f32,
pub alive: bool,
pub invisible: bool,
/// Its entity type (`minecraft:zombie`). API @API:client_entity_list@.
pub kind: String,
/// `Entity.getYRot` in degrees, 0 facing south and 90 west.
pub yaw: f32,
pub baby: bool,
/// A player in spectator mode, whom vanilla draws only to spectators.
pub spectator: bool,
/// The texture its body draws with (`EntityRenderer.getTextureLocation`):
/// `entity/cow/cow_temperate_baby`, or a player's skin. Pixels through
/// `native_client_texture`.
pub texture: Option<String>,
}
impl NativeClientEntity {
/// Where this frame draws it (`Entity.getPosition(partialTick)`), given
/// `NativeClientState::world_partial_tick`. API @API:client_entity_list@.
pub fn position_at(&self, partial_tick: f64) -> [f64; 3] {
position_at(self.previous_position, self.position, partial_tick)
}
}
/// A dropped item the client shows. API @API:client_entity_list@.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub struct NativeClientItem {
pub id: u32,
pub stack: ItemStack,
pub position: [f64; 3],
pub previous_position: [f64; 3],
}
impl NativeClientItem {
/// Where this frame draws it, as `NativeClientEntity::position_at`.
pub fn position_at(&self, partial_tick: f64) -> [f64; 3] {
position_at(self.previous_position, self.position, partial_tick)
}
}
/// `Downloader::image` keeps every skin at 64x64.
const SKIN_SIDE: u32 = 64;
fn position_at(previous: [f64; 3], position: [f64; 3], partial_tick: f64) -> [f64; 3] {
let vec = |[x, y, z]: [f64; 3]| Vec3::new(x, y, z);
let at = vec(previous).lerp(vec(position), partial_tick);
[at.x, at.y, at.z]
}
/// A posed model head, relative to its interpolated origin, in block units.
@@ -68,6 +201,23 @@ pub struct NativeClientProfile {
#[allow(dead_code)]
impl Game<'_> {
/// A screenshot as F2 takes it (`ScreenShot.grab`): of the next frame, at
/// the game's resolution times Screenshot Quality, into `screenshots/` with
/// vanilla's file name, and the same "Saved screenshot as" line in chat.
/// The file it will write, as a path string; an error without a window to draw, or while
/// another screenshot waits for the frame (F2's, an agent's, the world
/// icon's), which a mod's would replace. API @API:take_screenshot@.
pub(super) fn native_take_screenshot(&mut self) -> Result<String, String> {
let Some(renderer) = self.renderer.as_ref() else {
return Err("no window to take it from".into());
};
if renderer.screenshot.is_some() {
return Err("another screenshot is waiting for the frame".into());
}
self.take_screenshot()
.map(|path| path.to_string_lossy().into_owned())
.ok_or_else(|| "the screenshots folder can't be made".into())
}
/// Client/window/connection state, including when menus cover the world.
pub(super) fn native_client_state(&self) -> Option<NativeClientState> {
if !self.native_window() {
@@ -86,6 +236,20 @@ impl Game<'_> {
yaw: self.yaw,
pitch: self.pitch,
menu_blur: self.settings.menu_blur as f32,
screen: NativeScreenKind::of(
self.screen,
self.title.is_some()
|| self.screen_over_playing()
|| self.remote_screen_up()
|| self.pack_reload.is_some(),
),
debug_screen: self.debug,
world: self
.remote
.as_ref()
.filter(|r| r.logged_in)
.map(|r| NativeWorldId::of(r.save_folder.as_deref(), &r.session.address)),
sleeping_pos: self.sleeping,
})
}
/// Bulk client render data. Unlike EntityRef, this snapshot expires independently of handles.
@@ -111,10 +275,53 @@ impl Game<'_> {
head_pitch: m.head_pitch,
alive: m.alive(),
invisible: m.invisible || m.has_effect("minecraft:invisibility"),
kind: m.kind.id.to_string(),
yaw: m.yaw,
baby: m.is_baby(),
spectator: m.extra.spectator,
texture: Some(horizon_client::mobs::render::body(m).1)
.filter(|t| !t.is_empty())
.map(Cow::into_owned),
}
})
.collect()
}
/// The dropped items the server shows this window, by the same rules as
/// `native_client_entities`. API @API:client_entity_list@.
pub(super) fn native_client_items(&self) -> Vec<NativeClientItem> {
if !self.native_window() || self.remote.is_none() {
return Vec::new();
}
self.dropped
.iter()
.filter(|e| e.stack.count > 0)
.map(|e| NativeClientItem {
id: e.id,
stack: e.stack.clone(),
position: [e.position.x, e.position.y, e.position.z],
previous_position: [e.previous.x, e.previous.y, e.previous.z],
})
.collect()
}
/// The pixels of a texture as this window draws it, by the name
/// `NativeClientEntity::texture` and `NativeClientProfile::skin` give: a
/// downloaded skin while the client holds it, else
/// `native_client_resources().texture(name)`. Decodes on each call; None
/// on a server or for a texture it can't read. API
/// @API:client_entity_list@.
pub(super) fn native_client_texture(&self, texture: &str) -> Option<TexturePixels> {
if !self.native_window() {
return None;
}
if let Some(rgba) = self.skin_downloads.borrow().image(texture) {
return Some(TexturePixels {
width: SKIN_SIDE,
height: SKIN_SIDE,
rgba: rgba.to_vec(),
});
}
self.native_client_resources().texture(texture)
}
/// Requested model heads at a finite partial tick in 0..=1; at most 4096 IDs.
/// Name bounds are vanilla's, without dispatching name hooks recursively.
pub(super) fn native_client_heads(&self, ids: &[u32], partial: f64) -> Vec<NativeClientHead> {
@@ -330,3 +537,336 @@ impl Game<'_> {
self.physical_keys.contains(&key)
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_friends::code::Code;
#[test]
fn a_world_is_its_save_in_single_player_and_its_address_on_a_server() {
assert_eq!(
NativeWorldId::of(Some("New World"), "local"),
NativeWorldId::Singleplayer("New World".into())
);
assert_eq!(
NativeWorldId::of(None, "mc.example.com:25565"),
NativeWorldId::Server("mc.example.com:25565".into())
);
}
#[test]
fn friends_worlds_are_told_apart_by_their_hosts_never_by_their_secrets() {
const PORT: u16 = 50000;
const RELAY: &str = "relay.example.com:443";
// A short code: its nameplate, then its secret.
let short = |nameplate: &str, secret: &str| Code {
nameplate: nameplate.into(),
secret: secret.into(),
};
let invite = |secret: u8, host: &str, relay: Option<Code>| {
let code = invite::Invite {
secret: [secret; invite::SECRET],
port: PORT,
endpoints: vec![format!("{host}:{PORT}").parse().unwrap()],
relay: relay.map(|code| invite::RelayRef {
address: RELAY.into(),
code,
}),
}
.encode();
NativeWorldId::of(None, &format!("{FRIEND}{code}"))
};
assert_ne!(
invite(1, "192.168.1.5", None),
invite(1, "192.168.1.6", None),
"two friends"
);
assert_ne!(
invite(1, "192.168.1.5", Some(short("ABCD", "WXYZ"))),
invite(1, "192.168.1.5", Some(short("EFGH", "WXYZ"))),
"two friends on other networks behind the same private address"
);
assert_eq!(
invite(1, "192.168.1.5", Some(short("ABCD", "WXYZ"))),
invite(2, "192.168.1.5", Some(short("ABCD", "QRST"))),
"the invite's and the relay code's secrets take no part"
);
let typed = |code: Code| NativeWorldId::of(None, &format!("{FRIEND}{code}"));
assert_eq!(typed(short("ABCD", "WXYZ")), typed(short("ABCD", "QRST")));
assert_eq!(
typed(short("ABCD", "WXYZ")),
NativeWorldId::Server(format!("{FRIEND}ABCD"))
);
}
#[test]
fn a_hud_tells_chat_and_death_from_other_screens() {
assert_eq!(
NativeScreenKind::of(Screen::Playing, false),
NativeScreenKind::Playing
);
assert_eq!(
NativeScreenKind::of(Screen::Chat, false),
NativeScreenKind::Chat
);
assert_eq!(
NativeScreenKind::of(Screen::Dead, false),
NativeScreenKind::Death
);
assert_eq!(
NativeScreenKind::of(Screen::Inventory, false),
NativeScreenKind::Other
);
assert_eq!(
NativeScreenKind::of(Screen::Chat, true),
NativeScreenKind::Other,
"a sign or dialog over chat covers the world"
);
}
#[test]
fn an_entity_is_drawn_between_its_last_two_tick_positions() {
let entity = NativeClientEntity {
previous_position: [0.0, 64.0, -2.0],
position: [2.0, 65.0, 2.0],
..Default::default()
};
assert_eq!(entity.position_at(0.0), [0.0, 64.0, -2.0]);
assert_eq!(entity.position_at(0.25), [0.5, 64.25, -1.0]);
assert_eq!(entity.position_at(1.0), [2.0, 65.0, 2.0]);
}
}
/// A window joined to a server through memory, fed the server's packets as
/// it would receive them: what a client mod lists through the mod `Host`.
#[cfg(test)]
mod client_tests {
use crate::game::{Audio, Game, Options, Progress, Settings, load_resources};
use horizon_client::mod_api::Host;
use horizon_protocol::entity_data::{Value, serializer};
use horizon_protocol::item::Stack;
use horizon_protocol::packets::play::{self, Clientbound as C};
/// `AgeableMob.DATA_BABY_ID` and `ItemEntity.DATA_ITEM`, the entity data
/// a calf and a dropped item come with.
const DATA_BABY_ID: u8 = 16;
const DATA_ITEM: u8 = 8;
/// Vanilla's `zombie.png`, like every skin, is 64x64 RGBA.
const TEXTURE_SIDE: u32 = 64;
const RGBA_BYTES: usize = 4;
/// The network ids the server gives the window's player and the entities.
const OWN_PLAYER: i32 = 7;
const ZOMBIE: i32 = 20;
const CALF: i32 = 21;
const DIAMONDS: i32 = 22;
const ALEX: i32 = 23;
/// `UUID.hashCode` 2: `DefaultPlayerSkin.get` picks the third default
/// skin, slim Efe.
const ALEX_UUID: u128 = 2;
/// `ClientboundPlayerInfoUpdatePacket`'s `ADD_PLAYER` and
/// `UPDATE_GAME_MODE` actions, and `GameType.SPECTATOR`.
const ADD_PLAYER: u8 = 1;
const UPDATE_GAME_MODE: u8 = 4;
const SPECTATOR: i32 = 3;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_client_lists_the_mobs_and_items_the_server_shows_it() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let settings = Settings::default();
let loaded = load_resources(
root.clone(),
None,
&root,
&settings,
std::time::Instant::now(),
false,
true,
&Progress::new(),
&mut |_, _| unreachable!(),
)
.unwrap();
let mut options = Options::parse(std::iter::empty()).unwrap().unwrap();
options.resources = root.clone();
let mut game = Game::new(
&loaded.resources,
loaded.pack,
loaded.gui.unwrap(),
options,
settings,
Audio::silent(&root),
None,
)
.unwrap();
let (pipe, _server) = horizon_protocol::connection::Pipe::pair();
game.connect_local(pipe, "Steve").unwrap();
let builtin = game.remote.as_ref().unwrap().builtin.clone();
let id = |registry: &str, name: &str| builtin.get(registry).unwrap().id(name).unwrap();
let entity = |entity: i32, kind: &str, position: [f64; 3]| play::AddEntity {
id: entity,
uuid: entity as u128,
kind: id("minecraft:entity_type", kind),
position,
velocity: [0.0; 3],
pitch: 0,
yaw: 64,
head_yaw: 64,
data: 0,
};
let add =
|id: i32, kind: &str, position: [f64; 3]| C::AddEntity(entity(id, kind, position));
let data = |entity: i32, value: (u8, i32, Value)| {
C::SetEntityData(play::SetEntityData {
id: entity,
values: vec![value],
})
};
game.handle_packet(C::Login(play::Login {
player_id: OWN_PLAYER,
hardcore: false,
levels: vec!["minecraft:overworld".into()],
max_players: 20,
chunk_radius: 8,
simulation_distance: 8,
reduced_debug_info: false,
show_death_screen: true,
limited_crafting: false,
spawn: play::SpawnInfo {
dimension_type: 0,
dimension: "minecraft:overworld".into(),
seed: 1,
game_type: 0,
previous_game_type: None,
debug: false,
flat: false,
last_death: None,
portal_cooldown: 0,
sea_level: 63,
},
online_mode: false,
enforces_secure_chat: false,
}))
.unwrap();
game.handle_packet(C::PlayerPosition(play::PlayerPosition {
teleport: 1,
position: [0.5, 65.0, 0.5],
delta: [0.0; 3],
yaw: 0.0,
pitch: 0.0,
relatives: 0,
}))
.unwrap();
// What the server sends for `/summon zombie`, and for a calf.
game.handle_packet(add(ZOMBIE, "minecraft:zombie", [1.5, 64.0, 2.5]))
.unwrap();
game.handle_packet(add(CALF, "minecraft:cow", [-3.5, 64.0, 0.5]))
.unwrap();
game.handle_packet(data(
CALF,
(DATA_BABY_ID, serializer::BOOLEAN, Value::Bool(true)),
))
.unwrap();
let entities = game.client_entities();
let listed = |id: i32| entities.iter().find(|e| e.id == id as u32);
assert_eq!(entities.len(), 2);
assert!(listed(OWN_PLAYER).is_none(), "the window's own player");
let zombie = listed(ZOMBIE).unwrap();
assert_eq!(zombie.kind, "minecraft:zombie");
assert_eq!(zombie.position_at(0.5), [1.5, 64.0, 2.5]);
assert_eq!(zombie.yaw, 90.0);
assert!(!zombie.baby);
assert_eq!(zombie.texture.as_deref(), Some("entity/zombie/zombie"));
let calf = listed(CALF).unwrap();
assert!(calf.baby);
assert_eq!(
calf.texture.as_deref(),
Some("entity/cow/cow_temperate_baby")
);
// A dropped item is listed once its stack arrives.
game.handle_packet(add(DIAMONDS, "minecraft:item", [0.5, 65.0, 4.5]))
.unwrap();
assert!(game.client_items().is_empty());
let diamonds = Stack::new(id("minecraft:item", "minecraft:diamond"), 3);
game.handle_packet(data(
DIAMONDS,
(
DATA_ITEM,
serializer::ITEM_STACK,
Value::Item(Some(diamonds)),
),
))
.unwrap();
let items = game.client_items();
assert_eq!(items.len(), 1);
assert_eq!(items[0].id, DIAMONDS as u32);
assert_eq!(
(&*items[0].stack.item, items[0].stack.count),
("minecraft:diamond", 3)
);
assert_eq!(items[0].position_at(1.0), [0.5, 65.0, 4.5]);
// Another player: its list entry, a spectator, then its entity. The
// frame's skin refresh (`draw_tab_list`) and a client tick
// (`isSpectator` from the list) follow.
game.handle_packet(C::PlayerInfoUpdate(play::PlayerInfoUpdate {
actions: ADD_PLAYER | UPDATE_GAME_MODE,
entries: vec![play::PlayerInfo {
uuid: ALEX_UUID,
name: Some("Alex".into()),
game_mode: Some(SPECTATOR),
..Default::default()
}],
}))
.unwrap();
game.handle_packet(C::AddEntity(play::AddEntity {
uuid: ALEX_UUID,
..entity(ALEX, "minecraft:player", [3.5, 64.0, -1.5])
}))
.unwrap();
game.update_remote_skins();
game.tick_remote().unwrap();
let alex = game
.client_entities()
.into_iter()
.find(|e| e.id == ALEX as u32)
.unwrap();
assert_eq!(alex.player_uuid, Some(ALEX_UUID));
assert_eq!(alex.player_name.as_deref(), Some("Alex"));
assert!(alex.spectator);
assert_eq!(alex.texture.as_deref(), Some("entity/player/slim/efe"));
let pixels = (TEXTURE_SIDE * TEXTURE_SIDE) as usize * RGBA_BYTES;
let face = game.client_texture("entity/zombie/zombie").unwrap();
assert_eq!((face.width, face.height), (TEXTURE_SIDE, TEXTURE_SIDE));
assert_eq!(face.rgba.len(), pixels);
// A downloaded skin comes from memory.
let skin = vec![7; pixels];
game.skin_downloads
.borrow_mut()
.remember_image("skins/0123abcd", &skin);
assert_eq!(game.client_texture("skins/0123abcd").unwrap().rgba, skin);
assert!(
game.client_texture("entity/zombie/no_such_zombie")
.is_none()
);
let skin = game.client_texture("entity/player/slim/efe").unwrap();
assert_eq!((skin.width, skin.height), (TEXTURE_SIDE, TEXTURE_SIDE));
// A name that climbs out of the packs is refused, even where a picture
// is there.
let packs = std::env::temp_dir().join(format!("horizon-texture-{}", std::process::id()));
let textures = packs.join("assets/minecraft/textures");
std::fs::create_dir_all(&textures).unwrap();
let png =
std::fs::read(root.join("assets/minecraft/textures/entity/zombie/zombie.png")).unwrap();
std::fs::write(textures.join("inside.png"), &png).unwrap();
std::fs::write(packs.join("outside.png"), &png).unwrap();
game.options.resources = packs.clone();
assert!(game.client_texture("inside").is_some());
assert!(game.client_texture("../../../outside").is_none());
std::fs::remove_dir_all(&packs).unwrap();
}
}
@@ -0,0 +1,85 @@
//! Who a hit names, for `native_living_incoming_damage_v3` (docs/native-mods.md, "Server hooks
//! for plugin ports"): the hit arrives through `health::HURT_SERVER` with entity ids only, and
//! no `Game` to look a player up in, so the server's players are noted by entity id as each
//! guest's turn starts. Kept out of `native_hooks.rs`, whose lines may not hold macros.
use horizon_client::health::{Hurting, knockback_attacker};
use horizon_client::mobs::Who;
use std::{cell::RefCell, collections::HashMap};
/// More players than a server holds at once: past it, the notes start over (an entity id
/// is never reused, so a left or respawned player's note is only stale).
const MAX_NOTED: usize = 4096;
thread_local! {
/// The server's players' UUIDs by entity id, noted on the thread that ticks the world.
static PLAYERS: RefCell<HashMap<i32, u128>> = RefCell::new(HashMap::new());
}
/// Notes that the player with entity id `entity` is `uuid` (a guest whose turn starts), while
/// a native mod is loaded: a hit on them or by them is named by it from then on.
pub(super) fn note_player(entity: i32, uuid: u128) {
if horizon_client::mod_api::mods().is_empty() {
return;
}
PLAYERS.with(|players| {
let mut players = players.borrow_mut();
if players.len() >= MAX_NOTED && !players.contains_key(&entity) {
players.clear();
}
players.insert(entity, uuid);
});
}
/// The UUID of the player with entity id `entity`, if one was noted.
fn player_uuid(entity: i32) -> Option<u128> {
PLAYERS.with(|players| players.borrow().get(&entity).copied())
}
/// The victim's UUID (a player's), the hit's attacker (the causing entity), its UUID and the
/// direct entity, each none when not known.
pub(super) struct Parties {
pub victim_uuid: Option<u128>,
pub attacker: Option<u32>,
/// A player attacker's UUID.
pub attacker_uuid: Option<u128>,
pub direct: Option<u32>,
}
/// Who `hurting` names. The causing entity is the source's, or a mob's attacker; a player
/// attacker (`Who::Player`) and a source that names none fall back to the synchronous attack in
/// progress (`health::knockback_attacker`), as `native_living_knockback_v2` does. An attacker
/// that is the victim itself (thorns inside the victim's own attack) is none.
pub(super) fn parties(hurting: &Hurting<'_>) -> Parties {
let scope = knockback_attacker();
let (victim, victim_uuid, causing, direct) = match hurting {
Hurting::Player(entity, source) => {
(*entity, player_uuid(*entity), source.causing, source.direct)
}
Hurting::Mob(mob, damage) => {
let causing = match damage.attacker {
Some(Who::Mob(id)) => id as i32,
Some(Who::Player) => scope,
None => 0,
};
let direct = if super::creatures::melee(damage.kind) {
causing
} else {
0
};
// A mob's UUID is the game's (`persist.uuids`), out of reach here.
(mob.id as i32, None, causing, direct)
}
Hurting::Dragon(entity, source) | Hurting::ArmorStand(entity, source) => {
(*entity, None, source.causing, source.direct)
}
};
let causing = if causing != 0 { causing } else { scope };
let known = |id: i32| (id > 0 && id != victim).then_some(id as u32);
let attacker = known(causing);
Parties {
victim_uuid,
attacker,
attacker_uuid: attacker.and_then(|a| player_uuid(a as i32)),
direct: known(direct),
}
}
@@ -0,0 +1,251 @@
//! What a server's diagnostics mod reads (docs/native-mods.md, "Server hooks
//! for plugin ports: diagnostics"): the tick clock as vanilla and Bukkit
//! report it, what each open level holds, the packets each player's
//! connection carries, and the switches of the tick's profiler (spans and
//! sampled mob costs). Reads only; nothing here changes what the server
//! does.
use super::{Dim, Game, commands::Target, native_entities::target_id, tick_profile};
use horizon_client::{
mod_api::{NativeLevelStats, NativeMobCost},
world::World,
};
use std::collections::BTreeMap;
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// The last 100 server ticks' durations in nanoseconds, as vanilla's
/// `MinecraftServer.getTickTimesNanos` lays them out: the tick numbered
/// `n` at index `n % 100` (0 where none was recorded yet). Sort a copy
/// for percentiles; the average is `native_tick_stats`'. On a client, its
/// own ticks. API @API:tick_times@.
pub(super) fn native_tick_times(&self) -> Vec<u64> {
self.clock.tick_times_nanos()
}
/// The ticks a second over the last one, five and fifteen minutes
/// (Bukkit's `Server.getTPS`), measured between the ends of the recorded
/// ticks and averaged once a second as a load average is; 20 at the
/// start. A sprint reads more than the rate, a server that falls behind
/// less. API @API:tick_times@.
pub(super) fn native_tps(&self) -> [f64; 3] {
self.clock.tps()
}
/// The counts of the open level `dimension` (`minecraft:the_nether`):
/// its loaded and forced chunks, its block entities (and how many tick),
/// and its entities and block entities by type. A level not in place is
/// swapped in for the count and back, as `/execute in` does; the players
/// are those the hub holds, so inside a player's own hook (the hub taken
/// out) none are counted. A walk over every entity: once a second or
/// less, not every tick. None on a client and for a level that isn't
/// open. API @API:level_stats@.
pub(super) fn native_level_stats(&mut self, dimension: &str) -> Option<NativeLevelStats> {
let dim = self.diagnosed_level(dimension)?;
self.with_level_in_place(dim, |game| {
let mut entities = BTreeMap::new();
for (_, kind, _) in game.entities_in_place() {
*entities.entry(kind).or_default() += 1;
}
let mut block_entity_types = BTreeMap::new();
for (_, kind) in game.block_entity_types(&game.world) {
*block_entity_types.entry(kind).or_default() += 1;
}
NativeLevelStats {
chunks: game.world.chunks.len(),
forced_chunks: game.streaming.forced.len(),
block_entities: game.world.block_entities.len(),
ticking_block_entities: game.world.block_entities.ticking_len(),
entities: most_first(entities),
block_entity_types: most_first(block_entity_types),
}
})
}
/// Every entity of the open level `dimension`: its id (the one events
/// and `native_entity` take), its type and its position, players
/// included (as `native_level_stats` counts them). Empty on a client and
/// for a level that isn't open. API @API:level_stats@.
pub(super) fn native_level_entities(
&mut self,
dimension: &str,
) -> Vec<(u32, String, [f64; 3])> {
let Some(dim) = self.diagnosed_level(dimension) else {
return Vec::new();
};
self.with_level_in_place(dim, Self::entities_in_place)
.unwrap_or_default()
}
/// Every block entity of the open level `dimension`: its position and
/// its type, the vanilla block entity type of its block (`minecraft:chest`,
/// `minecraft:barrel`, `minecraft:mob_spawner`), else the block's name (a
/// mod's). Empty on a client and for a level that isn't open. API
/// @API:level_stats@.
pub(super) fn native_level_block_entities(&self, dimension: &str) -> Vec<([i32; 3], String)> {
let world = match self.diagnosed_level(dimension) {
Some(dim) if dim == self.dim => Some(&self.world),
Some(dim) => self.levels.parked_world(dim),
None => None,
};
world.map_or_else(Vec::new, |world| self.block_entity_types(world))
}
/// Keep (or stop keeping) every player's play packets for
/// `native_server_packets`, from now on: while on, each packet costs the
/// network thread a lock and a list entry, so turn it on for a session
/// and off after. Off drops what is kept. A switch for the whole server,
/// shared by every mod. API @API:server_packets@.
pub(super) fn native_count_packets(&self, on: bool) {
horizon_client::lan::conn::count_packets(on);
if !on {
for g in self.lan.iter().flat_map(|h| h.hub.guests.iter()) {
g.sender.take_packets();
}
}
}
/// The end of a server tick: each player's packets kept since the last
/// one go to `native_server_packets`. One relaxed load while nobody counts.
pub(super) fn diagnostics_packets_tick(&mut self) {
if !horizon_client::lan::conn::counting_packets() {
return;
}
let kept: Vec<_> = self
.lan
.iter()
.flat_map(|h| h.hub.guests.iter())
.map(|g| (g.uuid, g.sender.take_packets()))
.collect();
for (player, [sent, received]) in kept {
if !sent.is_empty() {
self.native_server_packets(player, &sent, true);
}
if !received.is_empty() {
self.native_server_packets(player, &received, false);
}
}
}
/// Record the server tick's spans for `native_server_tick_timed` (or no
/// longer): elsewhere than on a dedicated server, which records them
/// anyway, the hook fires only while they are on. One switch shared with
/// `HORIZON_SPIKES`, performance reports and every mod: spans stay on
/// while anyone wants them. Spans are recorded on every thread (two clock
/// reads a step): turn them on for a profiling session and off after.
/// Only on a server. API @API:server_tick_timed@.
pub(super) fn native_spans(&self, on: bool) {
if self.native_authoritative() {
tick_profile::want_spans(tick_profile::SPANS_MOD, on);
}
}
/// Time every mob's own tick (its riders inside it) from now on, or no
/// longer, summed by level and mob type for `native_take_mob_costs`:
/// two clock reads and a map update a mob while on, one thread-local
/// read off. Sampled apart from a performance report's, so both can run.
/// For a lighter profile, turn it on in `ServerTickStart` of one tick in
/// N and off after. Call it from a server hook (it belongs to the server
/// thread). API @API:server_tick_timed@.
pub(super) fn native_mob_cost_sampling(&self, on: bool) {
tick_profile::set_mod_sampling(on);
}
/// The mob costs sampled since the last call (`native_mob_cost_sampling`),
/// costliest first; clears them. API @API:server_tick_timed@.
pub(super) fn native_take_mob_costs(&self) -> Vec<NativeMobCost> {
tick_profile::take_mod()
.into_iter()
.map(|c| NativeMobCost {
dimension: c.dimension.to_owned(),
kind: c.kind,
nanos: c.nanos,
mob_ticks: c.mob_ticks,
})
.collect()
}
/// The open level `dimension` names, on a server.
fn diagnosed_level(&self, dimension: &str) -> Option<Dim> {
if !self.native_authoritative() {
return None;
}
std::iter::once(self.dim)
.chain(self.levels.parked_dims())
.find(|d| d.id() == dimension)
}
/// `f` with the level `dim` in place, then the level that was in place
/// back (`/execute in`'s swap). None when it could not be opened.
fn with_level_in_place<R>(&mut self, dim: Dim, f: impl FnOnce(&Self) -> R) -> Option<R> {
let back = self.enter_command_level(dim);
let result = (self.dim == dim).then(|| f(self));
if let Some(back) = back {
self.enter_command_level(back);
}
result
}
/// The entities of the level in place, as a selector finds them (`@e`),
/// and the hub's players in it.
fn entities_in_place(&self) -> Vec<(u32, String, [f64; 3])> {
let mut out = Vec::new();
for t in self.level_candidates() {
if let Target::Mob(id) = t
&& !self.mobs.iter().any(|m| m.id == id && !m.removed)
{
continue;
}
if let (Some(id), Some(pos)) = (target_id(t), self.target_position(t)) {
out.push((id, self.target_type(t), pos));
}
}
let dimension = self.dim.id();
for g in self.lan.iter().flat_map(|h| h.hub.guests.iter()) {
if g.placed && g.dimension == dimension {
out.push((
g.entity_id as u32,
"minecraft:player".to_owned(),
g.position,
));
}
}
out
}
/// A world's block entities with their types (`native_level_block_entities`).
fn block_entity_types(&self, world: &World) -> Vec<([i32; 3], String)> {
let facts = self.res.facts();
world
.block_entities
.iter()
.map(|(&(x, y, z), _)| {
let name = &facts.get(world.block(x, y, z)).name;
let kind = horizon_client::lan::chunks::block_entity_type(name)
.map_or_else(|| name.to_string(), str::to_owned);
([x, y, z], kind)
})
.collect()
}
}
/// Counts by name, the most first and ties by name.
fn most_first(counts: BTreeMap<String, usize>) -> Vec<(String, usize)> {
let mut out: Vec<_> = counts.into_iter().collect();
out.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
out
}
#[cfg(test)]
mod tests {
/// Most first; a tie keeps the names in order.
#[test]
fn counts_come_most_first() {
let counts = [
("minecraft:cow", 2),
("minecraft:bat", 2),
("minecraft:zombie", 5),
]
.map(|(k, n)| (k.to_owned(), n))
.into_iter()
.collect();
let names: Vec<_> = super::most_first(counts)
.into_iter()
.map(|(k, n)| format!("{k} {n}"))
.collect();
assert_eq!(
names,
["minecraft:zombie 5", "minecraft:bat 2", "minecraft:cow 2"]
);
}
}
@@ -100,7 +100,7 @@ fn root_id(target: Option<Target>, first: u32, live: impl Fn(u32) -> bool) -> Op
}
/// The entity id a summoned stack's root is known by.
fn target_id(t: Target) -> Option<u32> {
pub(super) fn target_id(t: Target) -> Option<u32> {
match t {
Target::Mob(id)
| Target::Item(id)
@@ -134,10 +134,12 @@ impl Game<'_> {
/// Spawns an entity of `kind` (`minecraft:item_display`) at `pos` in the
/// level in place, `dimension`, from `nbt` (its compound, as `/summon
/// <kind> <pos> <nbt>` takes it; passengers included): its id. None on a
/// client's copy of the world, for another dimension, a position outside
/// client's copy of the world, for a position outside
/// `/summon`'s bounds, a chunk that isn't loaded, a kind Horizon can't
/// load, or one a mod kept out (`native_entity_join_level`). API
/// 59.
/// 59. Since API @API:npc_any_level@, `dimension` may be any level the
/// server has loaded, which is put in place for the summon (`execute in
/// <dimension> run summon`); a level not loaded is refused.
pub(super) fn native_summon(
&mut self,
dimension: &str,
@@ -145,6 +147,13 @@ impl Game<'_> {
pos: [f64; 3],
nbt: Compound,
) -> Option<u32> {
if (self.serving.is_some() || self.options.dedicated.is_some())
&& dimension != self.dim.id()
{
return self
.npc_in_level(dimension, |g| g.native_summon(dimension, kind, pos, nbt))
.flatten();
}
if !self.native_simulates_level(dimension) || !position_ok(pos) {
return None;
}
@@ -250,14 +259,94 @@ impl Game<'_> {
/// Removes entity `id`, one Horizon keeps without simulating, as `/kill`
/// does (it drops nothing and isn't saved). False for no such entity or
/// another dimension. Mobs go with `native_remove_mob`. API
/// 59.
/// 59. Since API @API:remove_projectiles@ it also takes projectiles
/// (arrows, tridents, snowballs, eggs, pearls, fireballs, wind charges,
/// llama spit, shulker bullets, firework rockets) as `/kill` does, and a
/// server player's fishing bobber (`Entity.discard`; the player in place's,
/// or, outside a guest's turn, any of this level's players').
pub(super) fn native_remove_entity(&mut self, dimension: &str, id: u32) -> bool {
if !self.native_simulates_level(dimension) || !self.others.iter().any(|o| o.id == id) {
if !self.native_simulates_level(dimension) {
return false;
}
self.others.retain(|o| o.id != id);
self.persist.raw.remove(&id);
true
if self.others.iter().any(|o| o.id == id) {
self.others.retain(|o| o.id != id);
self.persist.raw.remove(&id);
return true;
}
// An id of 0 is a projectile no server tracks yet: never one a mod was told of.
if id != 0
&& let Some(
projectile @ (Target::Projectile(_) | Target::Trident(_) | Target::Rocket(_)),
) = self.level_entity(id as i32)
{
self.kill_target(projectile);
return true;
}
self.remove_server_hook(id)
}
/// Hurts the living entity `target` (a mob, or a player of the level `dimension`) by
/// `amount` of `damage_type` (`minecraft:mob_attack`), from `attacker` (a mob's or a
/// player's entity id: the causing and direct entity, as vanilla's
/// `damageSources().mobAttack(attacker)`; none for no entity), as `LivingEntity.hurtServer`
/// and `/damage <target> <amount> <damage_type> by <attacker>` do: armour, effects,
/// invulnerability time, knockback from the attacker, death and its message, and the damage
/// hooks (`native_living_incoming_damage` and its versions) hear it. Whether it was hurt:
/// false for an invulnerable, dead or creative target, another dimension, an entity that
/// isn't living, an attacker that isn't there, or an amount that isn't a finite number.
/// API @API:hurt_entity@.
pub(super) fn native_hurt_entity(
&mut self,
dimension: &str,
target: u32,
amount: f32,
damage_type: &str,
attacker: Option<u32>,
) -> bool {
if !self.native_simulates_level(dimension) || !amount.is_finite() {
return false;
}
let Some(victim) = self.hurt_entity_target(dimension, target) else {
return false;
};
let by = match attacker {
Some(id) => match self.hurt_entity_target(dimension, id) {
Some(t) => Some(t),
None => return false,
},
None => None,
};
let from = by.and_then(|t| self.target_position(t));
self.damage_target(victim, amount, damage_type, None, by, from)
}
/// The player or mob `id` names in the level `dimension`: a player by their entity id
/// (the one in place, whose turn it is, as `Target::Player`), a mob that is there.
fn hurt_entity_target(&self, dimension: &str, id: u32) -> Option<Target> {
let entity = i32::try_from(id).ok()?;
let guest = self
.lan
.iter()
.flat_map(|h| h.hub.guests.iter())
.find(|g| g.placed && g.entity_id == entity && g.dimension == dimension);
if let Some(g) = guest {
let acting = self.lan_acting == Some(g.conn) && self.player.is_some();
return Some(if acting {
Target::Player
} else {
Target::Guest(g.conn)
});
}
// The player in place while the hub is out (a guest's turn), or a LAN host.
if (self.lan_acting.is_some() || self.options.dedicated.is_none())
&& self.player.as_ref().is_some_and(|p| p.entity_id == entity)
{
return Some(Target::Player);
}
match self.level_entity(entity)? {
Target::Mob(mob) if self.mobs.iter().any(|m| m.id == mob && m.alive()) => {
Some(Target::Mob(mob))
}
_ => None,
}
}
}
Loaded 100 of 451 files, more files were not shown because too many files have changed in this diff. Show more