Compare commits

...
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 56e599462a Merge main and fold in the launcher file-room move
main's native_mods now installs the mods' content (#11083): the prebuilt export writes it into
DIR/native-content as an install does, and unpack (now in native_mods/source.rs) reads the
bundle's source files. cli.rs and native_mods.rs are over 3000 lines and may not grow, so this
branch also carries the pure move of #13996 (screenshots and multiplayer to cli/media.rs,
warn_conflicts to native_mods/conflicts.rs).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:06:12 +01:00
Horizon agent 31cfa786d2 Merge remote-tracking branch 'origin/agent/launcher-file-room' into fix-11898 2026-11-09 17:04:34 +01:00
Ludovic Marie b6b65ccb8d Merge pull request #13796 from MSedgeMC/fix/banner-raid-end
🤖 Agent: checked against Raider.ObtainRaidLeaderBannerGoal.cannotPickUpBanner: active raid, isOver and canBeLeader (witch/ravager excluded) are ported; the ominous-banner-on-head and living-wave-leader terms are still missing, which the body says.
2026-11-09 17:04:31 +01:00
Ludovic Marie ce2000390a Merge pull request #13792 from MSedgeMC/fix/beg-spectator
🤖 Agent: matches 26.3 BegGoal: forNonCombat filters spectators only in canUse, while canContinueToUse checks just isAlive, distance and the held item, so moving !spectator out of interesting() also lets a wolf keep begging at a player who turns spectator, as vanilla does.
2026-11-09 17:04:27 +01:00
Ludovic Marie 0f236dfaeb Merge pull request #13948 from MSedgeMC/fix/editbox-editable
🤖 Agent: matches AnvilScreen: setEditable(!input.isEmpty()) on slotChanged with the box kept focused, and setTextColorUneditable(-1) so it stays white. I changed the anvil test to expect the empty value slotChanged sets when the input goes, instead of a name that stays.
2026-11-09 17:01:31 +01:00
Ludovic Marie 1d56cf6658 Merge pull request #13922 from adiraj66132/perf/region-first-free-sector
🤖 Agent: the watermark stays exact because every write to used goes through force/free; the 120-op test pins placements to first fit from 0 (rustfmt and temp-dir cleanup pushed).
2026-11-09 17:01:27 +01:00
Ludovic Marie 5a99cf8ecf Merge pull request #13983 from horizon-rust/agent/big-file-room
🤖 Agent: a pure move (only `fn` visibility words change; rustfmt rewrapped one signature): ProjWorld's ProjectileWorld impl, the dragon's death render, the minecart display helpers and StopDig leave four files over 3000 lines. That gives #11507 room under the size rule. Verify and launcher are green on this head.
2026-11-09 16:58:37 +01:00
Ludovic Marie 7160eea6d5 Merge pull request #13844 from NikitaMGrimm/hdr-pass-rules
🤖 Agent: docs only; checked the references against main: rule 2's `2.0 - hi` limit is world_post.wgsl:77, TAA's history is Rgba16Float (world_post.rs HISTORY_FORMAT), and Hdr::tonemap / Hdr::sdr_screenshot exist as named.
2026-11-09 16:56:15 +01:00
Ludovic Marie 75b85d258f Merge pull request #13968 from horizon-rust/split/render-pipeline-config
🤖 Agent: config types move to the new engine crate horizon-render-pipeline; horizon-shaders keeps the parsing as the ReadConfig extension trait, so no pack reads differently.
2026-11-09 16:54:02 +01:00
Ludovic Marie fe2982ecdd Merge pull request #13977 from horizon-rust/move/jei-sprites
🤖 Agent: pure move, same six JEI sprites in the same order now come from jei_sprites::SPRITES; hud.rs shrinks by 26 lines, which the Recipes PRs need under the file-size rule.
2026-11-09 16:53:58 +01:00
Ludovic Marie 5248e83f28 Merge pull request #13953 from DUzzL/fix/13852-entity-ticking-neighbours
🤖 Agent: the 2-column gate matches ChunkMap.prepareEntityTickingChunk (getChunkRangeFuture radius 2), but it ran 25 hash probes per entity per call site; pushed a per-chunk memo inside the closure so a crowd in one chunk pays the neighbourhood check once. Forced chunks stream 3-4 rings, so a /forceload'd chunk still gets its ring.
2026-11-09 16:51:04 +01:00
Ludovic Marie 64d3619671 Merge pull request #13875 from TheMrZZ/ci/nightly-steps-bash32
🤖 Agent: ${arr[@]+"${arr[@]}"} expands to nothing for an empty array on bash 3.2 and to the same quoted words otherwise, so the nightly's argument lists are unchanged on CI's bash.
2026-11-09 16:45:15 +01:00
Ludovic Marie 693c5326f1 Merge pull request #13879 from NikitaMGrimm/hdr-calibration-window
🤖 Agent: the hint's font.split runs only while the calibration page is open, so no per-frame cost elsewhere; set_maximized right after set_fullscreen(None) is the one Windows-only step CI can't exercise.
2026-11-09 16:45:10 +01:00
Ludovic Marie 145d9e593b Merge pull request #13862 from vnsmoreira/fix/death-time-short
🤖 Agent: (short) casts with no clamp match LivingEntity.addAdditionalSaveData; a death_time past 32767 now wraps like Java instead of saturating. Nothing else found.
2026-11-09 16:45:04 +01:00
Ludovic Marie f000ba2db4 Merge pull request #13861 from MSedgeMC/fix/advancements-seen-tab
🤖 Agent: both packets match ClientAdvancements.setSelectedTab (sent before the same-tab compare) and AdvancementsScreen.removed. The client still ignores ClientboundSelectAdvancementsTabPacket, so the server's answer doesn't reopen its tab yet; that's a separate follow-up.
2026-11-09 16:44:59 +01:00
Ludovic Marie 720146f962 Merge pull request #13850 from TheMrZZ/audit/block-predicate-typed-state-ranges
🤖 Agent: matches RangedMatcher.match (Property.getValue on each bound, then compareTo); Direction ranks by its ordinal, not the facing property's possible-values order, which is the case a domain-index rule gets wrong. block_property_values allocates the domain per check, fine since only object ranges reach it. The blank lines removed to stay under the 3000-line cap are cosmetic.
2026-11-09 16:44:54 +01:00
Ludovic Marie bce20797b6 Merge pull request #10243 from iwaneth/codex/piston-projectile-parity
🤖 Agent: a stuck arrow or trident now leaves its block from the piston's own move (AbstractArrow.move with a non-SELF mover and shouldFall), with startFalling's three nextFloat()*0.2 draws and life = 0, instead of on any client block change; tick order stays Minecraft.tick's.
2026-11-09 16:44:48 +01:00
Ludovic Marie 6ea90b860a Merge pull request #11083 from voidsipp/fix/10914-native-content-assets
🤖 Agent: a combined native .hmod's content is validated through Content::read (no ../ or foreign namespaces), hashed into the build key and snapshotted beside that exact build, so an updated archive can't change an older build's sounds; the out-of-scope ci.yml/check_file_size change was taken out.
2026-11-09 16:44:44 +01:00
Ludovic Marie 0533979cc8 Merge pull request #13859 from DylanGonzaloFerreyra/split-play-commands
🤖 Agent: the command-tree codecs move verbatim and play.rs re-exports ARGUMENT_TYPES/ArgumentProperties/CommandNode/Commands, so horizon_protocol::packets::play paths keep working; nothing found.
2026-11-09 16:44:39 +01:00
Ludovic Marie e766d161d3 Merge pull request #13880 from Skr1zzzz/split-block-breaking
🤖 Agent: pure move of break_block and its four helpers, bodies identical, port_map rows repointed; nothing found.
2026-11-09 16:44:34 +01:00
Ludovic Marie 62f7d0fc17 Merge pull request #13878 from Skr1zzzz/split-explosions
🤖 Agent: pure move; the only change is the explosion:: paths shortening to the module's own BlockInteraction/ExplosionSource, which name the same types. Nothing found.
2026-11-09 16:44:29 +01:00
Ludovic Marie 74da693e68 Merge pull request #13877 from Skr1zzzz/split-held-items
🤖 Agent: pure move of the seven hand/inventory helpers (held, with_hand, give, glider_worn...), bodies identical; nothing found.
2026-11-09 16:44:24 +01:00
Ludovic Marie 311a64ea13 Merge pull request #13860 from Skr1zzzz/split-inventory-slots
🤖 Agent: pure move of container_kind/slot_count/slots, identical bodies; nothing found.
2026-11-09 16:44:20 +01:00
Ludovic Marie 3b53c55932 Merge pull request #13856 from Skr1zzzz/split-item-entities
🤖 Agent: pure move: spawn_item, the pop_resource pair, drops and block_experience match game.rs line for line (only rustfmt wrapping and pub(super) differ). Heads-up: #13864 touches the lines next to these and will need main merged in after this lands.
2026-11-09 16:44:16 +01:00
Ludovic Marie f9dee224bc Merge pull request #13854 from Skr1zzzz/split-weather
🤖 Agent: pure move checked line by line: the 8 weather/sky fns leave game.rs unchanged apart from pub(super); nothing found.
2026-11-09 16:44:11 +01:00
Horizon agentandClaude Opus 5.5 68be90d92b Launcher: move screenshots/multiplayer out of cli.rs and warn_conflicts out of native_mods.rs
A pure move into child modules (cli/media.rs, native_mods/conflicts.rs), so #11898's prebuilt
export fits under the file-size rule (docs/layers.md#file-size). Only `fn` -> `pub(super) fn`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:40:51 +01:00
Ludovic Marie 79e396c135 Merge pull request #13679 from d3xc0/android-touch-3b
🤖 Agent: nothing calls the gestures yet, so this is relabelled skip-release; one point for part 4's wiring: poll_state presses triggers[0] for a hold that uses the item and triggers[1] for mining, the same slots the shield button uses, so a shield hold and an eating hold on two fingers both drive triggers[0].
2026-11-09 16:40:05 +01:00
Ludovic Marie 40c22423dd Merge pull request #13952 from horizon-rust/layers/replay-packet-tap
🤖 Agent: net.rs now names only the PacketRecorder trait; the privacy filter moved with its tests to studio/recorder.rs, and verify passed (workspace tests + server build).
2026-11-09 16:40:01 +01:00
Ludovic Marie ae259da5df Merge pull request #13785 from MSedgeMC/port_map/sheep-wool-colour
🤖 Agent: nothing found; render.rs::layers picks jeb_wool_rgb(age) for jeb_ sheep on main, as the row now says.
2026-11-09 16:36:24 +01:00
Ludovic Marie f0ffcadaf5 Merge pull request #13784 from MSedgeMC/port_map/sheep-colour-layers
🤖 Agent: nothing found; glow_squid_block_light is called from creatures.rs mob_geometry on main, so dropping the 'never brightens' gap is right.
2026-11-09 16:36:20 +01:00
Ludovic Marie 771705a060 Merge pull request #13782 from MSedgeMC/port_map/frame-geometry-note
🤖 Agent: nothing found; checked that celestial_geometry is at game/frame_geometry.rs:79 on main.
2026-11-09 16:36:16 +01:00
Ludovic Marie 86255fd295 Merge pull request #13949 from horizon-rust/layers/seam-official
🤖 Agent: the shipped binary is now horizon-official, copied to horizon-client before signing and packaging. The Windows icon and 8 MB stack come from the same build-script code (packaging/windows/exe.rs). The launcher's native builds pick it only when the revision has crates/horizon-official, and the server build stays horizon-client's. The ping moves out of the engine.
2026-11-09 16:36:12 +01:00
Horizon agentandClaude Opus 5.5 2f124cc0d2 pack_post.rs: import BUFFERS instead of re-declaring it, so the file doesn't grow
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:34:13 +01:00
Ludovic Marie 8321f11360 Merge pull request #13928 from MSedgeMC/port_map/minecart-hurt-tilt
🤖 Agent: confirmed on main against AbstractMinecartRenderer.submit: the offsetSeed jitter (×0.004 per axis) and the sin(hurt)·hurt·damage/10·hurtDir roll with partial-tick hurt and damage are both in cart_geometry.
2026-11-09 16:31:37 +01:00
Ludovic Marie 4c231b6c45 Merge pull request #13927 from MSedgeMC/port_map/enchant-hand-slots
🤖 Agent: confirmed on main: apply_enchantment_attributes walks the armour, then mainhand and offhand (enchant_effects.rs:1375-1381), each gated by equipment_modifier_applies.
2026-11-09 16:31:32 +01:00
Ludovic Marie a7934f85c2 Merge pull request #13926 from MSedgeMC/port_map/minecart-display-nbt
🤖 Agent: confirmed on main: to_nbt writes DisplayOffset only off the kind's default and DisplayState only when set (minecarts.rs:3066-3083), and forget_unreadable_display drops a state that no longer resolves.
2026-11-09 16:31:28 +01:00
Ludovic Marie 05bc440a21 Merge pull request #13924 from MSedgeMC/port_map/hoglin-nbt
🤖 Agent: confirmed on main: mob_to_nbt writes CannotBeHunted (entity_nbt.rs:1026), mob_from_nbt reads it (2259), and a_hoglin_keeps_cannot_be_hunted_and_is_no_prey round-trips it; the gap notes were stale.
2026-11-09 16:31:24 +01:00
Ludovic Marie fffb893691 Merge pull request #13915 from Knifethrower/perf/box-template-block-nbt
🤖 Agent: boxing takes BlockInfo from 40 to 24 bytes; the only added cost is one allocation per NBT-carrying block at template load, and place_in_world moves the box out (*nbt) with no clone. Main has no new BlockInfo construction since your base, and horizon-world --all-targets checks clean merged with main.
2026-11-09 16:31:19 +01:00
Ludovic Marie 1dfd9f03a6 Merge pull request #13909 from Skr1zzzz/split-equipment
🤖 Agent: pure move, removed and added lines match; apply_equipment now reaches equipment_modifier_applies through the named import, and all its callers sit inside game/, so pub(super) from player_equip.rs still reaches them.
2026-11-09 16:31:14 +01:00
Ludovic Marie 5bec6ad6ee Merge pull request #12569 from gigamans442/fix/glsl-signed-indices-20261108
🤖 Agent: gl_VertexID/gl_InstanceID become int(gl_VertexIndex)/int(gl_InstanceIndex) at the token, so (gl_VertexID - 7) / 2 divides signed as GLSL says; explicit uint() casts keep their meaning.
2026-11-09 16:28:18 +01:00
Horizon agentandClaude Opus 5.5 047ec7683e Anvil rename test: an emptied input empties the box
AnvilScreen.slotChanged calls setValue("") when the input slot empties;
the caller passes the empty name, so the test does too instead of pinning
a name that stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:28:16 +01:00
Ludovic Marie 0be74d73d6 Merge pull request #12926 from Teiwazik/upscaler/backend-seam
🤖 Agent: TAA's uniform is rebuilt from UpscaleFrame with jitter[3] = 0 and reset as 1.0/0.0, the same bytes as taa_this_frame, so the move is exact. skip-release added.
2026-11-09 16:28:15 +01:00
Ludovic Marie edddfd6008 Merge pull request #13623 from NikitaMGrimm/hdr-output-o2
🤖 Agent: the display watch, and with it the get_capabilities read every 2 s, exists only while the HDR mode isn't Off, so players with HDR off pay nothing. Lands #13555's commits with it if that one isn't in yet.
2026-11-09 16:28:10 +01:00
Ludovic Marie fbc2031786 Merge pull request #13555 from NikitaMGrimm/hdr-crash-recovery
🤖 Agent: the hold is keyed on the driver fingerprint, OS version included, so a Windows cumulative update retries HDR every month by design; HDR off only drops the marker as before. Spec #13661 approved.
2026-11-09 16:28:06 +01:00
Horizon agentandClaude Opus 5.5 497c137523 Move four self-contained pieces out of creatures.rs, dragon.rs, minecarts.rs and lan_actions.rs
A pure move, so fixes in these files fit under the file-size rule (docs/layers.md#file-size):
- creatures.rs: ProjWorld's ProjectileWorld impl -> creatures/projectile_world.rs
- dragon.rs: the death dissolve and rays -> dragon/death_render.rs
- minecarts.rs: the display-offset helpers -> minecarts/display.rs
- lan_actions.rs: StopDig and stop_dig -> lan_actions/dig_stop.rs
Only visibility changes (private -> pub(super), pub(super) -> pub(in super::super)) and the
port map row for EnderDragonRenderer.submitRays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:27:00 +01:00
Horizon agentandClaude Opus 5.5 e3451f9fe6 Move the Recipes viewer's JEI sprite list out of hud.rs
Pure move: the six (name, PNG) pairs HudAtlas loads as horizon/jei/<name> now live in jei_sprites.rs. hud.rs is over 3000 lines and may not grow (docs/layers.md#file-size), and the open Recipes PRs (#11446, #11448, #11449, #11453) each add sprites to this list.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:13 +01:00
MSedgeMC 21b1ebd511 The read-only box consumes Backspace, as EditBox.keyPressed returns true
A refused Backspace still returns true, so the anvil test expects the key to
be handled with the value unchanged.
2026-11-09 15:23:25 +00:00
Horizon agentandClaude Opus 5.5 8cd25efc36 Entity ticking gate: one lookup per entity, not 25
Every caller asks once per entity, so remember each chunk's 5x5 answer
inside the closure: a crowd of entities in one chunk pays the neighbourhood
probe once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:20:45 +01:00
Ludovic Marie b80251563e Merge pull request #13947 from MSedgeMC/fix/switcher-mouse-release
🤖 Agent: matches GameModeSwitcherScreen.mouseReleased (keyDebugModifier.matchesMouse, then switchToHoveredGameMode and setScreen(null)); comparing the binding's key name and requiring key.mouse.* is the same type check matchesMouse makes. Nothing else found.
2026-11-09 16:20:02 +01:00
Ludovic Marie 6aafaf66cd Merge pull request #13946 from MSedgeMC/fix/container-double-click
🤖 Agent: checked against 26.3's bytecode: mouseClicked stores lastClickSlot at its very end for every click that gets past Screen.mouseClicked, null slot included, so recording before the outside/throw branch is right; the recipe book and native-screen presses still return before it, as the widgets do in vanilla.
2026-11-09 16:19:58 +01:00
Ludovic Marie f4b5bee9e5 Merge pull request #13944 from Ragbie/fix/guest-horse-fall-clip
🤖 Agent: moving the reset before the moved-wrongly check is the real fix here: handleMoveVehicle runs vehicle.move (and its FALLDAMAGE_RESETTING clip) before it can put the move back, so a rejected move keeps the reset for the boat too. The two horse cases are --ignored (need resources), so CI proves this through the 'vehicle fall clip' harness group.
2026-11-09 16:19:54 +01:00
Horizon agentandClaude Opus 5.5 7081c411dd Cargo.lock: horizon-render-pipeline
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:19:52 +01:00
Horizon agentandClaude Opus 5.5 4bdd1282b2 rustfmt the sync-flag lines in lan.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:19:15 +01:00
Horizon agentandClaude Opus 5.5 09722f6830 Merge origin/main into #10243
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:19:02 +01:00
Horizon agentandClaude Opus 5.5 86b52f39ec lan.rs is over 3000 lines and may not grow: clear the projectile and trident sync flags in one line each
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:19:00 +01:00
Horizon agentandClaude Opus 5.5 4ffc44a599 region: rustfmt the first-free test, remove its temp dir
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:18:54 +01:00
Horizon agentandClaude Opus 5.5 d8483e0972 Merge origin/main into #10243
main gave tridents AbstractArrow.shouldFall/startFalling (server side) and pistons set_piston_on_ground/set_on_ground: kept beside this PR's piston release. The trident keeps main's startFalling, which now also does vanilla's life = 0 and marks the LAN sync.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:18:39 +01:00
Ludovic Marie 8c0ec0945d Merge pull request #13919 from horizon-rust/layers/seam-hooks
🤖 Agent: the seam's foundation. The engine gets horizon-hooks (Hooks trait, register before the run, sealed in entry::main; an empty dispatch is one OnceLock load). The ping now runs only through it, still registered by horizon-client until #13949 ships horizon-official. No allowed entry added.
2026-11-09 16:17:03 +01:00
MSedgeMC 5ed5756b1f The read-only test clears its selection before typing again
insert_text replaces the selection, so the editable half of the test starts
with the caret at the end.
2026-11-09 15:15:53 +00:00
Horizon agentandClaude Opus 5.5 3df9ec4110 Merge origin/main into #11083; leave the file-size CI change for its own PR
Conflict in game/mods.rs: main reads enabled packages in parallel (read_contents); this PR's rules (a native-content snapshot is on unless the mod is disabled, a legacy copy never replaces the snapshot) now live in that filter. The ci.yml/check_file_size.py --pr-head change is a CI change in a feature PR and is not needed for it: main's versions are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:14:48 +01:00
MSedgeMC 0490e076ff The anvil's redraw uses the moved limit constant
draw_anvil_name still named ANVIL_NAME_CHARS, which now lives in the anvil
module.
2026-11-09 15:13:57 +00:00
Ludovic Marie 6442dc599e Merge pull request #13932 from horizon-rust/ci/git-slim
🤖 Agent: git-slim only touches .git packs and tmp_pack files, never refs or the working tree; 5 tests cover fold, gc, tmp cleanup.
2026-11-09 16:13:36 +01:00
Ludovic Marie 0ed8b278ad Merge pull request #10088 from MSedgeMC/fix/subtitles-over-screens
🤖 Agent: Screen.isInGameUi screens (containers, block editors, book, sign) now keep the subtitles under F1 and draw them before the screen, as Screen.extractBackground runs Hud.extractDeferredSubtitles; the subtitle and HUD-render gates moved out of game.rs, which stays under its size.
2026-11-09 16:13:32 +01:00
Horizon agentandClaude Opus 5.5 4639551d77 rustfmt recorder.rs
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:12:57 +01:00
Horizon agentandClaude Opus 5.5 0f70c0c356 launcher: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:12:12 +01:00
Ludovic Marie a574df5379 Merge pull request #11011 from rison1337/perf/cull-offscreen-xp-orbs
🤖 Agent: the orb cull runs only behind the camera (forward·c < 0) before the six-plane sphere test, so visible orbs pay one dot product; @rdbtCVS's split is done and approved at this head.
2026-11-09 16:10:34 +01:00
Ludovic Marie 2e4c2b511f Merge pull request #11803 from horizon-rust/ci/runner-gc-busy-versioned-bin
🤖 Agent: the new `-[a-z0-9_]{8}$` rule matches `tempfile.mkdtemp`'s suffix alphabet. It sits after the symlink and `parity-runner` skips, so the shared preparations and runner links are never touched. Verify is green on this head.
2026-11-09 16:10:29 +01:00
Horizon agent 246a39cfc2 Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts:
#	launcher/src/native_mods.rs
2026-11-09 16:09:35 +01:00
MSedgeMC 805414435e The anvil's read-only name box keeps its white text
AnvilScreen sets setTextColorUneditable(-1) beside setTextColor(-1), so the
read-only box is white too, not the EditBox default grey.
2026-11-09 15:04:08 +00:00
Ludovic Marie 57b0346a6d Merge pull request #13923 from horizon-rust/layers/dualsense-out-of-input
🤖 Agent: the trigger-effect bytes are unchanged (the moved test keeps every assertion); horizon-client-lib's dualsense dep was unused. Removes 2 layer exceptions.
2026-11-09 15:59:45 +01:00
Horizon agentandClaude Opus 5.5 1ce7bf44df Cargo.lock: horizon-heartbeat, horizon-official's build script
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:59:27 +01:00
DUzzL 8507cc3e38 Hold entity ticking until the 2-column neighbourhood is loaded 2026-11-09 15:58:53 +01:00
MSedgeMC 4678c7c83e A read-only edit box refuses typed text, and the anvil's name box is read-only without an input
EditBox.setEditable(false) leaves a box focused and showing its value in
textColorUneditable, keeps the caret keys and copy, and refuses typed text,
Backspace/Delete, paste, and Cut's take (Cut still copies). AnvilScreen makes
its name box read-only whenever the input slot is empty; Horizon's anvil
inserted text anyway on the paths that did not route keys through the "input
present" gate (an IME commit calls station_text directly), and it never used
the uneditable colour.

ui::EditBox now has `editable` and `text_color_uneditable`, gates its editing
methods, and draws the uneditable colour. The anvil's rename box lives in
game/stations/anvil.rs (moved out of the over-size stations.rs) and follows
slotChanged.
2026-11-09 14:58:27 +00:00
MSedgeMC 56dadd398a The game-mode switcher applies on the debug modifier's mouse release
GameModeSwitcherScreen.mouseReleased switches to the hovered mode and closes
when `keyDebugModifier.matchesMouse(event)`, so binding the debug modifier to
a mouse button makes that button's release apply the choice (its key release
already does through keyReleased). Horizon handled only the key release, so a
modifier on mouse button 4 or 5 left the switcher open and changed nothing.

The release now asks the same binding through
`game_mode_switcher::release_applies`. Moving `drop_whole_stack` to
`game/inventory_input.rs` in the same commit keeps game.rs from growing past
its file-size limit.
2026-11-09 14:58:19 +00:00
MSedgeMC b2a0a39b5e A click on no slot ends the container's double click
AbstractContainerScreen.mouseClicked sets `this.lastClickSlot = slot` for
every click that reaches the container logic; a click outside the window is a
click on no slot. The double click is the same slot and button within
MouseHandler's 250 ms. Horizon kept only the last slot press, so a click
outside did not end the chain: click a slot, click outside, then click the
same slot again within 250 ms, and the second click gathered the container
(PICKUP_ALL) or quick-moved every stack, as a double click.

The bookkeeping is now `container_screen::double_click`, which records every
click's slot (None outside) and answers the double click, and inventory_press
asks it once for the press.
2026-11-09 14:58:13 +00:00
Ludovic Marie 31d430f40a Merge pull request #13929 from horizon-rust/split/core-shaders-to-render
🤖 Agent: pure move of core_shader.rs (vanilla resource-pack core shaders) into the renderer, its only user; horizon-shaders no longer exports it.
2026-11-09 15:56:59 +01:00
Ludovic Marie 60cbe7fcc3 Merge pull request #13925 from horizon-rust/split/render-hooks-checked-in
🤖 Agent: the render hooks' generated code is now committed; the game's build compares it token by token with the catalog's and fails with the xtask command when stale (checked by editing a doc line). Removes horizon-client-render -> horizon-mod-api-gen.
2026-11-09 15:56:55 +01:00
Horizon agentandClaude Opus 5.5 45b9aa75ff Ship the official executable; the "I'm playing" ping leaves the engine
Release builds (game-build.yml, build-musl.sh) build horizon-official and ship
it as horizon-client; the launcher's native-mod builds do the same, and its
generated executable for trait mods registers Horizon's parts (official
feature, off in the server build). The ping moves from horizon-client-lib to
the Horizon crate horizon-heartbeat, registered by horizon-official; the
heartbeat feature is gone. The Windows icon and stack move to
packaging/windows/exe.rs, shared by both executables' build scripts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:53:26 +01:00
Horizon agentandClaude Opus 5.5 482caf2db9 Launcher and telemetry-server locks: horizon-hooks under horizon-platform
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:52:50 +01:00
Evrim CicekandClaude Opus 5.5 b230cc559c Reset a guest vehicle's fall before the move is checked
handleMoveVehicle runs vehicle.move, and with it Entity.move's long-move
fall reset, before the moved-wrongly and colliding checks; a move put
back with absSnapTo keeps the reset. The boat and the horse reset after
those checks, so a rejected move kept the fall.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:48:02 +03:00
Ludovic Marie 0eb683c454 Merge pull request #13917 from horizon-rust/decisions/upscaling-licence
🤖 Agent: interpolation.rs follows ffx_frameinterpolation.cpp like optical_flow.rs follows its .cpp, so it now carries the same MIT text; comment-only in Rust, no NVIDIA file anywhere.
2026-11-09 15:46:22 +01:00
Horizon agentandClaude Opus 5.5 5052d73cd4 horizon-render-pipeline: the pipeline's buffers and shadow map leave horizon-shaders
A new engine crate describes what an external shader pipeline gives the renderer. Its first
part is the colortex buffers and the shadow map (Format, Kind, Buffer, Shadow, Config, the
buffer names): moved unchanged from horizon-shaders' config.rs, which keeps reading them from a
pack's stage files (ReadConfig) and re-exports them. The renderer takes them from the new
crate. Layer plan M6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:44:45 +01:00
Horizon agentandClaude Sonnet 5.5 f9d90fcb53 CI: keep the runners' persistent .git from filling their disks
actions/checkout sets gc.auto=0 in a checkout and fetches into it at every
job; our self-hosted runners keep that checkout, so nothing ever repacks, each
fetch adds a pack and a fetch cut short leaves its tmp_pack_* behind. On
2026-11-09 the desktop's checks runners held 1051 packs (111 GB in one
checkout, 33 GB in another) and filled its disk.

tools/ci/git-slim.sh removes old tmp_pack_*/tmp_idx_* files, collects a .git
over MAX_GIT_MB (5 GB) with git gc --prune=now and folds more than MAX_PACKS
packs with a geometric repack. runner-gc.sh runs it on each idle Mac runner
(the nightly one too), and playhorizon's job-started.sh runs it before the
2 GB reclone it had.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 15:44:01 +01:00
Evrim CicekandClaude Opus 5.5 3e620360e9 A guest's horse resets its fall on a long move through a reset block or water
handleMoveVehicle's vehicle.move runs Entity.move, whose ray along a move
of a block or more (FALLDAMAGE_RESETTING, WATER) resets the fall before
the move counts. A guest's boat did this (#13021); the horse its client
moves (#13068) kept the fall and landed hurt. The vehicle fall clip group
reads the water boat's Pos[1] again now that the boat floats as vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:41:40 +03:00
Ludovic Marie ad67d515ad Merge pull request #13916 from horizon-rust/decisions/java-bridge-docs
🤖 Agent: docs only; nothing of the bridge is on main, the offer prompt and benchmark protocol are planned in #13914.
2026-11-09 15:41:40 +01:00
MSedgeMC 5f6de8bae4 port_map: the minecart renderer does apply the hurt tilt and jitter
The note said hurt_time is not read and there is no per-cart jitter. Both
are in cart_geometry.
2026-11-09 14:41:38 +00:00
Ludovic Marie 08c8433e3c Merge pull request #13912 from horizon-rust/decisions/unreviewed-translations
🤖 Agent: the review record keeps only text a person checked, so every new machine string starts unreviewed without anyone marking it; seeded from git blame, erring towards unreviewed.
2026-11-09 15:41:36 +01:00
MSedgeMC 9a3c1024ff port_map: runIterationOnEquipment reads the hands too
The note said only worn armour is read. apply_enchantment_attributes walks
the four armour slots and both hands.
2026-11-09 14:41:08 +00:00
MSedgeMC 9e410f893a port_map: the minecart rows save its display block and offset
The two notes said a custom display block and offset are not kept. Both
to_nbt and from_nbt handle DisplayState and DisplayOffset.
2026-11-09 14:40:44 +00:00
MSedgeMC 9f47fec19d port_map: the hoglin rows say CannotBeHunted is not saved
It is: mob_to_nbt writes it for every hoglin and mob_from_nbt reads it into
extra.cannot_be_hunted, with a round-trip test.
2026-11-09 14:40:22 +00:00
Horizon agentandClaude Opus 5.5 83b9cfc1d8 Vanilla core shaders' translation moves from horizon-shaders to the renderer
Resource packs' core shaders (shaders/core/) are a format vanilla reads, so their translation is
engine code: core_shader.rs moves unchanged from horizon-shaders (the platform's shaders/ pack
crate) to horizon-client-render, its only user. Layer plan M6, a step toward the renderer not
depending on horizon-shaders.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:39:12 +01:00
AdirajandMercury 3 bd2e287575 region: start sector allocation at the first free sector
RegionFile::allocate scanned the used-sectors bitmap from sector 0 on
every chunk write, so a long-lived region paid the full prefix scan per
save. Keep a low watermark of the first free sector; allocation starts
there (still first fit from 0, so placements are unchanged), and
force/free maintain it.

Co-Authored-By: Mercury 3 <[email protected]>
2026-11-09 20:05:56 +05:30
Horizon agentandClaude Opus 5.5 78a386e14d Cargo.lock: the dropped horizon-dualsense edges
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:29:30 +01:00
Horizon agentandClaude Opus 5.5 51060ec716 horizon-hooks: rustfmt, Cargo.lock
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:29:21 +01:00
Horizon agentandClaude Opus 5.5 1e1349cab7 Upscaling: interpolation.rs carries the FidelityFX MIT notice; docs say DLSS is a native mod
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:28:32 +01:00
Horizon agentandClaude Opus 5.5 b34ce5d7f3 Cargo.lock: the renderer has no build dependency left
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:27:05 +01:00
Horizon agentandClaude Opus 5.5 06fba30acc Docs: the Java bridge is a separate optional download, benchmarked against Java before promotion
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:26:54 +01:00
Skr1zzzzandClaude Opus 5.5 c584aac93d Move equipment attribute modifiers out of game.rs into game/player_equip.rs
Part of #11857. apply_equipment (LivingEntity.collectEquipmentChanges) joins game/player_equip.rs, which now imports equipment_modifier_applies from game.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:23:19 +03:00
Horizon agentandClaude Opus 5.5 b147d89df6 Translations: an unreviewed mark on machine-written strings
The project decided on 2026-11-09 that machine translations carry an
"unreviewed" mark that a person who speaks the language clears.
tools/lang_review/{game,launcher}/<code>.json records the text a person
wrote or checked; every other entry, or one changed since, is unreviewed.
lang_coverage.py counts them, lists them (--unreviewed) and records
checked entries (--mark-reviewed). docs/translations.md has the reviewer
workflow through #translations on Discord. The first record keeps the
1,917 entries git blame traces to single-language human contributors.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:21:31 +01:00
Horizon agentandClaude Opus 5.5 1db4e49c64 Render hooks committed: horizon-client-render stops depending on horizon-mod-api-gen
The renderer's RenderHooks trait and dispatch are made from native-hooks.json as before, but
committed in gfx/native_render/generated.rs: an engine crate may not build-depend on the
platform's generator (docs/layers.md, layer plan M6). xtask mod-api writes the file, and the
game's build, which still runs the generator, fails while it is out of date.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:20:43 +01:00
Horizon agentandClaude Opus 5.5 7cd8193a71 Layer seam: hooks for crates above the engine, and the official executable
horizon-hooks (engine): the Hooks trait (started, published, shutdown, empty
defaults), register before the game runs, the engine's dispatch. The platform
re-exports it as horizon_platform::hooks. horizon-game (platform): the game for
an executable to start. horizon-official (Horizon): the thin executable that
registers Horizon's in-game parts, not shipped yet. The "I'm playing" ping now
runs through the hooks. docs/layers.md: "How a Horizon crate plugs in".

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:16:18 +01:00
Horizon agentandClaude Opus 5.5 5a9f3f777a Replay recording: the session hands packets to a recorder trait (layer plan M6)
net.rs no longer names the Studio recorder: it offers PacketRecorder, a tap
the recorder fills, and the privacy filter moves to the recorder.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:15:39 +01:00
Ludovic Marie 1be2c30f53 Merge pull request #13011 from horizon-rust/layers/plan
🤖 Agent: docs only; NikitaMGrimm's hud_layout.rs correction folded in (engine; the widget framework is hud_kit), M3/M4a marked merged, M5/M6 started 2026-11-09 by project decision.
2026-11-09 15:14:42 +01:00
Horizon agentandClaude Opus 5.5 d0780fc55e Controllers: the DualSense trigger effects leave horizon-client-input
TriggerFeel's mapping to DualSense trigger effects moves from
horizon-client-input's gamepad.rs to game/pad/hardware/trigger_effects.rs,
the pad output code that already drives the DualSense. horizon-client-input
and horizon-client-lib no longer depend on horizon-dualsense (layer plan M6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:14:24 +01:00
Horizon agent d00cc3aa14 Merge remote-tracking branch 'origin/main' into layers/plan 2026-11-09 15:12:15 +01:00
MSedgeMC dfe1e81cd7 cargo fmt 2026-11-09 14:07:12 +00:00
KnifethrowerandClaude Opus 5.5 53b77a0525 Structure templates: box a block's NBT
Every block of every loaded template paid 24 bytes for an NBT compound
that nearly none of them have, and templates stay loaded for the session.
Option<Box<Compound>> takes 8, so a BlockInfo goes from 40 bytes to 24.
What gets placed doesn't change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:05:23 -05:00
Horizon agentandClaude Opus 5.5 a9e7940df2 docs/layers.md: M3 and M4's first part merged, M5 and M6 started
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:02:59 +01:00
Horizon agentandClaude Opus 5.5 9f4ef776d4 docs/layers.md: hud_layout.rs is engine; the HUD widget framework is hud_kit
NikitaMGrimm's correction on #13011.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:02:22 +01:00
MSedgeMC e3926a9a10 Move the subtitle and HUD-render gates out of game.rs
game.rs is over the 3000-line limit and may not grow, so the subtitle rule and the three HUD-render gates move to
game/subtitles.rs and game/hud_render.rs; game.rs ends below its base size.
2026-11-09 13:59:34 +00:00
Ludovic Marie 21a750533e Merge pull request #13866 from NikitaMGrimm/windows-pr-ready
🤖 Agent: test-only and tooling changes. The one Rust change opens the folder with FILE_FLAG_BACKUP_SEMANTICS only on Windows, so other platforms run the same test as before
2026-11-09 14:54:56 +01:00
Ludovic Marie 1341217fdf Merge pull request #13847 from NikitaMGrimm/tooling/file-size-skip-test-modules
🤖 Agent: when in doubt the module counts: a test module is skipped only when the brace count and rustfmt's layout agree on its end, so real code can't hide in one
2026-11-09 14:54:52 +01:00
MSedgeMC 43e356e823 Merge remote-tracking branch 'upstream/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-09 13:43:10 +00:00
Vinícius MoreiraandClaude Opus 5.5 f079407800 A mob keeps a negative DeathTime or HurtTime
LivingEntity reads HurtTime and DeathTime with getShortOr and writes them
back as (short) casts, nothing clamped. Horizon clamped both to
0..=32767 on save, so a negative value read back as 0s.

Fixes #13846

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:29:06 -03:00
Skr1zzzzandClaude Opus 5.5 10ffe236fb Move breaking a block and its drops out of game.rs into game/block_breaking.rs
Part of #11857. Mining with a held tool, break_block and the player's break, a broken block's drop, the destroy-progress cracks and a bundle dropping its content move to a new game/block_breaking.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:25:01 +03:00
Skr1zzzzandClaude Opus 5.5 8704d0e086 Move the level's explode entry points out of game.rs into game/explosion.rs
Part of #11857. ServerLevel.explode's entry points (explode, explode_mob, explode_sulfur_cube) join game/explosion.rs; inside it they name ExplosionSource and BlockInteraction without the explosion:: prefix. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:21:38 +03:00
Skr1zzzzandClaude Opus 5.5 afe1554048 Move the held and worn item helpers out of game.rs into game/held_items.rs
Part of #11857. The stack in the acting hand, the hand in use, with_hand, Inventory.add and the worn glider and freeze-immune gear move to a new game/held_items.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:18:15 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 ba6c54fe42 HDR calibration: the hint wraps, and the window comes back maximized
Calibrate's hint for each step was one centred line. At a large GUI scale
the Paper White hint is wider than the window, and its start and end were
cut off. It now wraps within the window, and the band of controls grows
up by the lines it adds. The test picture HDR output draws ends at that
band, so `picture`, `draw` and `click` share one layout, which now takes
the font.

Calibration turns a window that isn't fullscreen into fullscreen for its
test pictures. Leaving fullscreen gives a window back at its restored
size on Windows, so a maximized window came back un-maximized after Save,
Cancel or Escape. Calibration now remembers whether the window was
maximized and maximizes it again when it leaves (#13829).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:17:51 +01:00
Florian ErnstandClaude Opus 5.5 2ddadf8edc Nightly release steps: optional archives work with bash 3.2
"${arm64[@]}" and "${musl[@]}" are unbound when the array is empty
in bash before 4.4 (macOS's /bin/bash is 3.2), so the 3 cases of
test_optional_release_assets without an optional archive failed locally
and pr_ready showed Python tests red on every Mac. The
${a[@]+"${a[@]}"} form expands to nothing there and is the same on
newer bash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:17:03 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 42a15b0561 Python tools and tests, and a mods test: the same results on Windows
- check_file_size and stamp_native_hooks read git's output and their files as UTF-8, not
  in the code page (cp1252 can't decode 0x9d, in UTF-8 such as "”").
- test_optional_release_assets runs the bash on PATH, as test_pr_ready does: a bare "bash"
  is WSL's on Windows, which gets none of the steps' env.
- The live-switch verdict test opens the mod folder so Windows can set its time.

Fixes #13858

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:57:12 +01:00
Skr1zzzzandClaude Opus 5.5 4c82ef0d9e Move a container screen's slot list out of game.rs into game/inventory_slots.rs
Part of #11857. The open container screen's slot list (container_kind, slots, slot_count) joins the slot layout in game/inventory_slots.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:55:04 +03:00
MSedgeMC 2c3c6e355c port_map: the advancement tab rows say the packets go out
The removed row was filed as missing and the setSelectedTab row carried the
same gap in a note.
2026-11-09 12:52:53 +00:00
MSedgeMC 5399fce12b test: pin the two packets the advancements screen sends
openedTab carries action 0 and the advancement id, closedScreen action 1 and
no identifier at all - the read side that handleSeenAdvancements uses reads
a close as two bytes.
2026-11-09 12:52:48 +00:00
MSedgeMC 9a6fdc6b65 fix: the advancements screen tells the server which tab it opened and that it closed
ClientAdvancements.setSelectedTab(tab, true) sends
ServerboundSeenAdvancementsPacket.openedTab before it compares the tab, and
AdvancementsScreen.removed sends closedScreen() - action 1, no identifier.
Horizon sent neither: a LAN server's handleSeenAdvancements never heard a
tab, so a guest's selected tab stayed empty and no SelectAdvancementsTab
answer ever came.
2026-11-09 12:52:48 +00:00
Skr1zzzzandClaude Opus 5.5 35bdf494d1 Move spawning item entities, drops and block experience out of game.rs into game/item_entities.rs
Part of #11857. Spawning and dropping item entities, Block.popResource, Player.drop and the experience a block or a furnace gives join the rest of game/item_entities.rs. Methods that were private in game.rs become
pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:51:34 +03:00
Skr1zzzzandClaude Opus 5.5 49ffad5aee Move the weather and sky darkness out of game.rs into game/weather.rs
Part of #11857. Pure move: whether the level can have weather, the rain and
thunder levels, where it rains or snows and the rain height, the weather's
effects each tick, and Level.getSkyDarken with isDarkOutside. Methods that were
private in game.rs become pub(super), the same reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:51:09 +03:00
Dylan FerreyraandClaude Opus 5.5 791468c660 Move the command tree's codecs out of play.rs into play/commands.rs
CommandNode, ARGUMENT_TYPES, ArgumentProperties, the two private
helpers (read_argument_properties, acyclic) and Commands' Codec move
byte for byte into packets/play/commands.rs. play.rs re-exports the
four public items, so every `packets::play::…` path still resolves.

The port map follows: the five rest-007 rows that name
ArgumentProperties::read point at the new file, and five rest-012 rows
map what main's ledger only matched by name, now that play.rs no
longer cites ClientboundCommandsPacket: Entry.write, ArgumentNodeStub.write
and readNode to Commands' Codec, VecDelta.Linear/Stepped.decode to the
MoveEntity arms of Game::handle_packet (checked against the 26.3
bytecode).

Part of #13063.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:48:16 -03:00
Florian ERNSTandCodex 7ab182b08e Fix typed block-state predicate range comparisons
Co-Authored-By: Codex <[email protected]>
2026-11-09 13:44:35 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 3ff3720bc5 File size check: a test module whose braces close before its last line counts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:34:26 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 e8474ed6eb File size check: inline test modules don't count
A normal build doesn't compile `#[cfg(test)] mod name { ... }` modules, so they no longer count toward the
3000-line limit, on the base or now. The failure message names both ways out: a sibling `<file>_tests.rs`, or a
pure-move PR first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:27:09 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 4092f19f56 Docs: what a render pass keeps so HDR works
Upscaling, anti-aliasing, sharpening, frame generation, post effects and
captures each break HDR in their own way when written for SDR alone, mostly
without a test failing: frame identity runs with TAA and frame generation off.
docs/hdr-passes.md says where such a pass goes, what the frame holds, the
rules it keeps and how to check it; AGENTS.md and the docs index point to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:14:43 +01:00
MSedgeMC 7b9719af3d Banner chases stop when the raid ends 2026-11-09 09:40:54 +00:00
MSedgeMC 4f69ceeb88 Begging wolves ignore spectators 2026-11-09 09:31:15 +00:00
MSedgeMC 14f5904ee3 port_map: SheepRenderState.getWoolColor is the layers colour
sheep_wool_rgb is a re-export alias, not a function, so the row was a map
gap. The colour is chosen in render.rs::layers, jeb_ through jeb_wool_rgb.
2026-11-09 09:30:02 +00:00
MSedgeMC 3e0cd059d6 port_map: the sheep colour and glow squid light rows name the code
sheep_wool_rgb is a pub use alias, not a function, so both sheep rows were
map gaps. Point them at the layers function that picks jeb_wool_rgb; the
glow squid row now names glow_squid_block_light, which is implemented.
2026-11-09 09:29:41 +00:00
MSedgeMC 0e811d78e5 port_map: celestial_geometry lives in game/frame_geometry.rs
The note still named game.rs after the frame geometry split. The function
is in game/frame_geometry.rs; game.rs only calls it.
2026-11-09 09:27:46 +00:00
Arthur BlackandClaude Opus 5.5 e960166932 Touch world gestures and the dynamic joystick (Android touch input, part 3b)
- A short tap on the world acts once where it landed (or at the crosshair for aimed items); take_tap() hands it to the game with the item rule read when the finger landed
- A touch held 350 ms without dragging mines or uses the item, and keeps doing so while it turns the camera
- aim, holding and holding_use tell the game where and how this tick's world action goes; latch_use() keeps a hold as use
- pointer() places the touch marker in the tap schemes
- Optional crosshair gestures, attack button steering, and a dynamic joystick that appears where the finger lands inside a configurable strip
- Settings gain crosshair_gestures, attack_button_look, dynamic_joystick and dynamic_area_x/y (serde defaults keep saved files)

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-11-08 22:57:50 -08:00
Nikita Maximilian Grimm a4649dc6a1 Merge hdr-crash-recovery (main with the gate tests) into hdr-output-o2
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/gfx/hdr.rs
2026-11-09 07:21:17 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 b52536c729 Merge hdr-output-owner (main with the gate tests) into hdr-crash-recovery
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:20:44 +01:00
voidsip 9368197061 Format relocated native-mod tests with their regression 2026-11-08 23:43:37 -06:00
voidsip a0a4a905ad Merge commit 'b25a050be818408360d7cf97956350212dda0686' into fix/10914-native-content-assets
# Conflicts:
#	launcher/src/native_mods.rs
2026-11-08 23:42:26 -06:00
voidsip 034fd1593e Keep native content source and startup preparation in focused modules 2026-11-08 23:30:33 -06:00
voidsip fb845f8486 Merge commit '3690aa6119b9235be7e3b79680c156da8979479b' into fix/10914-native-content-assets 2026-11-08 22:46:55 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 7ed014c216 HDR output: mark the start before building it, and retry on a new surface
Two fixes to the checked output switch.

The crash-recovery marker is written again before HDR output's pipelines are
built, as it was before the switch moved into output_apply. A driver that
crashes while they are built now leaves the marker, so the next start holds
HDR back instead of trying the same crashing start again.

A new window surface now always makes the settings apply again. Before, it did
so only when the surface offered another encoding. So an HDR output that had
failed and fallen back was not tried again on a new surface with the same
offer until something else changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:42:20 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 7cbced85fa HDR output: follow a switch between HDR10 and scRGB while HDR stays on
When the display or compositor stops offering HDR10 and offers scRGB (or the
other way round) while HDR is on, the game now switches the swapchain, its HDR
metadata and the HDR output pass to the new encoding. Before, it compared only
whether HDR was on, kept presenting the old encoding, and read the surface's
offer again only when the display's luminance or HDR report changed.

The renderer now reads what the surface offers at every due display poll and
after a new surface, even when the display reports the same. Each settings or
display change resolves one candidate output. A change of nits, UI brightness
or report alone updates the values without building anything. A new encoding
is built and the swapchain configured for it before the snapshot that the UI,
F3 and a pack's Frame read says so. If that fails, the output that was there
stays where it still presents, else SDR. The failed output is not tried again
each frame, only once the request, the capabilities or the surface change. The
mode setting is never changed. The switched HDR output keeps the player's
tuning (HDR Lighting, UI brightness, masks, tiers, calibration, debug view).

HDR off presents and draws exactly as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:04:09 +01:00
voidsip 3690aa6119 Compare PR file growth against the tested merge base 2026-11-08 21:20:51 -06:00
Nikita Maximilian GrimmandClaude Opus 5.5 26d3eaaa9b HDR crash recovery: keep the big renderer and settings files from growing
Nothing changes for the player. The marker, the driver it names and the
once-per-start warning move from gfx.rs into gfx/hdr_start.rs, and Undo's
rule for the hold becomes Graphics::undone_to, so gfx.rs and graphics_ui.rs
end no longer than on the base (check_file_size, docs/layers.md#file-size).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:11:42 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 20bbcc3d76 HDR crash recovery: pin that the HDR page says the hold as it opens
The graphics screen now reads the output of the renderer the options are
drawn for on every draw, kept outside the screen that opening resets. A
test pins that a window holding HDR output back shows it on the HDR page
as soon as the screen opens, with no setting applied: Calibrate blocked by
the hold, where the same window without it calibrates.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:08:03 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 029c3e33d8 HDR crash recovery: a clean exit before HDR's first frame holds nothing
The marker that HDR output is starting is removed once HDR output presents
its first frame. A game closed cleanly before that, say while its window
was minimized, left it behind, and the next start held HDR output back on
a driver that never failed.

A clean exit now removes the window renderer's own marker. An exit after
an error, and a crash, still leave it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:07:40 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 a07652630e HDR crash recovery: a system update tries HDR again too
The hold on a driver HDR output failed to start on named the adapter and
its driver as wgpu reports them. Metal names no driver, so on macOS, where
the graphics driver updates with the system, a hold was never released.

The driver's fingerprint now ends with the system's version, as crash
reports name it (Windows' build, macOS's version, Linux's distribution and
kernel), read once a run. A system update releases the hold everywhere,
which costs one more try of HDR output.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:07:40 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 61a5dcc0db HDR crash recovery: Undo never brings the hold back
Undo on the graphics screen put back the whole of Horizon's graphics
settings, the hold on a driver HDR output failed to start on included. So
a held Auto changed to On, where HDR then worked on this driver, and
undone, came back held.

Undo now keeps the hold as it is, and drops it when it changes the HDR
mode, as any change of the mode does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:07:40 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 d40f83e06c HDR crash recovery: hold HDR back on the failing driver, keep the mode
A start that HDR output took down used to save HDR Off for good: one
crash while HDR started turned a player's HDR off forever, silently, even
after a driver update fixed it. With Auto the default (step 5a), that would
reach every player whose driver ever crashed once.

The marker now names the graphics driver it was written for: the backend,
the adapter's vendor and device IDs and name, and the driver's name and
version. A start that finds it keeps the mode (Auto or On) and saves that
driver as hdrFailedStart. A window on that driver starts SDR and stays SDR
while the key matches; the HDR page's HDR row says why, in one line, and
greys the brightness rows with the same line. Choosing any mode on the page
drops the key and tries HDR again. A window on another driver (an update,
another GPU) drops it too. The hold is logged once per start, with how to
clear it. A marker from before markers named a driver holds on the first
driver seen. Only a window writes the marker; offscreen renderers never
hold HDR back.

The hold is a capability of the window's output in the renderer-owned
resolver (OutputCapabilities::held), not a new global.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:07:12 +01:00
Horizon agentandClaude Opus 5.5 73b0a91c13 docs/layers.md: the rule's wording, replays split, the licence note
- duckybertduck's placement rule in their words (tooling excepted; the
  rest can be built on what's below), plus: a feature often splits, the
  part others build on is platform, the finished feature Horizon.
- Replays: recording and playback (.mcpr) are platform; the Studio
  (camera paths, timeline) is Horizon or a native mod.
- Licence: if this repository goes GPL, the linked Horizon layer must be
  GPL-compatible (no closed native code); WASM mods could stay closed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:10:39 +01:00
Horizon agentandClaude Opus 5.5 e547357ae7 docs/layers.md: controllers, splitscreen, Replay Studio, UI pages; the placement rule
Merges origin/main (keeps the rewritten page over #12993's edit of the
first version, which it already covers) and, from #contributors:
- duckybertduck's placement rule opens "Where a new feature goes".
- The compositor is engine (part of how the renderer presents a frame,
  idle on vanilla's one-view path), as Martipops and duckybertduck argued.
- Controllers, splitscreen, Replay Studio, the HUD widget framework and a
  UI page API are platform, on engine hooks named in a new section
  (actions and held state today; move vector, look delta and menu focus
  as an input source; screens). PvP presets and widgets stay Horizon.
- Plan states updated (#12983, #12990, #13015, #12993, #13002, #13058
  merged; #13060, #13094), M6 lists the input-source and page work.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:08:24 +01:00
Horizon agentandClaude Opus 5.5 b7b5cda0ad docs/layers.md: linked Horizon crates, portable crates, HDR in M6
From NikitaMGrimm's review on #13012:
- The Horizon layer's in-game parts are crates linked into the official
  game build in CI through platform hooks, never compiled by players. The
  official executable is a thin crate in the Horizon repository; this
  repository keeps a plain one.
- The portable crates (core, world, protocol, items, command, client-core,
  assets) take no desktop library; ports beyond the desktop build on them.
  #12997's text folded in; #13002 adds the check. Step P: gameplay into
  them over time.
- HDR's part of M6 is planned with NikitaMGrimm after HDR step 5.
- Credits name NikitaMGrimm (DuckyBertDuck on Discord).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:45:36 +01:00
Horizon agent 53776cf792 Merge remote-tracking branch 'origin/main' into layers/plan 2026-11-09 01:45:30 +01:00
voidsip 7c2f9dc7f4 Merge commit 'f858ee752f1c0e76b6d200ec846645532536aded' into fix/10914-native-content-assets 2026-11-08 18:38:08 -06:00
voidsip 0bd6a222a4 Merge main and preserve combined native content loading 2026-11-08 18:38:07 -06:00
Horizon agentandClaude Opus 5.5 7da0bdf46d docs/layers.md: the file-size check is #13015
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:58:02 +01:00
Horizon agentandClaude Opus 5.5 991a9b5451 docs/layers.md: the target (Horizon layer in its own repo) and the step plan
Ludovic, 2026-11-09: this repository keeps the engine and the platform,
a complete vanilla-like game; the Horizon layer (its extras, cosmetics,
shop, telemetry, sponsors, the launcher) moves to its own repository
after the split and ships with every official launcher download. So
Horizon uses only the platform's public API. Adds the seam, the
official download paths, the stage's asks (file-size rule, one address
list, sponsors in Horizon, a platform exposing what vanilla uses,
extras as native mods), the 47 exceptions and the M1-M6 plan with its
review gate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 00:54:03 +01:00
TeiwazikandClaude Sonnet 5.5 62b593cb3d world_post: upscaler backend seam, TAA as the only backend
UpscaleFrame carries what a temporal upscaler needs (jittered colour,
depth, sizes, jitter in clip space and pixels, camera reprojection,
reset); UpscalerBackend turns it into the output picture. TAA's resolve
pass moves behind it unchanged.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-08 23:01:59 +01:00
gigamans442andCodex gpt-6.1-sol 2e88edc841 fix(shaders): preserve signed vertex and instance IDs
Convert GLSL ID builtins before expression typing; retain explicit unsigned conversions and validate all supported draw paths with original CPU controls.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-08 09:27:29 -05:00
MSedgeMC d12994e93c Merge remote-tracking branch 'upstream/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-08 13:59:19 +00:00
Horizon agent 83d8f3bf05 Merge remote-tracking branch 'origin/main' into m10243
# Conflicts:
#	crates/horizon-client/src/projectiles.rs
2026-11-08 02:05:48 +01:00
rison1337andCodex 06f7c5d593 Merge main and reuse its consistent dependency locks
Main restored the socket2 0.5 package after our temporary 0.6 workaround.
The synthetic PR merge retained an unused 0.5 entry with a 0.6 manifest,
so Cargo rejected both lockfiles under --locked. Reuse current main's
protocol manifest and both complete locks; the workaround is no longer
needed. PackedStorage's Clippy fix is already present on main as well.

Preserve the orb optimization and branch history with a normal merge.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-08 03:17:37 +05:00
Horizon agentandClaude Opus 5.5 2325ba52d4 Launcher: native-build --prebuilt-out, the registry CI's side of prebuilt builds (#11859)
`horizon-launcher native-build --prebuilt-out DIR --version <Nightly> FOLDER` builds every
.hnmod in FOLDER through the player's own pipeline (pins, hook points, left-out mods), but
needs no prepared game files and installs nothing: it writes the program, its
native-mods.json and request.txt into DIR. request.txt is the build's request
(horizon_release_sign::native_build): target, the base's commit, each mod's id, version and
file SHA-256, so the registry's workflow and the launcher name a build alike. A set with a
post-build step is refused: the step runs on the player's game files.

native_mods: pipeline takes an export folder; the .hnmod unpacking is shared with
installed_mods; build_target names the Rust target of a build.

Measured on the desktop: zoom + minimap on Nightly 6560641, release profile, -j 4: 8 min,
request key equal to the one horizon-mods' plan computes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:53:51 +01:00
Horizon agentandClaude Opus 5.5 79ee436bdf runner-gc: run every 30 minutes, not 6 hours
It skips busy runners, so a 6-hour tick rarely found the parity runners idle:
their probe builds reached 31 + 22 GB and the Mac mini fell to 4.6 GB free
(2026-11-07). The live plist uses StartInterval 1800.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 17:46:55 +01:00
Ludovic Marie 6f3a8699be Merge origin/main 2026-11-07 17:46:54 +01:00
Horizon agentandClaude Opus 5.5 6a2f968125 runner-gc: drop per-run harness scratch folders after 2 hours
917 mkdtemp folders (<harness>-<8 chars>) under two runners' .local filled the
Mac mini's disk on 2026-11-07; nothing reuses them after the run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 15:21:55 +01:00
MSedgeMC 1925a4fa6f Merge remote-tracking branch 'upstream/main' into HEAD
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-11-07 13:10:23 +00:00
rison1337andCodex eb83c35515 Fix locked builds and the storage lint after merging main
Main's keepalive change requires socket2 0.5, but its lockfile references
that missing package while only socket2 0.6.5 remains. This prevents
locked builds before compilation after merging main.

Use the socket2 0.6 dependency already required by horizon-friends and
record that existing package for horizon-protocol in both game and launcher
lockfiles. Keep a single version
and retain the existing TCP keepalive behavior, checked by its socket test.

Use is_multiple_of for PackedStorage' nonzero per-word stride check, as
required by Clippy. The validated bit widths keep per_word between 2 and
64, so the predicate has the same behavior.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-07 17:09:52 +05:00
rison1337andCodex 98c7e923c3 Merge main after the flame TAA change landed
Reuse the accepted shared jitter-frustum helpers from main so this PR
contains only the independent experience-orb preparation optimization.
Preserve the existing branch history and maintainer changes.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-07 16:42:02 +05:00
Horizon agent 096a0b33ac Merge remote-tracking branch 'origin/main' into n/10088
# Conflicts:
#	crates/horizon-client/src/game.rs
#	tools/port_map/client2-009.tsv
2026-11-07 06:00:16 +01:00
Horizon agentandClaude Opus 5.5 056bb5d10a port_map: the deferred subtitles draw before the screen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:41:27 +01:00
Horizon agentandClaude Opus 5.5 5e2520c6d2 Piston projectiles: leave the remote tick order as main has it
Main already runs tick_remote_entities before the client block entities
and piston pushes (Minecraft.tick: tickEntities, then tickBlockEntities),
so the reorder only moved the pistons ahead of the other block-entity
animations and left two comments on the wrong lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:18:01 +01:00
Horizon agentandClaude Opus 5.5 75d2a607e2 Arrows and tridents: release from the piston move only, as AbstractArrow does
Follow-up on top of the rework (the order and the release from the piston
move were right). Three things still differed from 26.3:
- AbstractArrow.tick stays in its in-ground branch the tick startFalling
  runs (inGroundTime++, applyEffectsFromBlocks); the arrow flew in that
  same tick. arrows.rs is back to main's branch, with the shared
  start_falling_velocity.
- Entity.move(PISTON) never touches xo: begin_tick and
  piston_pushed_this_tick kept the pre-push position across two ticks.
  The old position is taken at the tick's start again.
- On a slime block, moveCollidedEntities sets the velocity and then moves
  the entity, and that move is what takes a stuck arrow out
  (startFalling's random * 0.2 damping). The bounce no longer un-grounds
  it. The server trident's release_if_unblocked (no lastState check, no
  damping) is gone; the piston release covers the piston case.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 02:04:40 +01:00
Horizon agentandClaude Opus 5.5 e1025cef1a Subtitles: an in-game screen's deferred subtitles draw before it
Screen.extractBackground runs Hud.extractDeferredSubtitles, so they sit under
the screen's slots and tooltips; drawing them after the screen put them over
both. F1 still leaves them with an in-game screen open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:58:23 +01:00
Horizon agentandClaude Opus 5.5 0a8dbbe152 Merge origin/main into #10088
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-07 01:58:17 +01:00
Horizon agent 9757924d07 Merge main into #10243 2026-11-07 01:53:03 +01:00
rison1337andCodex fe62d6c7db Merge main while retaining the orb-only optimization
Retain both the current depth-culling regression and the guarded-frustum
viewport-edge regression. Keep the shared billboard-frustum naming so
the separately submitted flame change can merge independently.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-07 04:04:15 +05:00
rison1337andCodex 4c764800c9 Split flame TAA culling out of the experience-orb PR
Keep this PR scoped to offscreen experience-orb preparation, including
its conservative TAA frustum. Restore the existing flame path and move
its independent optimization and measurements to a separate PR.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-07 04:02:52 +05:00
iwanethandCopilot 5894135cff Fix projectile piston compile errors
Co-authored-by: Copilot <[email protected]>
2026-11-06 21:11:32 +01:00
iwanethandCopilot 4a87dcdcde Allow gameplay-only piston helpers in client lib
Co-authored-by: Copilot <[email protected]>
2026-11-06 20:38:27 +01:00
iwanethandCopilot 565bc0c818 Move arrow fall helper to shared module
Co-authored-by: Copilot <[email protected]>
2026-11-06 20:26:26 +01:00
iwanethandCopilot acd93b5a0b Fix shared projectile helper path
Co-authored-by: Copilot <[email protected]>
2026-11-06 19:54:28 +01:00
iwanethandCopilot 38a3760258 Fix piston projectile tick parity
Co-authored-by: Copilot <[email protected]>
2026-11-06 18:56:31 +01:00
rison1337andCodex GPT-6 f4b7d61b80 Rerun CI after standalone rustc fixture timeouts
The verify runner exceeded the 60-second compiler timeout in the
native-mouse and split-window input fixtures. Both original tests pass
locally, as does the complete Python test suite. No files are changed.
A direct Actions rerun requires repository administrator permissions.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 19:52:34 +05:00
voidsip 58b69f8f71 Load combined native mod content with its exact build 2026-11-06 06:12:20 -06:00
rison1337andCodex GPT-6 f90bfe8f5b Share the jitter-guarded billboard frustum with entity flames
Keep offscreen flame preparation culling active under TAA using the same
input-pixel guard as orb sprites. Preserve ordinary model culling and
shader-pack fallbacks. Cover AABB acceptance at jittered viewport edges
and record separate flame FPS and image comparisons, including controls.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 16:12:44 +05:00
rison1337andCodex GPT-6 d227d13690 Merge main with completed UUID lookup build fixes
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 15:34:37 +05:00
rison1337andCodex GPT-6 e310be62d4 Cull experience-orb sprites behind the camera, including TAA
Skip invisible orb light/color/quad preparation with conservative bounds.
Cover TAA projection jitter at the configured input resolution and retain
the existing shader-pack path. Include rendering measurements and checks.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-06 15:24:16 +05:00
iwanethandCodex 909d703b15 Merge upstream main and preserve projectile piston fixes
Co-Authored-By: Codex <[email protected]>
2026-11-05 21:17:35 +01:00
iwanethandCodex 3fb08ac12f Keep projectile animation out of piston parity PR
Co-Authored-By: Codex <[email protected]>
2026-11-05 20:01:55 +01:00
MSedgeMC 887e94dff3 port_map: Hud.extractDeferredSubtitles is hud_layers' deferred draw
The row was absent; map-check: 150 rows, no problems.
2026-11-05 17:20:32 +00:00
MSedgeMC bb30c0b2cf test: the subtitles follow the HUD unless a screen keeps them
The four combinations of the HUD being shown and an in-game screen being
open, plus the screens that answer Screen.isInGameUi: the container screens
and the block editors yes, the pause menu, the chat and the death screen no,
and a book or a sign editor yes over Screen::Playing.
2026-11-05 17:20:24 +00:00
MSedgeMC ce0ce48661 fix: the subtitles draw over an in-game screen, and survive F1
Hud.extractRenderState asks Screen.isInGameUi twice. With no such screen the
subtitles draw in the HUD's own pass; with one they are deferred
(extractSubtitleOverlay(true) stores a Runnable that extractDeferredSubtitles
runs after the screen), and when F1 hides the HUD they are the one element
that still draws, as long as such a screen is open.

Screen.isInGameUi is false everywhere except AbstractContainerScreen and the
block, sign and book editors, so the rule covers the inventory, the creative
menu, the command block, structure block, jigsaw and test block editors, a
book the player reads and a sign being edited, and nothing else: not the pause
menu, the chat or the death screen.

Horizon drew the subtitles only inside the HUD's own pass, so they sat under
any open screen and vanished under F1. Now an in-game screen defers them, the
deferred draw lands after the screen and its tooltips and before the toasts,
and F1 alone still hides them.
2026-11-05 17:20:16 +00:00
iwanethandCopilot 41a885884c Merge latest origin/main into piston projectile fix
Co-authored-by: Copilot <[email protected]>
2026-11-01 23:39:54 +01:00
iwanethandCopilot 274ba45708 Fix projectile merge CI failures
Co-authored-by: Copilot <[email protected]>
2026-11-01 23:17:02 +01:00
IwaneTH 6f8a26a509 Merge branch 'main' into fix/piston-projectile-push 2026-11-01 22:55:52 +01:00
iwanethandCopilot b4ac2ce731 Merge origin/main into fix/piston-projectile-push
Co-authored-by: Copilot <[email protected]>
2026-11-01 22:30:00 +01:00
iwanethandCopilot d83f9d0504 Fix piston projectile flight and slime bounce
Align client piston updates with the server and release embedded arrows and tridents on slime bounce. Match vanilla projectile rotation interpolation.

Co-authored-by: Copilot <[email protected]>
2026-11-01 18:29:29 +01:00
iwanethandCopilot 64f566311b Smooth projectile flight orientation
Update remote arrows and tridents to ease their flight orientation like vanilla, and interpolate their angles during rendering so direction changes from piston pushes and collision updates are animated smoothly.

Co-authored-by: Copilot <[email protected]>
2026-10-31 23:30:53 +01:00
iwanethandCopilot f527f4af5d Fix tridents remaining suspended after block removal
Release stuck tridents when their embedded collision clears so gravity can resume, including after a piston push. Preserve projectile interpolation and synchronize pushed tridents.

Co-authored-by: Copilot <[email protected]>
2026-10-31 22:56:35 +01:00
iwanethandCopilot e011920b66 Push projectiles with moving pistons
Co-authored-by: Copilot <[email protected]>
2026-10-31 17:03:58 +01:00
328 changed files with 12100 additions and 3486 deletions

No files matched your search

+5 -5
View File
@@ -217,17 +217,17 @@ jobs:
$clang = Get-Command clang -ErrorAction Stop
Write-Output "Using $($clang.Source)"
& $clang.Source --version
- name: Build the game for native Windows ARM64
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Build the game for native Windows ARM64 (the official game, as game-build.yml ships it)
run: cargo build --release --locked --target aarch64-pc-windows-msvc -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: Check the game's PE machine
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
run: python tools/check_pe_arch.py arm64 target/aarch64-pc-windows-msvc/release/horizon-official.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
- name: Check game DLL imports
run: python tools/check_imports.py target/aarch64-pc-windows-msvc/release/horizon-client.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
run: python tools/check_imports.py target/aarch64-pc-windows-msvc/release/horizon-official.exe target/aarch64-pc-windows-msvc/release/horizon-ctl.exe target/aarch64-pc-windows-msvc/release/horizon-prepare.exe target/aarch64-pc-windows-msvc/release/horizon-server.exe
- name: Start every game executable
shell: pwsh
run: |
$ErrorActionPreference = 'Stop'
$client = 'target/aarch64-pc-windows-msvc/release/horizon-client.exe'
$client = 'target/aarch64-pc-windows-msvc/release/horizon-official.exe'
$output = & $client --help 2>&1
if ($LASTEXITCODE -ne 0 -or ($output -join "`n") -notmatch '--resources') { throw 'horizon-client --help failed' }
foreach ($binary in @('horizon-ctl.exe', 'horizon-prepare.exe', 'horizon-server.exe')) {
+7 -2
View File
@@ -168,9 +168,14 @@ jobs:
- name: ALSA and udev headers
if: runner.os == 'Linux' && !matrix.musl
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends libasound2-dev libudev-dev
- name: Build the client, horizon-ctl, horizon-prepare and horizon-server
- name: Build the official game, horizon-ctl, horizon-prepare and horizon-server
if: ${{ !matrix.musl }}
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-client -p horizon-client-lib --bin horizon-client --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
run: cargo build --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} -p horizon-official --bin horizon-official -p horizon-client-lib --bin horizon-ctl -p horizon-prepare --bin horizon-prepare -p horizon-server --bin horizon-server
- name: The official game is the game players get, under its name
# horizon-official: horizon-client's game with Horizon's own parts plugged in (docs/layers.md, "How a
# Horizon crate plugs in"). Everything below, the launcher and players know it as horizon-client.
if: ${{ !matrix.musl }}
run: cp -f "$BIN/horizon-official${{ matrix.exe }}" "$BIN/horizon-client${{ matrix.exe }}"
- name: Every program the release folder takes was built
if: ${{ !matrix.musl }}
run: |
+3 -3
View File
@@ -181,7 +181,7 @@ jobs:
musl=(); if [[ -f dist/horizon-linux-musl-x86_64.tar.gz ]]; then musl=(dist/horizon-linux-musl-x86_64.tar.gz)
else echo "::warning::horizon-linux-musl-x86_64.tar.gz is missing: SHA256SUMS goes out without it"; fi
bash tools/ci/sign_release.sh nightly dist/horizon-linux-x86_64.tar.gz dist/horizon-windows-x64.zip \
"${arm64[@]}" "${musl[@]}" \
${arm64[@]+"${arm64[@]}"} ${musl[@]+"${musl[@]}"} \
dist/horizon-macos-arm64.zip dist/horizon-macos-x64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/horizon-source.tar.gz
- uses: actions/upload-artifact@v7
@@ -267,9 +267,9 @@ jobs:
gh release create nightly --prerelease --latest=false --target "$SHA" \
--title "Nightly $DATE · ${SHA:0:7}" --notes-file dist/nightly-notes.md \
"dist/horizon-linux-x86_64.tar.gz#Game files the launcher installs (Nightly): Linux (horizon-linux-x86_64.tar.gz)" \
"${musl[@]}" \
${musl[@]+"${musl[@]}"} \
"dist/horizon-windows-x64.zip#Game files the launcher installs (Nightly): Windows (horizon-windows-x64.zip)" \
"${arm64[@]}" \
${arm64[@]+"${arm64[@]}"} \
"dist/horizon-macos-arm64.zip#Game files the launcher installs (Nightly): macOS on Apple Silicon (horizon-macos-arm64.zip)" \
"dist/horizon-macos-x64.zip#Game files the launcher installs (Nightly): macOS on Intel (horizon-macos-x64.zip)" \
"dist/horizon-server-linux-x86_64.tar.gz#Nightly dedicated server for Linux (horizon-server-linux-x86_64.tar.gz)" \
+9 -2
View File
@@ -25,8 +25,10 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
first line `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, commits
`Co-Authored-By: Claude <model> <[email protected]>`, last line
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- **Layers** ([docs/layers.md](docs/layers.md)): engine < platform < Horizon; a crate depends only on its own layer or a
lower one (`tools/check_layers.py`, crate layers in `tools/layers.json`). New Horizon code goes behind `horizon-extras`.
- **Layers** ([docs/layers.md](docs/layers.md)): engine uses engine; platform uses engine and platform; Horizon (its extras,
cosmetics, shop, telemetry, launcher) uses platform and Horizon only, never the engine: it moves to its own repository
later. `tools/check_layers.py` checks it. New Horizon code goes behind `horizon-extras`; built-in addresses go in
`horizon-addresses`.
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
they would change the vanilla look.
@@ -39,7 +41,11 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
issue is taken, and so is one labelled `in progress` (claimed on Discord): pick another. `/unclaim` when you stop;
a claim with no open PR linking it is released after 5 days. The issues free to claim, without reading threads:
`gh issue list --search 'is:open no:assignee -label:"in progress" -label:tracker -label:proposal -label:"not claimable" -linked:pr'`
- **Machine translations stay unreviewed.** `tools/lang_review/` records only text a person who speaks the language
wrote or checked; never `lang_coverage.py --mark-reviewed` machine text ([docs/translations.md](docs/translations.md)).
- **Controllers reach what registers.** Draw controls with a registering helper (`ui::button`, `settings_shell::foot_button`); `graphics_style::button` and `screen_draw::button` only paint, and registering one part of a screen ends its crosshair, so register all of it.
- **Render passes keep HDR's light.** A pass that reads, filters or redraws the world's picture (upscaling,
anti-aliasing, sharpening, frame generation, post effects, captures) follows [docs/hdr-passes.md](docs/hdr-passes.md).
## Crates
@@ -75,6 +81,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
| `horizon-system` | The machine the game runs on (OS, CPU, memory, drive, power, GPU maker and memory), for the crash report, the renderer's GPU info and telemetry |
| `horizon-platform` | The platform's public API: what the Horizon layer may use of the engine ([docs/layers.md](docs/layers.md)); re-exports `horizon-system` today |
| `horizon-hooks`, `horizon-game`, `horizon-official`, `horizon-heartbeat` | The layer seam: the engine's hooks for crates above it, the game an executable starts, the official executable (shipped as `horizon-client`) that registers Horizon's in-game parts, the first of them ([docs/layers.md](docs/layers.md#how-a-horizon-crate-plugs-in)) |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, repo tasks |
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
+15 -4
View File
@@ -45,9 +45,13 @@ and compares their outputs.
[The parity workflow](docs/parity-workflow.md) shows how harnesses run; copy a nearby one.
- **Horizon's own features** (the launcher, the new menus) follow [the design system](docs/design-system.md) and are
off by default wherever they would change the vanilla look.
- **Three layers: engine, platform, Horizon.** Vanilla code never depends on the platform (mods, shader packs,
Horizon-only settings) or on Horizon (cosmetics, the shop, official servers, telemetry), and the platform never
depends on Horizon. [docs/layers.md](docs/layers.md) says where a new feature goes; CI checks the dependencies.
- **Three layers: engine, platform, Horizon.**
- Vanilla code never depends on the platform (mods, shader packs, HDR, the settings system) or on Horizon
(its extras, cosmetics, the shop, official servers, telemetry).
- The platform never depends on Horizon.
- Horizon uses only the platform, never the engine: it will live in its own repository.
[docs/layers.md](docs/layers.md) says where a new feature goes; CI checks the dependencies.
- **Everything players run is Rust.** Python and Java are fine for dev tools and harness drivers in `tools/`, never
for player-facing code.
- **No game files in the repo.** Never commit game files, decompiled source or prepared resources (`.local/`):
@@ -93,7 +97,8 @@ names its sources: the sections of that specification, the documentation pages a
that downloads NVIDIA's files itself, on the player's machine. No NVIDIA binaries, headers or SDK files in the
repository.
- **The JVM bridge** (running Java mods) is only ever a separate, optional download, never installed or on by
default. The game may offer to turn it on when it finds a Java mod; the player decides.
default. The game may offer to turn it on when it finds a Java mod; the player decides. It is promoted out of beta
only after MSPT and RAM benchmarks against the Java client with the same mods ([#13914](https://github.com/horizon-rust/horizon/issues/13914)).
## Before asking for review
@@ -113,6 +118,12 @@ including examples. The dependency scope includes optional, platform, build and
library, binary, integration and doc tests without building unrelated example executables. Shared Cargo
configuration and toolchain changes select the whole workspace; CI also keeps its workspace-wide checks.
### Translations
Most of Horizon's translations were written by machine and stay marked unreviewed until someone who speaks the
language checks them. To report a wrong string or review your language, see [docs/translations.md](docs/translations.md)
and #translations on Horizon's Discord. Never mark machine-written text as reviewed.
## Claim before you start
Work is tracked in GitHub issues, and an issue that is assigned is taken (so is one with the `in progress` label:
Generated
+42 -3
View File
@@ -1450,6 +1450,7 @@ dependencies = [
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mod-api-gen",
"horizon-mods",
@@ -1500,7 +1501,6 @@ version = "0.1.0"
dependencies = [
"arboard",
"horizon-client-core",
"horizon-dualsense",
"horizon-theme",
"png",
"winit",
@@ -1529,9 +1529,9 @@ dependencies = [
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mcp",
"horizon-mods",
@@ -1588,11 +1588,11 @@ dependencies = [
"horizon-cosmetics",
"horizon-hdr",
"horizon-items",
"horizon-mod-api-gen",
"horizon-narrator",
"horizon-optifine",
"horizon-polytone",
"horizon-protocol",
"horizon-render-pipeline",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
@@ -1676,6 +1676,15 @@ dependencies = [
"spake2",
]
[[package]]
name = "horizon-game"
version = "0.1.0"
dependencies = [
"horizon-client",
"horizon-mods",
"horizon-threads",
]
[[package]]
name = "horizon-hdr"
version = "0.1.0"
@@ -1687,6 +1696,19 @@ dependencies = [
"wgpu",
]
[[package]]
name = "horizon-heartbeat"
version = "0.1.0"
dependencies = [
"horizon-platform",
"horizon-telemetry",
"log",
]
[[package]]
name = "horizon-hooks"
version = "0.1.0"
[[package]]
name = "horizon-items"
version = "0.1.0"
@@ -1767,6 +1789,17 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-game",
"horizon-heartbeat",
"horizon-platform",
"log",
"winresource",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
@@ -1781,6 +1814,7 @@ dependencies = [
name = "horizon-platform"
version = "0.1.0"
dependencies = [
"horizon-hooks",
"horizon-system",
]
@@ -1852,6 +1886,10 @@ dependencies = [
"ring",
]
[[package]]
name = "horizon-render-pipeline"
version = "0.1.0"
[[package]]
name = "horizon-server"
version = "0.1.0"
@@ -1916,6 +1954,7 @@ version = "0.1.0"
dependencies = [
"half",
"horizon-core",
"horizon-render-pipeline",
"naga",
"png",
"zip",
+5
View File
@@ -30,6 +30,10 @@ members = [
"crates/horizon-telemetry",
"crates/horizon-system",
"crates/horizon-platform",
"crates/horizon-hooks",
"crates/horizon-game",
"crates/horizon-official",
"crates/horizon-heartbeat",
"crates/horizon-bench",
"crates/horizon-friends",
"crates/horizon-relay",
@@ -39,6 +43,7 @@ members = [
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-render-pipeline",
"crates/horizon-optifine",
"crates/horizon-polytone",
"crates/horizon-shader-api",
+3 -2
View File
@@ -129,8 +129,9 @@ ports to WGSL of the optical flow and frame interpolation shaders of the
[AMD FidelityFX SDK](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK) 1.1.4
(`sdk/include/FidelityFX/gpu/{opticalflow,frameinterpolation}`, at commit
[c6efa6bf7f20](https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK/tree/c6efa6bf7f2027b3ec94f28578bb5965eabb9e55)),
and `optical_flow.rs` follows the SDK's `sdk/src/components/opticalflow/ffx_opticalflow.cpp`
(its passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
and `optical_flow.rs` and `interpolation.rs` follow the SDK's
`sdk/src/components/{opticalflow/ffx_opticalflow.cpp,frameinterpolation/ffx_frameinterpolation.cpp}`
(their passes, their order and their dispatch sizes). Copyright (C) 2024 Advanced Micro Devices,
Inc., under the MIT license, whose text heads each file. AMD and FidelityFX are AMD's
trademarks; Horizon is not affiliated with AMD.
+1
View File
@@ -90,6 +90,7 @@ impl Report {
/// The kind a file's extension says.
pub fn kind_of(path: &Path) -> Option<Kind> {
match path.extension()?.to_str()? {
"hmod" if horizon_mods::index::native::holds(path) => Some(Kind::NativeMod),
"hmod" => Some(Kind::Mod),
"hnmod" => Some(Kind::NativeMod),
"hpack" => Some(Kind::Modpack),
+7
View File
@@ -377,6 +377,13 @@ mod tests {
zip.finish().unwrap();
let report = check::check(&hnmod, check::kind_of(&hnmod).unwrap(), None, None).unwrap();
assert!(report.text().contains("machine_mod"), "{}", report.text());
let combined = dir.join("machine_mod.hmod");
std::fs::copy(&hnmod, &combined).unwrap();
assert_eq!(
check::kind_of(&combined),
Some(horizon_mods::index::Kind::NativeMod)
);
assert!(check::check(&combined, check::kind_of(&combined).unwrap(), None, None).is_ok());
// A pin that is not a commit is refused; with none, the folder builds on the
// instance's Horizon.
let bad = [
+1 -3
View File
@@ -13,11 +13,9 @@ winit = "0.30.13"
horizon-theme = { path = "../horizon-theme" }
png = "0.18"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
# The DualSense's trigger effects (the `controllers` feature).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
[features]
controllers = ["dep:horizon-dualsense"]
controllers = []
[lints]
workspace = true
+2 -109
View File
@@ -1523,9 +1523,8 @@ impl Rumble {
}
}
/// What a trigger feels like on a DualSense, from what its action is doing (docs/controllers.md
/// "Adaptive triggers" for the choices). Only the pad's official modes: Nielk1's notes warn the
/// others may leave the firmware.
/// What a trigger feels like, from what its action is doing (docs/controllers.md "Adaptive
/// triggers"); the controller code turns it into a DualSense trigger effect.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TriggerFeel {
Free,
@@ -1544,66 +1543,6 @@ pub enum TriggerFeel {
/// Mining a block of this hardness: a buzz, stronger and slower on harder blocks.
Texture(f32),
}
#[cfg(feature = "controllers")]
impl TriggerFeel {
/// The effect, for a trigger that presses its action at `threshold` (0..1) of its travel.
pub fn effect(self, threshold: f32) -> horizon_dualsense::TriggerEffect {
use horizon_dualsense::trigger;
// The zone (of ten along the travel) where the action presses: a catch gives way there,
// so the click and the shot coincide.
let press = ((threshold.clamp(0.0, 1.0) * 10.0) as u8).clamp(3, 8);
match self {
TriggerFeel::Free => horizon_dualsense::TRIGGER_OFF,
// From the third zone, so the press itself stays light; at most 6 of 8, as the
// draw is held.
TriggerFeel::Draw(charge) => {
trigger::feedback(2, 1 + (5.0 * charge.clamp(0.0, 1.0)).round() as u8)
}
TriggerFeel::Click => trigger::weapon((press - 2).max(2), press, 8),
TriggerFeel::Boost => trigger::weapon((press - 2).max(2), press, 5),
TriggerFeel::Brace => trigger::feedback(2, 3),
TriggerFeel::Locked => trigger::feedback(1, 8),
TriggerFeel::Texture(hardness) if hardness > 0.0 => {
// Dirt 2, stone 3, deepslate 4, obsidian 5 of 8.
let amplitude = (1.0 + 1.6 * hardness.sqrt()).round().clamp(1.0, 5.0);
// Sand and dirt a fine 40 Hz grit; stone 30; wood, deepslate and ores 20;
// obsidian a 10 Hz chisel.
let hertz = match hardness {
h if h < 1.0 => 40,
h if h < 2.0 => 30,
h if h < 5.0 => 20,
_ => 10,
};
trigger::vibration(1, amplitude as u8, hertz)
}
TriggerFeel::Texture(_) => horizon_dualsense::TRIGGER_OFF,
}
}
}
#[cfg(feature = "controllers")]
impl PadSettings {
/// The left and right triggers' effects: each feels the action bound to it, `key.use` or
/// `key.attack`.
pub fn trigger_effects(
&self,
using: TriggerFeel,
attacking: TriggerFeel,
) -> [horizon_dualsense::TriggerEffect; 2] {
[Button::LeftTrigger, Button::RightTrigger].map(|trigger| {
let actions = self.actions_of(trigger);
let feel = if !self.adaptive_triggers {
TriggerFeel::Free
} else if actions.contains(&"key.use") {
using
} else if actions.contains(&"key.attack") {
attacking
} else {
TriggerFeel::Free
};
feel.effect(self.trigger_threshold)
})
}
}
/// What a DualSense's lightbar shows (`lightbar:`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Lightbar {
@@ -2438,52 +2377,6 @@ bind_key.hotbar.2:north
assert_eq!(w.observe(Some(1.0)), Some(1.0));
}
#[cfg(feature = "controllers")]
#[test]
fn triggers_feel_their_action() {
use horizon_dualsense::{TRIGGER_OFF, trigger};
let mut s = PadSettings::default();
// Use is LT, attack RT by default.
let [left, right] = s.trigger_effects(TriggerFeel::Draw(1.0), TriggerFeel::Texture(1.5));
assert_eq!(left, trigger::feedback(2, 6));
assert_eq!(right, trigger::vibration(1, 3, 30));
assert_eq!(
s.trigger_effects(TriggerFeel::Draw(0.0), TriggerFeel::Free),
[trigger::feedback(2, 1), TRIGGER_OFF]
);
// The catch gives way in the zone where the default threshold (0.4) presses: weapon
// 0x25, zones 2 and 4, strength 8 (stored 7) or 5 (stored 4).
let catch = |strength| [0x25, 0x14, 0x00, strength, 0, 0, 0, 0, 0, 0, 0];
assert_eq!(TriggerFeel::Click.effect(0.4), catch(7));
assert_eq!(TriggerFeel::Boost.effect(0.4), catch(4));
// A deeper threshold moves it: zones 5 and 7.
assert_eq!(TriggerFeel::Click.effect(0.7)[1..3], [0xa0, 0x00]);
assert_eq!(TriggerFeel::Click.effect(0.1), trigger::weapon(2, 3, 8));
assert_eq!(TriggerFeel::Brace.effect(0.4), trigger::feedback(2, 3));
assert_eq!(TriggerFeel::Locked.effect(0.4), trigger::feedback(1, 8));
// Harder blocks buzz harder and slower; instant-break blocks don't.
assert_eq!(
TriggerFeel::Texture(0.5).effect(0.4),
trigger::vibration(1, 2, 40)
);
assert_eq!(
TriggerFeel::Texture(3.0).effect(0.4),
trigger::vibration(1, 4, 20)
);
assert_eq!(
TriggerFeel::Texture(50.0).effect(0.4),
trigger::vibration(1, 5, 10)
);
assert_eq!(TriggerFeel::Texture(0.0).effect(0.4), TRIGGER_OFF);
assert_eq!(TriggerFeel::Texture(-1.0).effect(0.4), TRIGGER_OFF);
s.adaptive_triggers = false;
assert_eq!(
s.trigger_effects(TriggerFeel::Click, TriggerFeel::Boost),
[TRIGGER_OFF; 2]
);
assert!(!PadSettings::parse(&s.serialize()).adaptive_triggers);
}
#[test]
fn lightbar_colours() {
let full = Lightbar::Health.colour(1.0, "minecraft:overworld").unwrap();
+5 -8
View File
@@ -22,6 +22,8 @@ horizon-command = { path = "../horizon-command" }
horizon-cosmetics = { path = "../horizon-cosmetics" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# Extension points for the crates above the engine (docs/layers.md, "How a Horizon crate plugs in").
horizon-hooks = { path = "../horizon-hooks" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
@@ -73,16 +75,13 @@ mio = { version = "1.2.4", features = ["os-poll", "net"] }
# Shared chunk frames are found by one keyed 128-bit hash of their body (lan/conn.rs SharedFrames).
xxhash-rust = { version = "0.8.19", features = ["xxh3"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md); `live` is
# the anonymous "I'm playing" ping the game sends itself (`heartbeat.rs`), the `heartbeat` feature.
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
crc32fast = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
# Controllers (evdev + libudev on Linux, IOKit on macOS, Windows.Gaming.Input); the `controllers` feature.
gilrs = { version = "0.11", optional = true }
# The DualSense's rumble, adaptive triggers and lights over HID (gilrs only reads it).
horizon-dualsense = { path = "../horizon-dualsense", optional = true }
# HTTPS to the Minecraft services (online-mode session join).
ureq = { version = "2", default-features = false, features = ["tls"] }
unicode-bidi = "0.3.18"
@@ -114,18 +113,16 @@ objc2-foundation = { version = "0.3", default-features = false, features = ["std
wgpu = { version = "30.0.1", default-features = false, features = ["gles"] }
[features]
default = ["audio", "controllers", "discord", "friends", "heartbeat"]
default = ["audio", "controllers", "discord", "friends"]
# Sound output. Without it (and `controllers`) the program links neither libasound nor libudev on
# Linux: the server build (`--no-default-features`, released as horizon-engine; docs/server.md).
audio = ["horizon-client-render/audio"]
# Controllers: gilrs, and the DualSense's own output. Without it only `horizon-ctl pad`'s virtual pad.
controllers = ["dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
controllers = ["dep:gilrs", "horizon-client-input/controllers"]
# "Playing Horizon" on the player's Discord profile (`discord_presence`).
discord = []
# Play with Friends, joining side (`net.rs`): without it no invite code joins.
friends = ["dep:horizon-friends"]
# The anonymous "I'm playing" ping (`heartbeat.rs`, docs/telemetry.md).
heartbeat = ["horizon-telemetry/live"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
+1 -3
View File
@@ -24,6 +24,7 @@ horizon-narrator = { path = "../horizon-narrator" }
horizon-optifine = { path = "../horizon-optifine" }
horizon-polytone = { path = "../horizon-polytone" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-render-pipeline = { path = "../horizon-render-pipeline" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-system = { path = "../horizon-system" }
@@ -75,9 +76,6 @@ audio = ["dep:cpal"]
# Helpers horizon-client's tests build their fixtures with.
test-support = []
[build-dependencies]
horizon-mod-api-gen = { path = "../horizon-mod-api-gen" }
[lints]
workspace = true
-7
View File
@@ -1,7 +0,0 @@
fn main() {
let root =
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../..");
let out_dir = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap());
horizon_mod_api_gen::generate(&root, &out_dir, horizon_mod_api_gen::Target::Render)
.expect("generating native render mod API");
}
+1 -1
View File
@@ -244,7 +244,7 @@ fn arch_label(build: &str, machine: &str) -> String {
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
fn operating_system() -> String {
pub(crate) fn operating_system() -> String {
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| {
+32 -141
View File
@@ -194,6 +194,7 @@ mod frame_gen;
mod gpu_culling;
mod hdr;
mod hdr_icc;
mod hdr_start;
mod hdr_stats;
pub(crate) mod hud_coverage;
pub use hdr::{Calibration as HdrCalibration, Output as HdrOutput};
@@ -215,6 +216,7 @@ mod occlusion;
mod oit;
mod oit_features;
pub(crate) mod output;
mod output_apply;
mod pack_atlas;
mod pack_center_depth;
pub mod pack_draw;
@@ -1526,6 +1528,8 @@ pub struct Renderer {
emission_stats: hdr_stats::EmissionStats,
/// What this renderer's destination offers and its device draws.
output_caps: output::OutputCapabilities,
/// An output that failed to apply, not tried again until something it needs changes.
output_rejected: Option<output_apply::Rejected>,
/// The output applied (`update_output`), with the frame every pipeline that draws it
/// targets: the swapchain's 8-bit format, or `Rgba16Unorm` when the renderer was made with
/// HDR output on and Smooth Gradients and the device draws and blends into it, or
@@ -1538,6 +1542,8 @@ pub struct Renderer {
/// The marker file of an HDR output that has not presented a frame yet
/// (`graphics::HDR_MARKER`).
hdr_marker: Option<std::path::PathBuf>,
/// The window's graphics driver (`hdr_start`); `None` offscreen.
hdr_driver: Option<String>,
/// The HDR Lighting setting, kept for the HDR output made when HDR turns on.
hdr_lighting_setting: bool,
/// The shader pack's post-processing, and the dimension (legacy id) it was loaded for.
@@ -2985,11 +2991,13 @@ impl Renderer {
adapter.get_downlevel_capabilities().flags,
adapter.get_texture_format_features(hud_coverage::FORMAT),
);
let hdr_driver = surface.is_some().then(|| output::window_driver(&info));
let output_caps = output::OutputCapabilities {
hdr: hdr_output,
wide_frame: wide_ok,
float_frame: float_ok,
hud_layer: coverage_ok,
held: output::held(settings, hdr_driver.as_deref()),
};
let frame = output::resolve_frame(settings, request, output_caps, display, config.format);
let (frame_format, hud_coverage, overlay_format) =
@@ -5346,10 +5354,12 @@ impl Renderer {
hdr_lighting_setting: true,
emission_stats,
output_caps,
output_rejected: None,
// Nothing is presented as HDR yet: `update_output` applies the settings.
output: output::resolve_output(settings, output_caps, display, frame, false),
frame_kept_logged: false,
hdr_marker: None,
hdr_driver,
pack_post: None,
pack_loading: None,
native: None,
@@ -5518,7 +5528,7 @@ impl Renderer {
}
/// The resource packs' `core/rendertype_lines` (`HudAtlas::lines_shader`) for the block
/// outline, or the built-in one.
pub fn set_lines_shader(&mut self, program: Option<&horizon_shaders::CoreProgram>) {
pub fn set_lines_shader(&mut self, program: Option<&crate::core_shader::CoreProgram>) {
let format = self.output.frame.format;
self.core_lines =
program.and_then(|program| core_lines::CoreLines::new(&self.device, format, program));
@@ -5527,8 +5537,8 @@ impl Renderer {
/// `stars_shader`), or the built-in sky and stars.
pub fn set_sky_shaders(
&mut self,
disc: Option<&horizon_shaders::CoreProgram>,
stars: Option<&horizon_shaders::CoreProgram>,
disc: Option<&crate::core_shader::CoreProgram>,
stars: Option<&crate::core_shader::CoreProgram>,
) {
self.core_sky = core_sky::CoreSky::new(&self.device, self.output.frame.format, disc, stars);
}
@@ -5631,6 +5641,7 @@ impl Renderer {
}
self.surface_unconfigured = configured.is_err();
self.surface = Some(Arc::new(surface));
self.surface_recreated();
Ok(())
}
/// The world's depth, which occlusion culling's test and post effects may read.
@@ -7086,14 +7097,7 @@ impl Renderer {
/// The swapchain's configuration: `config` (what every pipeline draws in), or
/// HDR10 or scRGB while HDR output is on.
fn surface_config(&self) -> wgpu::SurfaceConfiguration {
let mut config = self.config.clone();
if let Some(hdr) = &self.hdr {
config.format = hdr.output.format();
config.view_formats = vec![hdr.output.format()];
config.color_space = hdr.output.color_space();
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
}
config
output_apply::surface_config(&self.config, self.hdr.as_ref().map(|hdr| hdr.output))
}
/// This renderer's output as applied, with what its destination offers and reports.
pub fn output(&self) -> &output::EffectiveOutput {
@@ -7149,106 +7153,11 @@ impl Renderer {
output::resolve_frame(
settings,
request,
self.output_caps,
self.caps_for(settings),
self.reading,
self.config.format,
)
}
/// Follows the selected mode even when Auto currently wants SDR. Off never asks
/// the system about the display; selecting On or Auto starts a fresh watch.
fn watch_hdr(&mut self, mode: crate::graphics::HdrMode) {
if mode == crate::graphics::HdrMode::Off {
self.display = None;
self.display_fresh = false;
self.reading.stop_watching();
} else if self.display.is_none() {
let (watch, now) =
DisplayWatch::new(&self.window, self.surface.as_deref(), &self.adapter);
self.display = Some(watch);
self.refresh_hdr_offer();
self.reading = now;
self.display_fresh = true;
}
}
/// What the window's surface offers now: Vulkan's offered formats can change with desktop
/// HDR or the window's output.
fn refresh_hdr_offer(&mut self) {
if let Some(surface) = &self.surface {
self.output_caps.hdr = hdr::output(&surface.get_capabilities(&self.adapter));
}
}
/// HDR output (Horizon setting) as `settings` ask for it on this renderer's frame
/// (`output::resolve_output`): HDR10 or scRGB at paper white, the world's highlights
/// expanded up to the peak. Off where the surface can't present it. Offscreen
/// (`hdr::offscreen_output`) the frame is drawn the same and its HDR screenshot taken,
/// but nothing is presented, and Auto takes its defaults: no display reports.
pub fn update_output(
&mut self,
settings: &crate::graphics::Graphics,
marker: &std::path::Path,
) {
self.watch_hdr(settings.hdr);
let next = output::resolve_output(
settings,
self.output_caps,
self.reading,
self.output.frame,
self.surface.is_some() || self.offscreen.is_some(),
);
let [paper_white, peak, black] = next.nits;
if next.encoding.is_some() != self.hdr.is_some() {
// If the game dies before HDR output shows a frame, the marker is still there at
// the next start, which then comes up with HDR off.
if next.encoding.is_some() {
if self.surface.is_some() {
let _ = std::fs::write(marker, "HDR output was being switched on\n");
self.hdr_marker = Some(marker.to_owned());
}
} else if let Some(marker) = self.hdr_marker.take() {
let _ = std::fs::remove_file(marker);
}
let frame = self.output.frame;
self.hdr = next.encoding.map(|output| {
hdr::Hdr::new(
&self.device,
&self.queue,
frame.format,
output,
frame.hud_layer,
)
});
if let Some(hdr) = &mut self.hdr {
hdr.look = self.hdr_lighting_setting;
}
self.configure_surface();
match next.encoding {
Some(output) => log::info!(
"Horizon renderer: HDR on: {} ({:?}), {paper_white}/{peak} nits",
output.name(),
output.format()
),
None => log::info!("Horizon renderer: HDR off"),
}
self.measure_emission();
}
if let Some(hdr) = &mut self.hdr {
hdr.ui = next.ui_brightness;
}
if let Some(hdr) = &mut self.hdr
&& (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black)
{
hdr.paper_white = paper_white;
hdr.peak = peak;
hdr.black = black;
self.send_hdr_metadata();
}
// An output of another encoding than the one presented doesn't replace it yet, only
// Off does: the snapshot says what is presented.
self.output = output::EffectiveOutput {
encoding: self.hdr.as_ref().map(|hdr| hdr.output),
..next
};
}
/// Measures which pixels of each block sprite may shine (`hdr_stats`), at the current
/// masks. Only with HDR output on: without it the frame's alpha is never read.
pub fn measure_emission(&mut self) {
@@ -7258,24 +7167,6 @@ impl Renderer {
.measure(&self.device, &self.queue, &views, hdr.masks);
}
}
/// Whether what the system reports for the display changed (another output, the
/// player recalibrating the desktop): the settings' Auto values follow it then, so
/// apply them again.
pub fn display_changed(&mut self) -> bool {
flush_display_log();
if std::mem::take(&mut self.display_fresh) {
return true;
}
let Some(watch) = self.display.as_mut() else {
return false;
};
let Some(now) = watch.changed(self.reading, self.surface.as_deref(), &self.adapter) else {
return false;
};
self.refresh_hdr_offer();
self.reading = now;
true
}
/// The share of real frames frame generation shows a made frame before (1 while it
/// keeps up), `None` while it isn't running.
/// The posed models' store size (`SkinFrame::base`), with entity skinning.
@@ -15399,6 +15290,10 @@ struct DisplayWatch {
}
impl DisplayReading {
/// What wgpu reads from the OS for `surface`'s display; nothing without one.
fn read(surface: Option<&wgpu::Surface>, adapter: &wgpu::Adapter) -> Self {
surface.map_or_else(Self::default, |s| Self::os(&s.display_hdr_info(adapter)))
}
fn os(info: &wgpu::DisplayHdrInfo) -> Self {
#[cfg(target_os = "macos")]
let hdr = macos_hdr(info);
@@ -15439,9 +15334,7 @@ impl DisplayWatch {
return (watch, wayland_reading(now));
}
let _ = window;
let now = surface.map_or_else(DisplayReading::default, |surface| {
DisplayReading::os(&surface.display_hdr_info(adapter))
});
let now = DisplayReading::read(surface, adapter);
log_os_reported(now.luminance);
let watch = Self {
#[cfg(target_os = "linux")]
@@ -15450,30 +15343,28 @@ impl DisplayWatch {
};
(watch, now)
}
/// What the system reports, if that changed from `last`.
fn changed(
/// What the system reports at each poll that is due at `time` (`read`; on Wayland, at each
/// event), whether or not it is `last` again: the surface's capabilities are read with it.
fn poll(
&mut self,
last: DisplayReading,
surface: Option<&wgpu::Surface>,
adapter: &wgpu::Adapter,
time: std::time::Instant,
read: impl FnOnce() -> DisplayReading,
) -> Option<DisplayReading> {
#[cfg(target_os = "linux")]
if let Some(wayland) = &mut self.wayland {
return wayland.changed().map(wayland_reading);
}
let next = self.polled.as_mut()?;
let time = std::time::Instant::now();
if time < *next {
return None;
}
*next = time + DISPLAY_POLL;
let now = surface.map_or_else(DisplayReading::default, |surface| {
DisplayReading::os(&surface.display_hdr_info(adapter))
});
(now != last).then(|| {
let now = read();
if now != last {
log_os_reported(now.luminance);
now
})
}
Some(now)
}
}
@@ -15830,11 +15721,11 @@ mod shadow_caster_tests {
#[test]
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
let shadow = horizon_shaders::Shadow {
let shadow = horizon_render_pipeline::Shadow {
distance: 128.0,
near,
far,
..horizon_shaders::Shadow::default()
..horizon_render_pipeline::Shadow::default()
};
let camera = DVec3::new(13.5, y + 1.62, -13.5);
let view = pack_post::ShadowView {
@@ -2,8 +2,8 @@
//! and `secondaryBlockOutline` under a high contrast one): an outline its fragment shader colours
//! or animates. Without one the outline draws natively (`vs_line` in terrain.wgsl).
use crate::core_shader::{CoreProgram, Glue};
use glam::Mat4;
use horizon_shaders::{CoreProgram, Glue};
/// The outline's edges (`pack_lines::layout`: start, end, ARGB colour and width per instance) as
/// vanilla's lines: four vertices an edge, the start twice and the end twice, the side
@@ -425,7 +425,7 @@ mod tests {
eprintln!("no GPU: skipping");
return;
};
let program = horizon_shaders::translate_core(LINES_VSH, RGB_FSH, &[], &include, &GLUE)
let program = crate::core_shader::translate_core(LINES_VSH, RGB_FSH, &[], &include, &GLUE)
.expect("the shaders translate");
assert_pack_coloured(&draw_edge(&device, &queue, &program));
}
@@ -441,7 +441,7 @@ mod tests {
include(id)
.map(|text| text.replace("vec3 ModelOffset;", "vec3 ModelOffset;\n vec4 More;"))
};
let program = horizon_shaders::translate_core(LINES_VSH, RGB_FSH, &[], &bigger, &GLUE)
let program = crate::core_shader::translate_core(LINES_VSH, RGB_FSH, &[], &bigger, &GLUE)
.expect("the shaders translate");
assert!(CoreLines::new(&device, wgpu::TextureFormat::Rgba8Unorm, &program).is_none());
}
@@ -470,11 +470,11 @@ mod tests {
let (namespace, path) = id.split_once(':')?;
std::fs::read_to_string(root.join(format!("assets/{namespace}/{path}"))).ok()
};
let program = horizon_shaders::translate_core(&vertex, RGB_FSH, &[], &include, &GLUE)
let program = crate::core_shader::translate_core(&vertex, RGB_FSH, &[], &include, &GLUE)
.expect("vanilla's vertex shader translates");
// A pack that replaces only the vertex shader runs vanilla's fragment shader.
let fragment = include("minecraft:shaders/core/rendertype_lines.fsh").unwrap();
let vanilla = horizon_shaders::translate_core(&vertex, &fragment, &[], &include, &GLUE)
let vanilla = crate::core_shader::translate_core(&vertex, &fragment, &[], &include, &GLUE)
.expect("vanilla's fragment shader translates");
let Some((device, queue)) = device() else {
eprintln!("no GPU: skipping the draw");
@@ -3,8 +3,8 @@
//! to the horizon or tints the night sky expects. Without one the sky draws natively (`fs_sky` in
//! terrain.wgsl, stars.rs).
use crate::core_shader::{CoreProgram, Glue};
use glam::Mat4;
use horizon_shaders::{CoreProgram, Glue};
use wgpu::util::DeviceExt;
/// `DefaultVertexFormat.POSITION`: the disc's and the stars' vertices.
@@ -325,7 +325,7 @@ mod tests {
let fragment = "#version 330\n#include <minecraft:dynamictransforms.glsl>\n\
layout(location = 0) out vec4 fragColor;\n\
void main() {\n fragColor = ColorModulator;\n}\n";
let program = horizon_shaders::translate_core(&vertex, fragment, &[], &include, &GLUE)
let program = crate::core_shader::translate_core(&vertex, fragment, &[], &include, &GLUE)
.expect("the sky shaders translate");
let instance = crate::gfx::test_gpu::instance();
let Some((device, queue)) =
@@ -1,3 +1,25 @@
// This file is part of the FidelityFX SDK.
//
// Copyright (C) 2024 Advanced Micro Devices, Inc.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files(the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and /or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions :
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
//! FSR 3.1's frame interpolation (AMD FidelityFX SDK 1.1.4, `ffx_frameinterpolation*`,
//! MIT), ported to WGSL (`interpolation.wgsl`): the frame halfway between the previous
//! real frame and this one, from the camera's motion vectors, the optical flow
+107
View File
@@ -419,6 +419,26 @@ impl Hdr {
}
}
/// The same HDR output presenting `output` instead (`output_apply`): the same frame and
/// HUD layer, and the player's tuning as it is (HDR Lighting, the world's lighting, the UI
/// brightness, the masks and tiers, the debug view, calibration and the luminance). Its
/// targets are made again on the next frame.
pub fn retargeted(&self, device: &wgpu::Device, queue: &wgpu::Queue, output: Output) -> Self {
Self {
debug: self.debug,
paper_white: self.paper_white,
peak: self.peak,
black: self.black,
tiers: self.tiers,
ui: self.ui,
masks: self.masks,
lighting: self.lighting,
look: self.look,
calibration: self.calibration,
..Self::new(device, queue, self.format, output, self.layered.is_some())
}
}
/// The frame every pass draws into this frame, sized like the window.
pub fn frame(&mut self, device: &wgpu::Device, size: [u32; 2]) -> wgpu::Texture {
if self.targets.as_ref().is_none_or(|t| t.size != size) {
@@ -1541,6 +1561,93 @@ struct ReferenceParams { peak: f32, scene: f32, world: f32, debug: f32, share: v
assert!(r == 1.0 && near(g, 0.5) && near(b, 0.2), "{r} {g} {b}");
}
/// HDR output switched from HDR10 to scRGB and back (`output_apply`): the player's tuning
/// stays, each swapchain gets the same light in its own encoding, and the HDR10 capture
/// and the HDR10 picture after the switch back are the ones before it.
#[test]
fn a_switched_encoding_keeps_the_tuning_and_shows_the_same_light() {
let Some((device, queue)) = gpu() else {
eprintln!("HDR encoding switch GPU skipped: no adapter");
return;
};
let mut hdr = Hdr::with_debug(&device, &queue, F::Rgba16Float, Output::Pq, true, None);
(hdr.paper_white, hdr.peak, hdr.black) = (250.0, 800.0, 0.0);
(hdr.ui, hdr.look, hdr.lighting) = (0.5, false, false);
hdr.masks.parts = 0.4;
hdr.tiers[1].share = 0.7;
hdr.debug = true;
hdr.calibration = Some(super::Calibration {
step: 2,
nits: 700.0,
band: 0.6,
});
let tuning = |h: &Hdr| {
(
[h.paper_white, h.peak, h.black, h.ui],
(h.look, h.lighting, h.debug, h.layered.is_some(), h.format),
(h.masks, h.tiers, h.calibration),
)
};
let switched = hdr.retargeted(&device, &queue, Output::ScRgb);
assert_eq!(switched.output, Output::ScRgb);
assert_eq!(tuning(&switched), tuning(&hdr));
hdr.calibration = None;
hdr.debug = false;
// The swapchain's first texel (`capture`: the HDR10 screenshot's) for light 2 times
// paper white, grey, as the layered frame stores it.
let shot = |hdr: &mut Hdr, capture: bool| {
let frame = hdr.frame(&device, [1, 1]);
let mut encoder = device.create_command_encoder(&Default::default());
let g = 2f64.powf(1.0 / 2.2);
fill(&mut encoder, &frame, [g, g, g, 1.0]);
hdr.begin(&queue, true);
hdr.keep_world(&mut encoder, None);
super::super::hud_coverage::clear(&mut encoder, &hdr.coverage_view().unwrap());
let target = if capture {
hdr.capture(&device, &queue, &mut encoder, true).unwrap()
} else {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: frame.size(),
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: hdr.output.format(),
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
};
if !capture {
hdr.present(&queue, &mut encoder, &target, true, None);
}
texel(&device, &queue, encoder, &target)
};
// BT.2100's PQ EOTF: a 10-bit code's nits.
let pq_nits = |code: f32| {
let (m1, m2) = (2610.0 / 16384.0, 2523.0 / 4096.0 * 128.0);
let (c1, c2, c3) = (3424.0 / 4096.0, 2413.0 / 128.0, 2392.0 / 128.0);
let p = (code / 1023.0f32).powf(1.0 / m2);
((p - c1).max(0.0) / (c2 - c3 * p)).powf(1.0 / m1) * 10000.0
};
let pq = shot(&mut hdr, false);
let pq_capture = shot(&mut hdr, true);
let mut scrgb = hdr.retargeted(&device, &queue, Output::ScRgb);
let linear = shot(&mut scrgb, false);
assert_eq!(shot(&mut scrgb, true), pq_capture);
// Two times paper white in both: 500 nits, as HDR10's code and as scRGB's 80-nit units.
let near = |got: f32| (got - 500.0).abs() <= 0.015 * 500.0;
assert!(
near(pq_nits(pq[0])) && pq[..3].iter().all(|&c| c == pq[0]),
"{pq:?}"
);
assert!(near(linear[0] * 80.0), "{linear:?}");
let mut back = scrgb.retargeted(&device, &queue, Output::Pq);
assert_eq!(
(shot(&mut back, false), shot(&mut back, true)),
(pq, pq_capture)
);
}
/// ITU-R BT.2390's black level lift on grey light, in HDR10 and in scRGB, against values
/// worked out apart from the shader, in f64 from SMPTE ST 2084: E3 = E2 + b (1 - E2)^4 on
/// the PQ signal, for a source from 0 to the peak and b the black level's share of it.
@@ -0,0 +1,65 @@
//! A window's HDR output start and what a failed one leaves (`graphics::HDR_MARKER`). Turning
//! HDR output on writes a marker naming the window's graphics driver (`output::window_driver`);
//! the first frame presented, turning HDR output off and a clean exit remove it. A start that
//! finds it holds HDR output back on that driver (`output::held`) until a mode is chosen on the
//! HDR page or the driver changes. Offscreen renderers have no driver: they never write the
//! marker or hold HDR output back.
use super::{Renderer, output};
impl Renderer {
/// What this renderer's destination offers for `settings`: HDR output held back on a window
/// whose graphics driver a start failed on (`output::held`).
pub(super) fn caps_for(
&self,
settings: &crate::graphics::Graphics,
) -> output::OutputCapabilities {
output::OutputCapabilities {
held: output::held(settings, self.hdr_driver.as_deref()),
..self.output_caps
}
}
/// Takes the hold `settings` put on this window's driver into the capabilities, and says it
/// once per start.
pub(super) fn hold_hdr_back(&mut self, settings: &crate::graphics::Graphics) {
self.output_caps = self.caps_for(settings);
if self.output_caps.held
&& let Some(driver) = &self.hdr_driver
{
static LOGGED: std::sync::Once = std::sync::Once::new();
LOGGED.call_once(|| {
log::warn!(
"Horizon renderer: HDR output is held back: the last start with this graphics driver ({driver}) ended before HDR output showed a frame. Choose an HDR mode on the HDR page to try again (or remove hdrFailedStart from {})",
crate::graphics::FILE
)
});
}
}
/// Writes the marker at `marker` as a window's HDR output turns on (`on`), and removes this
/// renderer's marker as it turns off.
pub(super) fn mark_hdr_start(&mut self, on: bool, marker: &std::path::Path) {
if !on {
self.drop_hdr_marker();
} else if self.surface.is_some()
&& let Some(driver) = &self.hdr_driver
{
let _ = std::fs::write(marker, crate::graphics::hdr_marker_text(driver));
self.hdr_marker = Some(marker.to_owned());
}
}
/// The graphics driver this window's HDR output starts on (`output::window_driver`);
/// `None` offscreen.
pub fn hdr_driver(&self) -> Option<&str> {
self.hdr_driver.as_deref()
}
/// Removes this renderer's marker of an HDR output that hasn't presented a frame yet, for a
/// clean exit: only a crash may leave it.
pub fn drop_hdr_marker(&mut self) {
if let Some(marker) = self.hdr_marker.take() {
let _ = std::fs::remove_file(marker);
}
}
}
@@ -338,7 +338,7 @@ pub struct NativeFrame {
reflection_sampler: wgpu::Sampler,
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
pub shadow_map: super::native_shadow::ShadowMap,
shadow_camera: Option<horizon_shaders::Shadow>,
shadow_camera: Option<horizon_render_pipeline::Shadow>,
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
/// is drawn and `has_reflection` stays 0.
pub reflects: bool,
@@ -1497,7 +1497,7 @@ pub fn frame_inputs(
size: [u32; 2],
seconds: f32,
index: u32,
shadow: Option<&horizon_shaders::Shadow>,
shadow: Option<&horizon_render_pipeline::Shadow>,
) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
@@ -2677,7 +2677,8 @@ file = \"b.wgsl\"
/// On the layered HDR frame a 0.2 pack's light is tone mapped once: by Horizon's curve,
/// the one HDR without a pack uses, when the pack has no `[tonemap]`, else by the pack's
/// alone, held to the headroom. In SDR Horizon's is a clip at white.
/// alone, held to the headroom. In SDR Horizon's is a clip at white. An HDR output switched
/// to scRGB and back to HDR10 (`Hdr::retargeted`) maps it once all the same.
#[test]
fn a_0_2_pack_is_tone_mapped_once_by_its_own_curve_or_horizons() {
let Some((device, queue)) = device() else {
@@ -2687,8 +2688,9 @@ file = \"b.wgsl\"
let light = 8.0f32;
let (paper, peak) = (203.0, 1000.0);
let headroom = peak / paper;
// The picture shown for that light, in linear light: (a pack's [tonemap], HDR).
let shown = |pack_curve: bool, hdr: bool| {
// The picture shown for that light, in linear light: (a pack's [tonemap], HDR, the
// output switched).
let shown = |pack_curve: bool, hdr: bool, switched: bool| {
let (stage, file, source) = if pack_curve {
(
"[tonemap]",
@@ -2746,6 +2748,11 @@ file = \"b.wgsl\"
true,
);
(display.paper_white, display.peak) = (paper, peak);
if switched {
use super::super::hdr::Output::{Pq, ScRgb};
display = display.retargeted(&device, &queue, ScRgb);
display = display.retargeted(&device, &queue, Pq);
}
let screen = display.frame(&device, [4, 4]);
let usage =
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
@@ -2813,14 +2820,16 @@ file = \"b.wgsl\"
// Horizon's curve once: BT.2390's EETF at 203 and 1000 nits puts light 8 at 4.82
// (worked out apart from the shader, as in the HDR tone curve's own test). Clipped at
// the headroom first, or mapped twice, it would land lower.
let horizon = shown(false, true);
assert!(near(horizon, 4.82043), "{horizon}");
// The pack's identity curve alone: the light held to the headroom, not eased again.
let pack = shown(true, true);
assert!(near(pack, headroom), "{pack}");
for switched in [false, true] {
let horizon = shown(false, true, switched);
assert!(near(horizon, 4.82043), "{switched}: {horizon}");
// The pack's identity curve alone: the light held to the headroom, not eased again.
let pack = shown(true, true, switched);
assert!(near(pack, headroom), "{switched}: {pack}");
}
// SDR: a clip at white, the pack's or Horizon's.
for pack_curve in [false, true] {
let sdr = shown(pack_curve, false);
let sdr = shown(pack_curve, false, false);
assert!(near(sdr, 1.0), "{pack_curve}: {sdr}");
}
}
@@ -11,9 +11,9 @@ use std::sync::{
atomic::{AtomicBool, Ordering},
};
mod generated {
include!(concat!(env!("OUT_DIR"), "/native_render_generated.rs"));
}
/// The hooks' trait and dispatch, made from `native-hooks.json` by `cargo run -p xtask -- mod-api`;
/// the game's build checks that the file is current.
mod generated;
pub use generated::{
HandDrawnEvent, RenderHooks, RenderNeedsEvent, WorldDrawnEvent, WorldWarpEvent,
};
@@ -0,0 +1,71 @@
// @generated by cargo run -p xtask -- mod-api; do not edit.
use super::*;
pub trait RenderHooks: Send + Sync {
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
fn world_drawn(&self, event: &mut WorldDrawnEvent<'_, '_>) {
let _ = event;
}
/// Asked as a frame is built and drawn: set what your mod needs.
///
/// Every mod gets `event.needs` in order and may add, change or remove needs.
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
let _ = event;
}
/// The first-person hand is drawn (see [`HandDrawn`]), on every frame [`native_world_drawn`]
/// runs, with no hand too. API 364.
fn hand_drawn(&self, event: &mut HandDrawnEvent<'_, '_>) {
let _ = event;
}
/// Asked once a frame, before the world is drawn (not on the title screen's panorama): set
/// [`WorldWarp`] to have the frame drawn through your warp. Mods are asked in order and one warp
/// draws: the last one set. Keep it cheap: compare and reuse your `Arc<str>`.
///
/// Every mod gets `event.warp` in order and may add, change or remove warp.
fn world_warp(&self, event: &mut WorldWarpEvent<'_>) {
let _ = event;
}
}
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct WorldDrawnEvent<'a, 'b> {
pub world: &'a mut WorldDrawn<'b>,
}
pub fn dispatch_world_drawn(mods: &[&dyn RenderHooks], world: &mut WorldDrawn<'_>) {
let mut event = WorldDrawnEvent { world };
for mod_hook in mods {
mod_hook.world_drawn(&mut event);
}
}
/// What [`RenderHooks::render_needs`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct RenderNeedsEvent<'a> {
pub needs: &'a mut RenderNeeds,
}
pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds) {
let mut event = RenderNeedsEvent { needs };
for mod_hook in mods {
mod_hook.render_needs(&mut event);
}
}
/// What [`RenderHooks::hand_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct HandDrawnEvent<'a, 'b> {
pub hand: &'a mut HandDrawn<'b>,
}
pub fn dispatch_hand_drawn(mods: &[&dyn RenderHooks], hand: &mut HandDrawn<'_>) {
let mut event = HandDrawnEvent { hand };
for mod_hook in mods {
mod_hook.hand_drawn(&mut event);
}
}
/// What [`RenderHooks::world_warp`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct WorldWarpEvent<'a> {
pub warp: &'a mut WorldWarp,
}
pub fn dispatch_world_warp(mods: &[&dyn RenderHooks], warp: &mut WorldWarp) {
let mut event = WorldWarpEvent { warp };
for mod_hook in mods {
mod_hook.world_warp(&mut event);
}
}
@@ -6,8 +6,8 @@
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_render_pipeline::Shadow;
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
use super::wgsl_prefix::{TERRAIN, prefixed, section};
+163 -4
View File
@@ -18,6 +18,43 @@ pub(crate) struct OutputCapabilities {
pub float_frame: bool,
/// The device keeps the HUD on a layer of its own (`hud_coverage::supported`).
pub hud_layer: bool,
/// HDR output is held back: the last start on this window's graphics driver ended before
/// HDR output showed a frame ([`held`]). The mode stays; the output is SDR.
pub held: bool,
}
/// Whether HDR output is held back on a renderer whose window's graphics driver is `driver`
/// (`None` offscreen, which never holds it back: only a window writes the marker).
pub(crate) fn held(settings: &Graphics, driver: Option<&str>) -> bool {
driver.is_some_and(|driver| settings.hdr_held(driver))
}
/// The graphics driver this window's HDR output starts on ([`driver_fingerprint`]), with the
/// system's version as crash reports name it (`crash::operating_system`), read once a run.
pub(crate) fn window_driver(info: &wgpu::AdapterInfo) -> String {
static OS: std::sync::OnceLock<String> = std::sync::OnceLock::new();
driver_fingerprint(info, OS.get_or_init(crate::crash::operating_system))
}
/// The graphics driver a window's HDR output starts on, in one line: the backend, the
/// adapter's vendor and device IDs and its name, the driver's name and its version, and the
/// system's version `os`. Another GPU, a driver update or a system update reads differently:
/// Metal names no driver, so on macOS the driver updates with the system.
pub fn driver_fingerprint(info: &wgpu::AdapterInfo, os: &str) -> String {
let adapter = format!(
"{:?} {:04x}:{:04x} {}",
info.backend, info.vendor, info.device, info.name
);
[adapter.as_str(), &info.driver, &info.driver_info, os]
.map(|field| {
field
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect::<String>()
.trim()
.to_owned()
})
.join("; ")
}
/// What the system last reported for a renderer's display: its luminance, and whether it
@@ -97,6 +134,8 @@ pub struct EffectiveOutput {
pub(crate) reported: Option<Reported>,
/// The device draws the 16-bit frame (Smooth Gradients).
pub(crate) wide_frame_supported: bool,
/// HDR output is held back on this window's graphics driver (`OutputCapabilities::held`).
pub(crate) held: bool,
/// The HDR output presented; `None` is SDR.
pub(crate) encoding: Option<Output>,
pub(crate) frame: FrameConfiguration,
@@ -118,6 +157,7 @@ impl EffectiveOutput {
system_hdr: None,
reported: None,
wide_frame_supported: false,
held: false,
encoding: None,
frame: FrameConfiguration {
format: wgpu::TextureFormat::Bgra8Unorm,
@@ -136,9 +176,10 @@ impl EffectiveOutput {
self.encoding.is_some()
}
/// Whether `mode` asks for HDR here: On always, Auto where the destination offers it and
/// the display reports HDR. The output still needs an offer and a frame that can take it.
/// the display reports HDR, neither while HDR output is held back. The output still needs
/// an offer and a frame that can take it.
pub fn requests_hdr(&self, mode: HdrMode) -> bool {
requests_hdr(mode, self.offered.is_some(), self.system_hdr)
!self.held && requests_hdr(mode, self.offered.is_some(), self.system_hdr)
}
/// What a 0.2 pack's `Frame` describes: the HDR display on the layered frame, whose
/// picture is light; SDR otherwise, where the frame holds SDR's picture, the one HDR
@@ -169,6 +210,10 @@ fn requests_hdr(mode: HdrMode, offered: bool, system: Option<bool>) -> bool {
HdrMode::Auto => offered && system == Some(true),
}
}
/// Whether `settings` ask for HDR on a destination of `caps` whose display reports `reading`.
fn wanted(settings: &Graphics, caps: OutputCapabilities, reading: DisplayReading) -> bool {
!caps.held && requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr)
}
/// The frame a renderer made now draws in, for `settings`, the pack's `request`, what the
/// destination and device can do (`caps`) and what the display reports.
@@ -179,7 +224,7 @@ pub(crate) fn resolve_frame(
reading: DisplayReading,
swapchain: wgpu::TextureFormat,
) -> FrameConfiguration {
let wanted = requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr);
let wanted = wanted(settings, caps, reading);
let (format, hud_layer) = super::frame_configuration(
wanted && settings.hdr_smooth_gradients,
(
@@ -210,12 +255,13 @@ pub(crate) fn resolve_output(
frame: FrameConfiguration,
presents: bool,
) -> EffectiveOutput {
let wanted = requests_hdr(settings.hdr, caps.hdr.is_some(), reading.hdr);
let wanted = wanted(settings, caps, reading);
EffectiveOutput {
offered: caps.hdr,
system_hdr: reading.hdr,
reported: reading.luminance,
wide_frame_supported: caps.wide_frame,
held: caps.held,
encoding: caps
.hdr
.filter(|_| wanted && presents && frame.overlay_format == frame.format),
@@ -247,6 +293,7 @@ pub(crate) mod tests {
wide_frame: true,
float_frame: true,
hud_layer: true,
held: false,
},
DisplayReading {
luminance: Some(Reported {
@@ -378,6 +425,118 @@ pub(crate) mod tests {
assert_eq!(settings.hdr, HdrMode::On);
}
/// A window on the driver a start failed on stays SDR, on an SDR frame, whatever the
/// mode, and the mode stays as chosen; another driver tries HDR again, and an offscreen
/// renderer (no driver) never holds HDR back.
#[test]
fn a_failed_start_holds_hdr_back_on_its_driver_only() {
let failed = "Vulkan 10de:2684 GPU; driver; 1.0";
for mode in [HdrMode::On, HdrMode::Auto] {
let settings = Graphics {
hdr: mode,
hdr_failed_start: Some(failed.into()),
..Graphics::default()
};
let request = FrameRequest {
linear_pack: false,
hdr_float: true,
};
let on = |driver: Option<&str>| {
let (mut caps, reading) = window(Some(Output::Pq), Some(true));
caps.held = held(&settings, driver);
snapshot(&settings, request, caps, reading)
};
let out = on(Some(failed));
assert_eq!(out.encoding, None, "{mode:?}");
assert_eq!(out.frame.format, Bgra8Unorm, "{mode:?}");
assert!(!out.frame.hud_layer && out.held && !out.requests_hdr(mode));
// Still offered: the page lets the player pick a mode to try again.
assert!(out.hdr_offered());
assert_eq!(settings.hdr, mode);
for driver in [Some("Vulkan 10de:2684 GPU; driver; 1.1"), None] {
let out = on(driver);
assert_eq!(out.encoding, Some(Output::Pq), "{mode:?} {driver:?}");
assert_eq!(out.frame.format, LINEAR_FRAME, "{mode:?} {driver:?}");
assert!(!out.held && out.requests_hdr(mode));
}
}
// A marker that named no driver holds on whichever window comes first.
let legacy = Graphics {
hdr: HdrMode::On,
hdr_failed_start: Some(String::new()),
..Graphics::default()
};
assert!(held(&legacy, Some(failed)));
assert!(!held(&legacy, None));
}
/// The fingerprint tells another GPU, backend, driver version or system version apart, in
/// one line.
#[test]
fn the_driver_fingerprint_is_one_line_that_changes_with_the_driver() {
let os = "windows (x86_64) version Windows 11 (26200.7019)";
let info = wgpu::AdapterInfo {
name: "NVIDIA GeForce RTX 4090".into(),
vendor: 0x10de,
device: 0x2684,
device_type: wgpu::DeviceType::DiscreteGpu,
device_pci_bus_id: "0000:01:00.0".into(),
driver: "NVIDIA".into(),
driver_info: "581.57\n".into(),
backend: wgpu::Backend::Vulkan,
subgroup_min_size: 32,
subgroup_max_size: 32,
transient_saves_memory: None,
limit_bucket: None,
};
let fingerprint = driver_fingerprint(&info, os);
assert_eq!(
fingerprint,
"Vulkan 10de:2684 NVIDIA GeForce RTX 4090; NVIDIA; 581.57; \
windows (x86_64) version Windows 11 (26200.7019)"
);
assert_ne!(
driver_fingerprint(&info, "windows (x86_64) version Windows 11 (26200.7171)"),
fingerprint
);
// Metal names no driver: a macOS update is what changes it.
let metal = wgpu::AdapterInfo {
name: "Apple M3".into(),
vendor: 0,
device: 0,
driver: String::new(),
driver_info: String::new(),
backend: wgpu::Backend::Metal,
..info.clone()
};
let sonoma = driver_fingerprint(&metal, "macos (aarch64) version macOS 14.6");
assert_eq!(
sonoma,
"Metal 0000:0000 Apple M3; ; ; macos (aarch64) version macOS 14.6"
);
assert_ne!(
driver_fingerprint(&metal, "macos (aarch64) version macOS 15.1"),
sonoma
);
let other = [
wgpu::AdapterInfo {
driver_info: "582.02".into(),
..info.clone()
},
wgpu::AdapterInfo {
backend: wgpu::Backend::Dx12,
..info.clone()
},
wgpu::AdapterInfo {
device: 0x2704,
..info.clone()
},
];
for other in other {
assert_ne!(driver_fingerprint(&other, os), fingerprint, "{other:?}");
}
}
/// Auto's paper white and peak are this display's report; the player's own values win.
#[test]
fn auto_levels_follow_this_renderers_report() {
@@ -0,0 +1,532 @@
//! Applying the output a renderer resolves (`output::resolve_output`). Each observation of the
//! settings, the destination's capabilities and the display resolves one candidate. One that
//! changes only values (the luminance, the UI brightness, what the display reports) updates
//! them in place. Another encoding or frame is built and the swapchain configured for it before
//! the snapshot says so. One that fails is never published as active, and is not tried again
//! until the request, the capabilities or the surface change.
use super::{DisplayReading, DisplayWatch, Renderer, hdr, output};
use output::{EffectiveOutput, OutputCapabilities};
/// What an output's resources are made for: the encoding presented and the frame.
type Key = (Option<hdr::Output>, output::FrameConfiguration);
fn key(output: &EffectiveOutput) -> Key {
(output.encoding, output.frame)
}
/// An output whose resources or swapchain configuration failed, with the capabilities it
/// failed on.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Rejected {
key: Key,
caps: OutputCapabilities,
}
/// What applying an output changes in a renderer.
trait Present {
/// Builds what `output` presents and configures the swapchain for it, then swaps the new
/// resources in. On an error the resources stay as they were, and the swapchain may be
/// configured for neither output.
fn present(&mut self, output: &EffectiveOutput) -> std::result::Result<(), String>;
/// Presents SDR whatever happens: a swapchain that doesn't configure now is configured
/// again before the next frame (`surface_unconfigured`).
fn present_sdr(&mut self);
}
/// The snapshot to publish for `candidate` after `applied`, on `caps`. The same resources
/// take the candidate's values as they are. Other ones are presented first and published only
/// once that worked. On a failure the output applied stays where it presents again, else
/// SDR, and the failed one is kept in `rejected` until the candidate's resources or the
/// capabilities change.
fn commit(
target: &mut impl Present,
applied: &EffectiveOutput,
candidate: EffectiveOutput,
caps: OutputCapabilities,
rejected: &mut Option<Rejected>,
) -> EffectiveOutput {
let wanted = key(&candidate);
if rejected.is_some_and(|r| r.key != wanted || r.caps != caps) {
*rejected = None;
}
if wanted == key(applied) {
return candidate;
}
let kept = EffectiveOutput {
encoding: applied.encoding,
..candidate
};
if rejected.is_some() {
return kept;
}
let Err(e) = target.present(&candidate) else {
return candidate;
};
log::warn!(
"Horizon renderer: {} could not be presented ({e}); keeping {}",
name(candidate.encoding),
name(applied.encoding)
);
*rejected = Some(Rejected { key: wanted, caps });
if target.present(&kept).is_ok() {
return kept;
}
log::warn!(
"Horizon renderer: {} no longer presents; SDR",
name(kept.encoding)
);
target.present_sdr();
EffectiveOutput {
encoding: None,
..candidate
}
}
fn name(encoding: Option<hdr::Output>) -> &'static str {
encoding.map_or("SDR", hdr::Output::name)
}
/// The swapchain's configuration for `encoding`: `config` (what every pipeline draws in), or
/// HDR10 or scRGB.
pub(super) fn surface_config(
config: &wgpu::SurfaceConfiguration,
encoding: Option<hdr::Output>,
) -> wgpu::SurfaceConfiguration {
let mut config = config.clone();
if let Some(output) = encoding {
config.format = output.format();
config.view_formats = vec![output.format()];
config.color_space = output.color_space();
config.usage = wgpu::TextureUsages::RENDER_ATTACHMENT;
}
config
}
/// Takes what a due poll read into `reading` and `caps`: the display's report `now`, and what
/// the surface offers (`offer`, `None` without a surface). Whether either changed, the offer
/// alone included.
fn observe(
reading: &mut DisplayReading,
caps: &mut OutputCapabilities,
now: DisplayReading,
offer: Option<Option<hdr::Output>>,
) -> bool {
let before = (*reading, caps.hdr);
*reading = now;
if let Some(offer) = offer {
caps.hdr = offer;
}
before != (*reading, caps.hdr)
}
/// A renderer applying an output, with the marker its HDR output's start writes.
struct Applying<'a> {
renderer: &'a mut Renderer,
marker: &'a std::path::Path,
}
impl Present for Applying<'_> {
fn present(&mut self, output: &EffectiveOutput) -> std::result::Result<(), String> {
let r = &mut *self.renderer;
let current = r.hdr.as_ref().map(|hdr| hdr.output);
let switching = output.encoding.is_some_and(|e| current != Some(e));
// If the game dies before HDR output shows a frame, its pipelines' build included, the
// marker is still there at the next start, which then holds HDR output back on this
// driver.
if output.encoding.is_none() || switching {
r.mark_hdr_start(output.encoding.is_some(), self.marker);
}
let built = match output.encoding {
Some(encoding) if switching => Some(r.build_hdr(encoding, output)?),
_ => None,
};
if let Some(surface) = &r.surface {
r.frame_gen.stop();
// The submit thread may still present to the old swapchain.
r.queue.wait();
let hdr = built.as_ref().or(r.hdr.as_ref());
Renderer::configure(
&r.device,
surface,
&surface_config(&r.config, output.encoding),
hdr.filter(|_| output.encoding.is_some()),
&r.device_lost,
)?;
r.surface_unconfigured = false;
}
if output.encoding.is_none() {
r.hdr = None;
} else if built.is_some() {
r.hdr = built;
}
Ok(())
}
fn present_sdr(&mut self) {
let r = &mut *self.renderer;
r.hdr = None;
r.mark_hdr_start(false, self.marker);
if let Some(surface) = &r.surface {
r.frame_gen.stop();
r.queue.wait();
r.surface_unconfigured =
Renderer::configure(&r.device, surface, &r.config, None, &r.device_lost).is_err();
}
}
}
impl Renderer {
/// HDR output presenting `encoding` for `output`, made apart from the one presented: from
/// it where there is one, keeping the player's tuning (`Hdr::retargeted`). An error is
/// the device's, caught here rather than left for the next frame.
fn build_hdr(
&self,
encoding: hdr::Output,
output: &EffectiveOutput,
) -> std::result::Result<hdr::Hdr, String> {
let validation = self.device.push_error_scope(wgpu::ErrorFilter::Validation);
let memory = self.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let mut hdr = match &self.hdr {
Some(hdr) => hdr.retargeted(&self.device, &self.queue, encoding),
None => {
let mut hdr = hdr::Hdr::new(
&self.device,
&self.queue,
output.frame.format,
encoding,
output.frame.hud_layer,
);
hdr.look = self.hdr_lighting_setting;
hdr
}
};
let memory = pollster::block_on(memory.pop());
if let Some(e) = memory.or(pollster::block_on(validation.pop())) {
return Err(e.to_string());
}
// The metadata the swapchain's configuration sends.
[hdr.paper_white, hdr.peak, hdr.black] = output.nits;
hdr.ui = output.ui_brightness;
Ok(hdr)
}
/// HDR output (Horizon setting) as `settings` ask for it on this renderer's frame
/// (`output::resolve_output`): HDR10 or scRGB at paper white, the world's highlights
/// expanded up to the peak. Off where the surface can't present it. Offscreen
/// (`hdr::offscreen_output`) the frame is drawn the same and its HDR screenshot taken,
/// but nothing is presented, and Auto takes its defaults: no display reports.
pub fn update_output(
&mut self,
settings: &crate::graphics::Graphics,
marker: &std::path::Path,
) {
self.watch_hdr(settings.hdr);
self.hold_hdr_back(settings);
let candidate = output::resolve_output(
settings,
self.output_caps,
self.reading,
self.output.frame,
self.surface.is_some() || self.offscreen.is_some(),
);
let (applied, caps) = (self.output, self.output_caps);
let mut rejected = self.output_rejected.take();
let renderer = &mut Applying {
renderer: self,
marker,
};
let next = commit(renderer, &applied, candidate, caps, &mut rejected);
self.output_rejected = rejected;
let [paper_white, peak, black] = next.nits;
if next.encoding != applied.encoding {
match next.encoding {
Some(output) => log::info!(
"Horizon renderer: HDR on: {} ({:?}), {paper_white}/{peak} nits",
output.name(),
output.format()
),
None => log::info!("Horizon renderer: HDR off"),
}
self.measure_emission();
}
if let Some(hdr) = &mut self.hdr {
hdr.ui = next.ui_brightness;
if (hdr.paper_white, hdr.peak, hdr.black) != (paper_white, peak, black) {
(hdr.paper_white, hdr.peak, hdr.black) = (paper_white, peak, black);
self.send_hdr_metadata();
}
}
self.output = next;
}
/// Follows the selected mode even when Auto currently wants SDR. Off never asks
/// the system about the display; selecting On or Auto starts a fresh watch.
fn watch_hdr(&mut self, mode: crate::graphics::HdrMode) {
if mode == crate::graphics::HdrMode::Off {
self.display = None;
self.display_fresh = false;
self.reading.stop_watching();
} else if self.display.is_none() {
let (watch, now) =
DisplayWatch::new(&self.window, self.surface.as_deref(), &self.adapter);
self.display = Some(watch);
self.output_caps.hdr = self.surface_offer().unwrap_or(self.output_caps.hdr);
self.reading = now;
self.display_fresh = true;
}
}
/// What the window's surface offers now (`None` offscreen): Vulkan's offered formats can
/// change with desktop HDR or the window's output, and a display can offer the other
/// encoding.
fn surface_offer(&self) -> Option<Option<hdr::Output>> {
let surface = self.surface.as_ref()?;
Some(hdr::output(&surface.get_capabilities(&self.adapter)))
}
/// After a new window surface: what it offers, and an output that failed on the old one
/// may be tried again. The settings apply again.
pub(super) fn surface_recreated(&mut self) {
self.output_caps.hdr = self.surface_offer().unwrap_or(self.output_caps.hdr);
self.display_fresh = true;
self.output_rejected = None;
}
/// Whether what the system reports for the display, or what the surface offers, changed
/// (another output, the player recalibrating the desktop, desktop HDR offering the other
/// encoding): the settings' Auto values and the output follow it then, so apply them
/// again. Each due poll reads the surface's capabilities as well, even when the display
/// reports the same.
pub fn display_changed(&mut self) -> bool {
super::flush_display_log();
if std::mem::take(&mut self.display_fresh) {
return true;
}
let Some(watch) = self.display.as_mut() else {
return false;
};
let (surface, adapter) = (self.surface.as_deref(), &self.adapter);
let read = || DisplayReading::read(surface, adapter);
let Some(now) = watch.poll(self.reading, std::time::Instant::now(), read) else {
return false;
};
let offer = self.surface_offer();
observe(&mut self.reading, &mut self.output_caps, now, offer)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graphics::{Graphics, HdrMode, Reported};
use hdr::Output::{Pq, ScRgb};
use output::FrameRequest;
use output::tests::{snapshot, window};
/// A swapchain that records what it was asked to present, and fails `fails`.
#[derive(Default)]
struct Swapchain {
fails: Vec<Option<hdr::Output>>,
/// Each encoding presented, with the luminance its metadata sends.
asked: Vec<(Option<hdr::Output>, [f32; 3])>,
sdr: usize,
}
impl Present for Swapchain {
fn present(&mut self, output: &EffectiveOutput) -> std::result::Result<(), String> {
self.asked.push((output.encoding, output.nits));
if self.fails.contains(&output.encoding) {
return Err("unsupported".into());
}
Ok(())
}
fn present_sdr(&mut self) {
self.sdr += 1;
}
}
const NO_PACK: FrameRequest = FrameRequest {
linear_pack: false,
hdr_float: true,
};
fn on() -> Graphics {
Graphics {
hdr: HdrMode::On,
..Graphics::default()
}
}
/// The candidate on a renderer whose frame is `applied`'s.
fn candidate(
settings: &Graphics,
caps: OutputCapabilities,
reading: DisplayReading,
applied: &EffectiveOutput,
) -> EffectiveOutput {
output::resolve_output(settings, caps, reading, applied.frame, true)
}
/// HDR stays on while the display offers scRGB instead of HDR10 and then HDR10 again:
/// the swapchain, its metadata and the snapshot follow each, with no Off between.
#[test]
fn the_output_follows_pq_to_scrgb_and_back_while_hdr_stays_on() {
let settings = on();
let (mut caps, reading) = window(Some(Pq), Some(true));
let mut applied = snapshot(&settings, NO_PACK, caps, reading);
assert_eq!(applied.encoding, Some(Pq));
let (mut swapchain, mut rejected) = (Swapchain::default(), None);
for encoding in [ScRgb, Pq] {
caps.hdr = Some(encoding);
let next = candidate(&settings, caps, reading, &applied);
applied = commit(&mut swapchain, &applied, next, caps, &mut rejected);
assert_eq!(
(applied.encoding, applied.offered),
(Some(encoding), Some(encoding))
);
assert_eq!(
applied.frame,
snapshot(&settings, NO_PACK, caps, reading).frame
);
}
let metadata = [400.0, 1000.0, 0.0];
assert_eq!(
swapchain.asked,
[(Some(ScRgb), metadata), (Some(Pq), metadata)]
);
assert_eq!((rejected, swapchain.sdr), (None, 0));
}
/// The display reports the same luminance and HDR, but the surface now offers scRGB: the
/// next due poll reads it, and the output follows with the same nits and report.
#[test]
fn a_change_of_offer_alone_is_seen_at_the_next_poll() {
let settings = on();
let (mut caps, mut reading) = window(Some(Pq), Some(true));
let applied = snapshot(&settings, NO_PACK, caps, reading);
let start = std::time::Instant::now();
let mut watch = DisplayWatch {
#[cfg(target_os = "linux")]
wayland: None,
polled: Some(start + super::super::DISPLAY_POLL),
};
let same = reading;
assert_eq!(watch.poll(reading, start, || unreachable!()), None);
let due = start + super::super::DISPLAY_POLL;
assert_eq!(watch.poll(reading, due, || same), Some(same));
assert_eq!(watch.polled, Some(due + super::super::DISPLAY_POLL));
assert!(observe(&mut reading, &mut caps, same, Some(Some(ScRgb))));
// Nothing changed at the next poll, and offscreen nothing is offered to read.
assert!(!observe(&mut reading, &mut caps, same, Some(Some(ScRgb))));
assert!(!observe(&mut reading, &mut caps, same, None));
let next = candidate(&settings, caps, reading, &applied);
let published = commit(&mut Swapchain::default(), &applied, next, caps, &mut None);
assert_eq!(published.encoding, Some(ScRgb));
assert_eq!(
(published.nits, published.reported, published.system_hdr),
(applied.nits, applied.reported, applied.system_hdr)
);
}
/// The display's luminance or the UI brightness change on the same output: the values
/// follow, and nothing is built or configured again.
#[test]
fn new_nits_on_the_same_output_build_nothing() {
let mut settings = on();
let (caps, mut reading) = window(Some(Pq), Some(true));
let applied = snapshot(&settings, NO_PACK, caps, reading);
reading.luminance = Some(Reported {
paper_white: 250,
peak: 600,
});
settings.hdr_ui_brightness = 60;
let mut swapchain = Swapchain::default();
let next = candidate(&settings, caps, reading, &applied);
let published = commit(&mut swapchain, &applied, next, caps, &mut None);
assert_eq!(published, next);
assert_eq!(published.nits, [250.0, 600.0, 0.0]);
assert_eq!(published.ui_brightness, 0.6);
assert!(swapchain.asked.is_empty());
}
/// An encoding that fails is never the snapshot's: the output applied stays where it
/// presents again, else SDR. It is not tried again each frame, nor for new nits, only
/// once the request or the capabilities change.
#[test]
fn an_output_that_fails_is_never_active_nor_tried_every_frame() {
let settings = on();
let (mut caps, mut reading) = window(Some(Pq), Some(true));
let pq = snapshot(&settings, NO_PACK, caps, reading);
caps.hdr = Some(ScRgb);
let mut swapchain = Swapchain {
fails: vec![Some(ScRgb)],
..Default::default()
};
let mut rejected = None;
let next = candidate(&settings, caps, reading, &pq);
let published = commit(&mut swapchain, &pq, next, caps, &mut rejected);
assert_eq!(published.encoding, Some(Pq));
assert_eq!(swapchain.asked.len(), 2, "scRGB, then HDR10 again");
// Frames after, and a new luminance: no new attempt.
for paper_white in [400, 400, 300] {
reading.luminance = Some(Reported {
paper_white,
peak: 1000,
});
let next = candidate(&settings, caps, reading, &published);
let again = commit(&mut swapchain, &published, next, caps, &mut rejected);
assert_eq!(again.encoding, Some(Pq));
assert_eq!(again.nits[0], paper_white as f32);
}
assert_eq!(swapchain.asked.len(), 2);
// Another request (HDR Off, then On) tries it again.
let off = Graphics::default();
let sdr = commit(
&mut swapchain,
&published,
candidate(&off, caps, reading, &published),
caps,
&mut rejected,
);
assert_eq!((sdr.encoding, rejected), (None, None));
let next = candidate(&settings, caps, reading, &sdr);
assert_eq!(
commit(&mut swapchain, &sdr, next, caps, &mut rejected).encoding,
None
);
assert_eq!(
swapchain.asked.iter().map(|a| a.0).collect::<Vec<_>>(),
[Some(ScRgb), Some(Pq), None, Some(ScRgb), None]
);
// So does another capability (the hold lifted here stands for any).
caps.held = true;
let held = candidate(&settings, caps, reading, &sdr);
assert_eq!(held.encoding, None);
commit(&mut swapchain, &sdr, held, caps, &mut rejected);
caps.held = false;
let asked = swapchain.asked.len();
let next = candidate(&settings, caps, reading, &sdr);
commit(&mut swapchain, &sdr, next, caps, &mut rejected);
assert_eq!(swapchain.asked.len(), asked + 2);
// The output applied failing too: SDR, never the failed one.
let mut broken = Swapchain {
fails: vec![Some(ScRgb), Some(Pq)],
..Default::default()
};
let next = candidate(&settings, caps, reading, &pq);
let published = commit(&mut broken, &pq, next, caps, &mut None);
assert_eq!((published.encoding, broken.sdr), (None, 1));
assert_eq!(settings.hdr, HdrMode::On, "the mode stays");
// A new surface forgets the failure (`surface_recreated`): the same request on the
// same capabilities is tried again.
let mut swapchain = Swapchain {
fails: vec![Some(ScRgb)],
..Default::default()
};
let mut rejected = None;
let next = candidate(&settings, caps, reading, &pq);
for _ in 0..2 {
commit(&mut swapchain, &pq, next, caps, &mut rejected);
}
assert_eq!(swapchain.asked.len(), 2);
rejected = None;
commit(&mut swapchain, &pq, next, caps, &mut rejected);
assert_eq!(swapchain.asked.len(), 4);
}
}
@@ -4,7 +4,8 @@
use std::collections::BTreeMap;
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
use horizon_render_pipeline::{Format, Kind};
use horizon_shaders::{BufferObject, CustomImage};
pub(super) struct PackImage {
pub(super) def: CustomImage,
@@ -18,7 +18,8 @@
use std::collections::HashMap;
use glam::{DMat4, DVec3};
use horizon_shaders::{Format, Group, Kind, Post, SHADOW_COLORS};
use horizon_render_pipeline::{BUFFERS, Format, Kind, SHADOW_COLORS};
use horizon_shaders::{Group, Post};
use smallvec::SmallVec;
use super::pack_draw::DrawUniforms;
@@ -28,8 +29,6 @@ use super::pack_sky::PackSky;
use super::pack_textures;
use super::pack_uniforms::{self, FrameUniforms, PackInputs};
const BUFFERS: usize = horizon_shaders::BUFFERS;
mod packed;
#[cfg(test)]
@@ -890,7 +889,7 @@ fn depth_linear(interface: &horizon_shaders::Interface, linear: u8) -> [f32; 4]
}
fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool) -> Source {
let shadow_color = horizon_shaders::shadow_color(name);
let shadow_color = horizon_render_pipeline::shadow_color(name);
let shadow_depth = match name {
"shadowtex0" | "watershadow" | "shadowtex0HW" => Some(0),
"shadowtex1" | "shadowtex1HW" => Some(1),
@@ -909,7 +908,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
};
}
}
if shadow_space && horizon_shaders::buffer_index(name).is_some() {
if shadow_space && horizon_render_pipeline::buffer_index(name).is_some() {
// colortex buffers are not this pass's.
return Source::Dummy;
}
@@ -923,7 +922,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
"normals" => Source::Normals,
"specular" => Source::Specular,
horizon_shaders::WATER_MASK => Source::WaterMask,
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
n => horizon_render_pipeline::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1580,7 +1579,7 @@ fn bindings(
!unbound
&& (t.stage != pack_textures::stage(group)
|| released.0 != t.stage
|| horizon_shaders::buffer_index(&t.name)
|| horizon_render_pipeline::buffer_index(&t.name)
.is_none_or(|b| released.1 & 1 << b == 0))
});
let image = pack
@@ -2522,7 +2521,7 @@ impl PackPost {
let shadow_config = post.config.shadow.clone();
let noise_size = post.config.noise_resolution.unwrap_or(256);
let half_lives = post.config.half_lives;
for (name, ticks) in horizon_shaders::HALF_LIVES.iter().zip(half_lives) {
for (name, ticks) in horizon_render_pipeline::HALF_LIVES.iter().zip(half_lives) {
if let Some(t) = ticks {
notes.push(format!("`{name}` is {t} ticks"));
}
@@ -10,7 +10,7 @@
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
use horizon_shaders::{Format, Kind};
use horizon_render_pipeline::{Format, Kind};
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
@@ -3,7 +3,7 @@
//! with two sides that `shadowcomp` programs flip as composite programs flip colortex (6.1.6).
//! Square, `shadowMapResolution` pixels.
use horizon_shaders::SHADOW_COLORS;
use horizon_render_pipeline::SHADOW_COLORS;
type Ops<T> = wgpu::Operations<T>;
type Color = wgpu::Color;
@@ -97,7 +97,11 @@ pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
}
impl ShadowTargets {
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
pub fn reset_history(
&mut self,
device: &wgpu::Device,
shadow: &horizon_render_pipeline::Shadow,
) {
let mut colors = 0;
let mut doubled = 0;
let formats = std::array::from_fn(|n| match &self.color[n] {
@@ -125,7 +129,7 @@ impl ShadowTargets {
/// resolution is kept within the device's limit.
pub fn new(
device: &wgpu::Device,
shadow: &horizon_shaders::Shadow,
shadow: &horizon_render_pipeline::Shadow,
formats: [wgpu::TextureFormat; SHADOW_COLORS],
colors: u16,
doubled: u16,
@@ -1,7 +1,8 @@
//! A shader pack's custom textures (`texture.<stage>.<name>`, `texture.noise`) on the GPU, and
//! which of them a program's sampler reads.
use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
use horizon_render_pipeline::Kind;
use horizon_shaders::{CustomTexture, Group, Texels, Texture, TextureDim, TextureSource};
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
pub(super) enum Image {
@@ -118,12 +119,12 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
} else {
wgpu::TextureViewDimension::D2
};
let buffer = horizon_shaders::buffer_index(name);
let buffer = horizon_render_pipeline::buffer_index(name);
let staged = textures.iter().position(|t| {
t.stage == stage(group)
&& t.dimension == dimension
&& (t.name == name
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
|| buffer.is_some() && horizon_render_pipeline::buffer_index(&t.name) == buffer)
});
staged.or_else(|| {
textures.iter().position(|t| {
@@ -28,7 +28,7 @@
//! [-w, w], and the depth buffers packs read hold GL window depth.
use glam::{DMat4, DVec3, Mat4, Vec3, Vec4};
use horizon_shaders::Shadow;
use horizon_render_pipeline::Shadow;
use super::Frame;
@@ -440,7 +440,7 @@ pub struct FrameUniforms {
center_depth: Option<f32>,
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
/// The pack's smoothing half-lives in ticks (`horizon_shaders::HALF_LIVES`).
/// The pack's smoothing half-lives in ticks (`horizon_render_pipeline::HALF_LIVES`).
pub half_lives: [Option<f32>; 4],
}
@@ -50,6 +50,98 @@ struct TaaResources {
uniform: wgpu::Buffer,
}
/// One frame handed to an upscaler backend: the jittered world at `render_size` and what
/// it needs to rebuild it at `output_size`. The motion is the camera's alone (`reproject`);
/// objects that move on their own carry no motion vectors yet.
// Only TAA reads it so far; a vendor backend reads the rest (#12918).
#[allow(dead_code)]
pub(super) struct UpscaleFrame<'a> {
/// The world's colour, rendered with this frame's jitter.
pub color: &'a wgpu::TextureView,
/// The world's depth, at `render_size`.
pub depth: &'a wgpu::TextureView,
pub render_size: [u32; 2],
pub output_size: [u32; 2],
/// The jitter in clip space, as added to the projection.
pub jitter_clip: [f32; 2],
/// The same jitter in rendered pixels, y down.
pub jitter_pixels: [f32; 2],
/// This frame's unjittered clip space to the previous frame's.
pub reproject: [[f32; 4]; 4],
/// The history is unusable: a cut, a resize, a frame without the upscaler.
pub reset: bool,
/// Frames jittered so far.
pub index: u32,
}
/// Something that turns the jittered low-resolution world into the output picture.
/// TAA (`TaaResources`) is the only backend; `Target` is the per-size state a backend
/// keeps beside the world target (TAA's history textures).
pub(super) trait UpscalerBackend {
type Target;
/// The name its GPU timing is shown under.
fn name(&self) -> &'static str;
/// Write this frame's output and return the bind group that shows it (`draw_onto`).
fn resolve<'t>(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
frame: &UpscaleFrame<'_>,
target: &'t Self::Target,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> &'t wgpu::BindGroup;
}
impl UpscalerBackend for TaaResources {
type Target = History;
fn name(&self) -> &'static str {
"TAA"
}
fn resolve<'t>(
&self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
frame: &UpscaleFrame<'_>,
taa: &'t History,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> &'t wgpu::BindGroup {
// The resolve bind group already samples `frame.color` and `frame.depth`.
let i = (frame.index % 2) as usize;
let uniform = TaaUniform {
reproject: frame.reproject,
jitter: [
frame.jitter_clip[0],
frame.jitter_clip[1],
if frame.reset { 1.0 } else { 0.0 },
0.0,
],
};
queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&uniform));
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &taa.history[i],
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon TAA"),
timestamp_writes: timing.and_then(|t| t.writes(self.name())),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
&taa.show[i]
}
}
pub struct Post {
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
@@ -563,33 +655,25 @@ impl Post {
self.finish_with(encoder, screen, &target.group, timing.as_deref_mut());
return;
};
let i = (self.taa_frame % 2) as usize;
queue.write_buffer(&res.uniform, 0, bytemuck::bytes_of(&self.taa_this_frame));
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &taa.history[i],
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon TAA"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("TAA")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&res.pipeline);
pass.set_bind_group(0, &taa.resolve[i], &[]);
pass.draw(0..3, 0..1);
drop(pass);
let size = self.size().unwrap_or([1, 1]);
let frame = UpscaleFrame {
color: &target.color,
depth: &target.depth,
render_size: size,
output_size: taa.size,
jitter_clip: [self.taa_this_frame.jitter[0], self.taa_this_frame.jitter[1]],
jitter_pixels: self.jitter_pixels(),
reproject: self.taa_this_frame.reproject,
reset: self.taa_this_frame.jitter[2] > 0.5,
index: self.taa_frame,
};
let shown = res.resolve(queue, encoder, &frame, taa, timing.as_deref_mut());
let pipeline = if self.upscale > 1.0 {
&self.sharpen
} else {
&self.copy
};
self.draw_onto(encoder, screen, &taa.show[i], pipeline, timing);
self.draw_onto(encoder, screen, shown, pipeline, timing);
}
/// Draw what `group` binds (`bind`) onto `screen`.
+155 -5
View File
@@ -5,9 +5,23 @@
use std::{fs, path::Path};
pub const FILE: &str = "horizon-graphics.txt";
/// Written beside it before HDR output is switched on and removed once the first HDR frame is
/// presented. Found at the next start, it means HDR output took the game down, so HDR starts off.
/// Written beside it before a window's HDR output is switched on, naming the graphics driver it
/// starts on ([`hdr_marker_text`]), and removed once the first HDR frame is presented. Found at
/// the next start, it means HDR output took the game down there: HDR output is held back on
/// that driver ([`Graphics::hdr_failed_start`]), and the mode stays as the player chose it.
pub const HDR_MARKER: &str = "horizon-hdr-pending";
/// The marker's text for a window whose HDR output starts on `driver`
/// (`gfx::output::window_driver`).
pub fn hdr_marker_text(driver: &str) -> String {
format!("driver:{driver}\n")
}
/// The driver a marker names; empty for one written before markers named it.
fn marker_driver(text: &str) -> &str {
text.lines()
.find_map(|line| line.strip_prefix("driver:"))
.unwrap_or_default()
.trim()
}
/// What the system reports for the display HDR output goes to, in nits in HDR output's
/// scale: where its SDR white sits (the desktop's white) and the peak. Each renderer keeps its
@@ -809,6 +823,11 @@ pub struct Graphics {
pub hdr_lighting: bool,
/// A vanilla key name (`HDR_KEY`): opens HDR's settings from the game. Unbound by default.
pub hdr_key: String,
/// The graphics driver whose last start ended before HDR output showed a frame (the
/// marker found, [`HDR_MARKER`]): a window on it stays SDR whatever the mode, until the
/// mode is changed or the window starts on another driver ([`Graphics::hdr_held`]).
/// Empty for a marker that named no driver: the first one seen holds it.
pub hdr_failed_start: Option<String>,
/// Battery mode: at most 60 frames per second in a world and 30 in menus
/// or behind other windows, and half the worker threads. Off by default;
/// it changes when frames are drawn, never what they show or the ticks.
@@ -884,6 +903,7 @@ impl Default for Graphics {
hdr_ui_brightness: 100,
hdr_lighting: true,
hdr_key: "key.keyboard.unknown".into(),
hdr_failed_start: None,
battery: false,
shadow_cache: false,
api: GraphicsApi::Auto,
@@ -909,15 +929,50 @@ impl Graphics {
Vec::new()
}
};
if fs::remove_file(dir.join(HDR_MARKER)).is_ok() && g.hdr != HdrMode::Off {
g.hdr = HdrMode::Off;
let marker = dir.join(HDR_MARKER);
if let Ok(text) = fs::read_to_string(&marker)
&& fs::remove_file(&marker).is_ok()
&& g.hdr != HdrMode::Off
{
g.hdr_failed_start = Some(marker_driver(&text).to_owned());
g.save(dir);
log::warn!(
"the last start ended before HDR output showed a frame; HDR is off now (graphics option hdr, in {FILE})"
"the last start ended before HDR output showed a frame; HDR output stays off with that graphics driver until the HDR mode is changed (graphics option hdrFailedStart, in {FILE})"
);
}
g
}
/// Whether HDR output is held back on a window whose graphics driver is `driver`
/// (`gfx::output::window_driver`): the last start on it ended before HDR output
/// showed a frame ([`Graphics::hdr_failed_start`]).
pub fn hdr_held(&self, driver: &str) -> bool {
self.hdr_failed_start
.as_deref()
.is_some_and(|failed| failed.is_empty() || failed == driver)
}
/// The window's renderer runs on `driver`: a failed start that named no driver is this
/// one's now, and another driver's (a driver update, another GPU) is dropped, so HDR output
/// is tried again. True when it changed.
pub fn settle_failed_start(&mut self, driver: &str) -> bool {
match self.hdr_failed_start.as_deref() {
Some("") => self.hdr_failed_start = Some(driver.to_owned()),
Some(failed) if failed != driver => self.hdr_failed_start = None,
_ => return false,
}
true
}
/// `before`, as Undo brings it back, but never the hold on a driver HDR output failed to
/// start on: that stays as it is now, and goes when Undo changes the mode, as any change of
/// the mode drops it.
pub fn undone_to(&self, before: &Self) -> Self {
Self {
hdr_failed_start: self
.hdr_failed_start
.clone()
.filter(|_| self.hdr == before.hdr),
..before.clone()
}
}
pub fn parse(text: &str) -> Self {
let mut g = Self::default();
for line in text.lines() {
@@ -1026,6 +1081,7 @@ impl Graphics {
g.hdr_ui_brightness = number(g.hdr_ui_brightness).clamp(25, 100)
}
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"hdrFailedStart" => g.hdr_failed_start = Some(value.to_owned()),
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"batchTranslucent" => g.translucent_batching = flag(g.translucent_batching),
@@ -1104,6 +1160,9 @@ impl Graphics {
bool_text(self.hdr_smooth_gradients),
self.gpu_culling.key(),
);
if let Some(driver) = &self.hdr_failed_start {
text += &format!("hdrFailedStart:{driver}\n");
}
text += &self.blue_map.serialize();
text
}
@@ -1243,6 +1302,85 @@ mod tests {
}
}
fn scratch(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"horizon-{name}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
dir
}
/// A start that finds the marker (HDR output took the last one down) keeps the mode the
/// player chose and saves the driver the marker names; HDR Off saves nothing.
#[test]
fn a_marker_keeps_the_mode_and_holds_the_driver_it_names() {
let driver = "Vulkan 10de:2684 NVIDIA GeForce RTX 4090; NVIDIA; 581.57";
for mode in HdrMode::ALL {
let dir = scratch(&format!("hdr-marker-{mode:?}"));
let saved = Graphics {
hdr: mode,
..Graphics::default()
};
saved.save(&dir);
fs::write(dir.join(HDR_MARKER), hdr_marker_text(driver)).unwrap();
let g = Graphics::load(&dir);
let failed = (mode != HdrMode::Off).then(|| driver.to_owned());
assert_eq!((g.hdr, &g.hdr_failed_start), (mode, &failed), "{mode:?}");
assert!(!dir.join(HDR_MARKER).exists(), "{mode:?}");
// Saved for good: the next start, without a marker, reads the same.
let again = Graphics::load(&dir);
assert_eq!((again.hdr, again.hdr_failed_start), (mode, failed));
let _ = fs::remove_dir_all(&dir);
}
}
/// Today's marker names no driver: it holds whichever driver the window starts on first,
/// and is that driver's from then on.
#[test]
fn a_marker_without_a_driver_holds_the_first_one_seen() {
let dir = scratch("hdr-marker-legacy");
Graphics {
hdr: HdrMode::Auto,
..Graphics::default()
}
.save(&dir);
fs::write(dir.join(HDR_MARKER), "HDR output was being switched on\n").unwrap();
let mut g = Graphics::load(&dir);
assert_eq!(
(g.hdr, g.hdr_failed_start.as_deref()),
(HdrMode::Auto, Some(""))
);
let (first, second) = (
"Vulkan 10de:2684 GPU; NVIDIA; 1",
"Dx12 1002:744c GPU; AMD; 2",
);
assert!(g.hdr_held(first) && g.hdr_held(second));
assert!(g.settle_failed_start(first));
assert!(g.hdr_held(first) && !g.hdr_held(second));
assert!(!g.settle_failed_start(first));
let _ = fs::remove_dir_all(&dir);
}
/// The window starting on another driver (an update, another GPU) drops the hold, so HDR
/// output is tried again; on the same driver it stays.
#[test]
fn another_driver_drops_the_hold() {
let held = "Vulkan 10de:2684 GPU; NVIDIA; 581.57";
let mut g = Graphics {
hdr: HdrMode::On,
hdr_failed_start: Some(held.into()),
..Graphics::default()
};
assert!(!g.settle_failed_start(held));
assert!(g.hdr_held(held));
assert!(g.settle_failed_start("Vulkan 10de:2684 GPU; NVIDIA; 582.02"));
assert_eq!((g.hdr, g.hdr_failed_start), (HdrMode::On, None));
assert!(!Graphics::default().hdr_held(held));
}
#[test]
fn round_trips() {
let g = Graphics {
@@ -1295,6 +1433,9 @@ mod tests {
hdr_ui_brightness: 60,
hdr_lighting: false,
hdr_key: "key.keyboard.h".into(),
hdr_failed_start: Some(
"Vulkan 10de:2684 NVIDIA GeForce RTX 4090; NVIDIA; 581.57".into(),
),
battery: true,
shadow_cache: true,
api: GraphicsApi::Dx12,
@@ -1303,6 +1444,15 @@ mod tests {
shader_pack: "Golden Hour".into(),
};
assert_eq!(Graphics::parse(&g.serialize()), g);
// A legacy marker's hold, which names no driver, round-trips too; none writes no line.
for failed in [Some(String::new()), None] {
let graphics = Graphics {
hdr_failed_start: failed,
..Graphics::default()
};
assert_eq!(Graphics::parse(&graphics.serialize()), graphics);
}
assert!(!Graphics::default().serialize().contains("hdrFailedStart"));
for mode in EntityCullingMode::ALL {
let graphics = Graphics {
entity_culling: mode,
@@ -708,6 +708,10 @@ fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
Setup::Changed
}
}
/// What the HDR page says while HDR output is held back on this window's graphics driver
/// (`EffectiveOutput::held`): picking a mode tries again (`H::cycle`).
const HDR_HELD: &str = "HDR output stopped the game the last time it started with this graphics driver. Choose a mode to try again.";
/// The description beside `item` where it depends on [`setup`]; `text` otherwise.
fn description(
item: Item,
@@ -716,6 +720,7 @@ fn description(
text: &'static str,
) -> &'static str {
match (item, setup(s, out)) {
(Item::Horizon(H::Hdr), _) if out.held => HDR_HELD,
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
"Your display didn't say how bright it gets: calibrate to set it up."
}
@@ -1086,7 +1091,9 @@ impl H {
H::FrameGeneration => {
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
}
// Any choice tries HDR output again on a driver a start failed on.
H::Hdr => {
g.hdr_failed_start = None;
g.hdr = step(&HdrMode::ALL, g.hdr, back);
if g.hdr == HdrMode::On && !out.hdr_offered() {
g.hdr = step(&HdrMode::ALL, g.hdr, back);
@@ -1155,7 +1162,11 @@ impl Item {
| H::HdrUiBrightness
| H::HdrLighting
| H::HdrSmoothGradients,
) if !out.requests_hdr(s.horizon.hdr) => Some("Turn HDR on to use it."),
) if !out.requests_hdr(s.horizon.hdr) => Some(if out.held {
HDR_HELD
} else {
"Turn HDR on to use it."
}),
Item::Horizon(H::TranslucentBatching)
if !crate::graphics::translucent_batching_offered() =>
{
@@ -1522,7 +1533,7 @@ impl Screen for GraphicsScreen {
/// one `horizon-ctl options-set` changed meanwhile), nor fullscreen, which F11 and the
/// window manager change.
fn undo(&mut self, s: &mut Settings, b: &Settings) -> Vec<Output> {
s.horizon = b.horizon.clone();
s.horizon = s.horizon.undone_to(&b.horizon);
s.render_distance = b.render_distance;
s.simulation_distance = b.simulation_distance;
s.gamma = b.gamma;
@@ -134,3 +134,68 @@ fn hdr_setup_follows_what_the_system_reported() {
assert_eq!(setup_of(&g, true), Setup::Changed);
assert_eq!(setup_of(&g, false), Setup::Unknown);
}
/// While HDR output is held back on this window's driver, the HDR row says so and keeps
/// the mode; any change of the mode drops the hold, and HDR is asked for again.
#[test]
fn a_held_hdr_start_is_said_on_the_page_and_a_mode_change_tries_again() {
use crate::gfx::{HdrOutput, output::tests};
let mut s = Settings::default();
s.horizon.hdr = HdrMode::Auto;
s.horizon.hdr_failed_start = Some("Vulkan 10de:2684 GPU; driver; 1.0".into());
let (mut caps, reading) = tests::window(Some(HdrOutput::Pq), Some(true));
caps.held = true;
let out = tests::snapshot(&s.horizon, Default::default(), caps, reading);
let hdr = Item::Horizon(H::Hdr);
let setting = h(
H::Hdr,
Cost::None,
"Lava, fire and sunlight shine brighter than white.",
);
let held = super::row(setting, &s, &out);
assert_eq!(held.chosen.as_deref(), Some(HDR_HELD));
assert_eq!(held.why, None);
assert_eq!(hdr.label(&s, &out).plain(), "HDR: Auto");
assert_eq!(Item::Horizon(H::HdrPeak).blocked(&s, &out), Some(HDR_HELD));
// Not held, the row says what HDR does.
caps.held = false;
let free = tests::snapshot(&s.horizon, Default::default(), caps, reading);
assert_eq!(super::row(setting, &s, &free).chosen, None);
// Any step of the mode, either way, tries again.
for back in [false, true] {
let mut s = s.clone();
hdr.cycle(&mut s, back, FB, &out);
assert_ne!(s.horizon.hdr, HdrMode::Auto);
assert_eq!(s.horizon.hdr_failed_start, None);
}
}
/// Undo never brings the hold back: held Auto, changed to On (HDR tried again), then
/// undone, is Auto without the hold. With the mode left alone, it keeps the hold as it is.
#[test]
fn undo_never_restores_a_held_hdr_start() {
let mut screen = GraphicsScreen {
framebuffer: FB,
output: NONE,
};
let mut opened = Settings::default();
opened.horizon.hdr = HdrMode::Auto;
opened.horizon.hdr_failed_start = Some("Vulkan 10de:2684 GPU; driver; 1.0; os".into());
let mut s = opened.clone();
let out = offering(true, Some(true));
Item::Horizon(H::Hdr).cycle(&mut s, true, FB, &out);
assert_eq!(
(s.horizon.hdr, &s.horizon.hdr_failed_start),
(HdrMode::On, &None)
);
screen.undo(&mut s, &opened);
assert_eq!(
(s.horizon.hdr, &s.horizon.hdr_failed_start),
(HdrMode::Auto, &None)
);
// Another row changed and undone: the hold stays.
let mut s = opened.clone();
s.horizon.fxaa = !s.horizon.fxaa;
screen.undo(&mut s, &opened);
assert_eq!(s.horizon, opened.horizon);
}
@@ -17,6 +17,7 @@
//! Nothing changes until Save, which writes the three into Horizon's settings; Cancel
//! or Escape leaves them as they were.
use crate::{
font::Font,
hud::{HudAtlas, HudBatch, rgba},
lang,
settings::Settings,
@@ -33,6 +34,10 @@ pub enum Step {
}
const STEPS: [Step; 3] = [Step::Black, Step::Peak, Step::PaperWhite];
/// A step's test picture for HDR output: the step, its nits and where the controls start (a
/// share of the height).
pub type Picture = (Step, f32, f32);
/// What the options screen does after an input here.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Outcome {
@@ -43,6 +48,30 @@ pub enum Outcome {
Cancel,
}
/// The window as calibration found it: calibration turns it fullscreen for its test pictures,
/// and gives it back as it was.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WindowState {
Fullscreen,
Maximized,
Windowed,
}
impl WindowState {
pub fn of(fullscreen: bool, maximized: bool) -> Self {
match (fullscreen, maximized) {
(true, _) => Self::Fullscreen,
(false, true) => Self::Maximized,
(false, false) => Self::Windowed,
}
}
/// On leaving calibration: whether the window leaves fullscreen, and whether it is
/// maximized again after (leaving fullscreen gives back its restored size on Windows).
pub fn leaving(self) -> (bool, bool) {
(self != Self::Fullscreen, self == Self::Maximized)
}
}
pub struct Calibration {
step: usize,
/// Nits, in the order of `STEPS`.
@@ -53,8 +82,11 @@ pub struct Calibration {
dragging: bool,
}
/// Height of the band of controls at the bottom, in GUI pixels.
/// Height of the band of controls at the bottom with a hint of one line, in GUI pixels.
const BAND: f32 = 84.0;
/// Each further line of the hint, which wraps within the window less this at each side.
const HINT_LINE: f32 = 10.0;
const HINT_MARGIN: f32 = 10.0;
impl Step {
/// The slider's steps past its first: the settings' own.
@@ -115,18 +147,24 @@ impl Step {
struct Layout {
top: f32,
hint: Vec<Text>,
slider: (f32, f32, f32),
cancel: (f32, f32, f32),
back: (f32, f32, f32),
next: (f32, f32, f32),
}
fn layout(w: f32, h: f32) -> Layout {
let top = (h - BAND).floor();
/// The band grows up by the lines `step`'s hint takes past its first.
fn layout(font: &Font, step: Step, (w, h): (f32, f32)) -> Layout {
let hint = Text::literal(lang::get(step.hint_key()));
let hint = font.split(&hint, w - 2.0 * HINT_MARGIN);
let more = hint.len().saturating_sub(1) as f32 * HINT_LINE;
let top = (h - BAND - more).floor();
let cx = (w / 2.0).floor();
let by = top + 58.0;
let by = top + more + 58.0;
Layout {
top,
slider: (cx - 100.0, top + 32.0, 200.0),
hint,
slider: (cx - 100.0, top + more + 32.0, 200.0),
cancel: (cx - 152.0, by, 98.0),
back: (cx - 49.0, by, 98.0),
next: (cx + 54.0, by, 98.0),
@@ -159,6 +197,9 @@ impl Calibration {
fn current(&self) -> Step {
STEPS[self.step]
}
fn layout(&self, font: &Font, size: (f32, f32)) -> Layout {
layout(font, self.current(), size)
}
/// The slider's position, 0..=1.
fn position(&self) -> f32 {
let step = self.current();
@@ -184,18 +225,17 @@ impl Calibration {
};
Text::literal(format!("{}: {}", step.name(), value))
}
/// The step, its nits and where the controls start (a share of the height), for
/// HDR output's test picture.
pub fn picture(&self, h: f32) -> (Step, f32, f32) {
/// The current step's test picture in a GUI of `size`.
pub fn picture(&self, font: &Font, size: (f32, f32)) -> Picture {
(
self.current(),
self.values[self.step],
layout(0.0, h).top / h.max(1.0),
self.layout(font, size).top / size.1.max(1.0),
)
}
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, cursor: (f32, f32)) {
let (w, h) = (batch.width, batch.height);
let l = layout(w, h);
let l = self.layout(&atlas.font, (w, h));
// Above the band HDR output draws the test picture over whatever is here.
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
@@ -205,16 +245,10 @@ impl Calibration {
&[&(self.step + 1), &STEPS.len(), &step.name()],
));
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
let hint = Text::literal(lang::get(step.hint_key()));
ui::centered_text(
batch,
atlas,
&hint,
0.0,
l.top + 18.0,
w,
rgba(160, 160, 160, 255),
);
for (i, line) in l.hint.iter().enumerate() {
let y = l.top + 18.0 + i as f32 * HINT_LINE;
ui::centered_text(batch, atlas, line, 0.0, y, w, rgba(160, 160, 160, 255));
}
let (x, y, sw) = l.slider;
ui::slider(
batch,
@@ -261,8 +295,14 @@ impl Calibration {
}
}
/// A press, or a drag with `drag`, at `cursor`.
pub fn click(&mut self, cursor: (f32, f32), (w, h): (f32, f32), drag: bool) -> Outcome {
let l = layout(w, h);
pub fn click(
&mut self,
font: &Font,
cursor: (f32, f32),
size: (f32, f32),
drag: bool,
) -> Outcome {
let l = self.layout(font, size);
let (x, _, sw) = l.slider;
if drag {
if !self.dragging {
@@ -336,6 +376,14 @@ mod tests {
const NONE: EffectiveOutput = EffectiveOutput::NONE;
fn font() -> Font {
Font::metrics(std::path::Path::new("/nonexistent/horizon-test-assets"))
}
/// At the default GUI scale of the game's first window (854 x 480 at 2).
fn picture(c: &Calibration) -> Picture {
c.picture(&font(), (427.0, 240.0))
}
#[test]
fn calibration_saves_only_on_save() {
let mut s = Settings::default();
@@ -344,14 +392,14 @@ mod tests {
let mut c = Calibration::new(&s, &NONE);
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
assert_eq!(c.key(KeyCode::ArrowLeft), Outcome::Changed);
assert!((c.picture(100.0).1 - 0.03).abs() < 1e-6);
assert!((picture(&c).1 - 0.03).abs() < 1e-6);
assert_eq!(c.key(KeyCode::Escape), Outcome::Cancel);
assert_eq!(s.horizon, before);
// Black, then Peak (one step up), then Paper White, then Save.
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
assert_eq!(c.key(KeyCode::ArrowRight), Outcome::Changed);
assert_eq!(c.key(KeyCode::Enter), Outcome::Click);
assert_eq!(c.picture(100.0).0, Step::PaperWhite);
assert_eq!(picture(&c).0, Step::PaperWhite);
assert_eq!(c.key(KeyCode::Enter), Outcome::Save);
c.save(&mut s);
assert_eq!(s.horizon.hdr_black_level, 3);
@@ -370,10 +418,10 @@ mod tests {
for (output, peak, paper_white) in [(&reporting, 1000.0, 400.0), (&NONE, 1000.0, 203.0)] {
let mut c = Calibration::new(&s, output);
c.key(KeyCode::Enter);
assert_eq!(c.picture(1.0).0, Step::Peak);
assert_eq!(c.picture(1.0).1, peak);
assert_eq!(picture(&c).0, Step::Peak);
assert_eq!(picture(&c).1, peak);
c.key(KeyCode::Enter);
assert_eq!(c.picture(1.0).1, paper_white);
assert_eq!(picture(&c).1, paper_white);
}
}
@@ -392,14 +440,14 @@ mod tests {
];
let mut c = Calibration::new(&Settings::default(), &NONE);
for (i, expected) in expected.iter().enumerate() {
assert_eq!(c.picture(1.0).0, STEPS[i]);
assert_eq!(picture(&c).0, STEPS[i]);
for _ in 0..200 {
c.key(KeyCode::ArrowLeft);
}
let mut seen = vec![c.picture(1.0).1];
let mut seen = vec![picture(&c).1];
for _ in 0..200 {
c.key(KeyCode::ArrowRight);
let v = c.picture(1.0).1;
let v = picture(&c).1;
if seen.last() != Some(&v) {
seen.push(v);
}
@@ -422,7 +470,7 @@ mod tests {
assert_eq!(s.horizon.hdr_black_level, 2);
// Calibrated back down, 0 is no lift: what the step showed.
let mut c = Calibration::new(&s, &NONE);
assert!((c.picture(1.0).1 - 0.02).abs() < 1e-6);
assert!((picture(&c).1 - 0.02).abs() < 1e-6);
c.key(KeyCode::ArrowLeft);
c.key(KeyCode::ArrowLeft);
c.key(KeyCode::Enter);
@@ -435,14 +483,89 @@ mod tests {
assert_eq!(s.horizon.hdr_peak, 10_000);
let mut c = Calibration::new(&s, &NONE);
c.key(KeyCode::Enter);
assert_eq!(c.picture(1.0).1, 10_000.0);
assert_eq!(picture(&c).1, 10_000.0);
// One step down from the top is 500 nits; from 2000, 50.
c.key(KeyCode::ArrowLeft);
assert_eq!(c.picture(1.0).1, 9500.0);
assert_eq!(picture(&c).1, 9500.0);
s.horizon.hdr_peak = 2000;
let mut c = Calibration::new(&s, &NONE);
c.key(KeyCode::Enter);
c.key(KeyCode::ArrowLeft);
assert_eq!(c.picture(1.0).1, 1950.0);
assert_eq!(picture(&c).1, 1950.0);
}
/// A hint wider than the window less its margins wraps, the band grows up by the lines
/// it adds, and the test picture ends where the band drawn starts.
#[test]
fn the_hint_wraps_and_the_band_grows_by_its_lines() {
let font = font();
let mut c = Calibration::new(&Settings::default(), &NONE);
c.key(KeyCode::Enter);
c.key(KeyCode::Enter);
let hint = Text::literal(lang::get(Step::PaperWhite.hint_key()));
let wide = font.width_text(&hint);
let size = (wide + 2.0 * HINT_MARGIN, 240.0);
let one = c.layout(&font, size);
assert_eq!(one.hint.len(), 1);
assert_eq!(one.top, 240.0 - BAND);
let narrow = (wide * 0.6, 240.0);
let wrapped = c.layout(&font, narrow);
assert_eq!(wrapped.hint.len(), 2);
assert!(
wrapped
.hint
.iter()
.all(|l| font.width_text(l) <= narrow.0 - 2.0 * HINT_MARGIN)
);
assert_eq!(wrapped.top, 240.0 - BAND - HINT_LINE);
// Under the hint's lines, the controls keep their place in the band.
assert_eq!(
wrapped.slider.1 - wrapped.top,
one.slider.1 - one.top + HINT_LINE
);
assert_eq!(
wrapped.next.1 - wrapped.top,
one.next.1 - one.top + HINT_LINE
);
for size in [size, narrow] {
let (_, _, band) = c.picture(&font, size);
assert_eq!(band * size.1, c.layout(&font, size).top);
}
}
/// The Peak step's patch (a square of a tenth of the screen, centred above the band,
/// `hdr.wgsl`) stays above the band even on the narrowest GUI with every hint wrapped:
/// its side is no more than the band's top.
#[test]
fn the_test_pictures_stay_above_a_grown_band() {
let font = font();
let mut c = Calibration::new(&Settings::default(), &NONE);
for step in STEPS {
for (w, h) in [(320.0, 240.0), (200.0, 240.0), (427.0, 240.0)] {
let (shown, _, band) = c.picture(&font, (w, h));
assert_eq!(shown, step);
let side = (0.1 * w * h).sqrt();
assert!(
side <= band * h,
"{step:?} at {w}x{h}: {side} > {}",
band * h
);
}
c.key(KeyCode::Enter);
}
}
/// Leaving calibration gives the window back as it found it.
#[test]
fn leaving_gives_the_window_back_as_it_was() {
use WindowState as W;
assert_eq!(W::of(true, false), W::Fullscreen);
assert_eq!(W::of(true, true), W::Fullscreen);
assert_eq!(W::of(false, true), W::Maximized);
assert_eq!(W::of(false, false), W::Windowed);
// (leave fullscreen, maximize again)
assert_eq!(W::Fullscreen.leaving(), (false, false));
assert_eq!(W::Maximized.leaving(), (true, true));
assert_eq!(W::Windowed.leaving(), (true, false));
}
}
+6 -31
View File
@@ -100,13 +100,13 @@ pub struct HudAtlas {
pub glint_armor: (Vec<u8>, u32, u32),
/// A resource pack's own `core/text` shader for the GUI's text, translated;
/// none draws the text natively.
pub text_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
pub text_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
/// A resource pack's own `core/rendertype_lines` shader for the block outline, translated;
/// none draws the outline natively.
pub lines_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
pub lines_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
/// The packs' `core/sky` and `core/stars`, when a pack replaces them.
pub sky_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
pub stars_shader: Option<std::sync::Arc<horizon_shaders::CoreProgram>>,
pub sky_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
pub stars_shader: Option<std::sync::Arc<crate::core_shader::CoreProgram>>,
/// The packs' Polytone screen sprite modifiers.
pub sprite_modifiers: std::sync::Arc<sprite_modifiers::SpriteModifiers>,
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
@@ -1427,32 +1427,7 @@ impl HudAtlas {
}
if full {
// Official Recipes artwork from Just Enough Items, under MIT.
for (name, png) in [
(
"button_enabled_v2",
include_bytes!("jei_recipes/button_enabled_v2.png").as_slice(),
),
(
"button_disabled_v2",
include_bytes!("jei_recipes/button_disabled_v2.png").as_slice(),
),
(
"button_highlight_v2",
include_bytes!("jei_recipes/button_highlight_v2.png").as_slice(),
),
(
"icons/arrow_previous_v2",
include_bytes!("jei_recipes/icons/arrow_previous_v2.png").as_slice(),
),
(
"icons/arrow_next_v2",
include_bytes!("jei_recipes/icons/arrow_next_v2.png").as_slice(),
),
(
"search_background_v2",
include_bytes!("jei_recipes/search_background_v2.png").as_slice(),
),
] {
for (name, png) in crate::jei_sprites::SPRITES {
let (width, height, rgba) = decode_bytes(png)?;
images.push((format!("horizon/jei/{name}"), width, height, rgba));
}
@@ -2895,7 +2870,7 @@ impl HudRenderer {
pub fn set_text_shader(
&mut self,
device: &wgpu::Device,
shader: Option<&horizon_shaders::CoreProgram>,
shader: Option<&crate::core_shader::CoreProgram>,
) {
self.pack_text = shader.and_then(|shader| {
pack_text::PackText::new(device, self.format, &self.atlas_view, shader, self.coverage)
@@ -393,7 +393,7 @@ fn coverage_uses_pack_texts_final_alpha_and_discard() {
return;
};
// A translated-style struct result and early return; no dependency on game resources.
let program = horizon_shaders::CoreProgram {
let program = crate::core_shader::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p.x / 2.0 - 1.0, 1.0 - p.y, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("struct Result { @location(0) color: vec4<f32> } @fragment fn main(@builtin(position) p: vec4<f32>) -> Result { if p.x > 2.0 { discard; } return Result(vec4(0.4, 0.2, 0.1, 0.5)); }").unwrap(),
};
@@ -414,7 +414,7 @@ fn a_pack_text_shader_without_a_colour_output_is_dropped_not_wrapped() {
let Some(mut gpu) = Gpu::new() else {
return;
};
let program = horizon_shaders::CoreProgram {
let program = crate::core_shader::CoreProgram {
vertex: naga::front::wgsl::parse_str("@vertex fn main(@location(0) p: vec2<f32>) -> @builtin(position) vec4<f32> { return vec4(p, 0.0, 1.0); }").unwrap(),
fragment: naga::front::wgsl::parse_str("@fragment fn main() -> @location(1) vec4<f32> { return vec4(1.0); }").unwrap(),
};
@@ -5,7 +5,7 @@
use std::path::Path;
use std::sync::Arc;
use horizon_shaders::{CoreProgram, Glue};
use crate::core_shader::{CoreProgram, Glue};
use super::{HudBatch, HudVertex};
use crate::gfx::hud_coverage::Pipeline;
@@ -64,7 +64,7 @@ pub(super) fn load_core(
let path = format!("assets/{namespace}/{path}");
read(root, &path).or_else(|| read(defaults, &path))
};
match horizon_shaders::translate_core(&vertex, &fragment, defines, &include, glue) {
match crate::core_shader::translate_core(&vertex, &fragment, defines, &include, glue) {
Ok(program) => {
log::info!("the resource pack's {name} shader is used");
Some(Arc::new(program))
@@ -132,7 +132,7 @@ impl PackText {
count: None,
})
.collect();
for (_, binding) in horizon_shaders::SAMPLERS {
for (_, binding) in crate::core_shader::SAMPLERS {
entries.push(wgpu::BindGroupLayoutEntry {
binding,
visibility,
@@ -175,7 +175,7 @@ impl PackText {
}),
})
.collect();
for (_, binding) in horizon_shaders::SAMPLERS {
for (_, binding) in crate::core_shader::SAMPLERS {
resources.push(wgpu::BindGroupEntry {
binding,
resource: wgpu::BindingResource::TextureView(atlas),
@@ -0,0 +1,30 @@
//! The official Recipes viewer's artwork from Just Enough Items, under MIT (`jei_recipes/LICENSE.txt`,
//! THIRD_PARTY_NOTICES.md): `HudAtlas` loads each as the sprite `horizon/jei/<name>`.
/// Each sprite's name and its PNG.
pub(crate) const SPRITES: &[(&str, &[u8])] = &[
(
"button_enabled_v2",
include_bytes!("jei_recipes/button_enabled_v2.png"),
),
(
"button_disabled_v2",
include_bytes!("jei_recipes/button_disabled_v2.png"),
),
(
"button_highlight_v2",
include_bytes!("jei_recipes/button_highlight_v2.png"),
),
(
"icons/arrow_previous_v2",
include_bytes!("jei_recipes/icons/arrow_previous_v2.png"),
),
(
"icons/arrow_next_v2",
include_bytes!("jei_recipes/icons/arrow_next_v2.png"),
),
(
"search_background_v2",
include_bytes!("jei_recipes/search_background_v2.png"),
),
];
+2
View File
@@ -38,6 +38,7 @@ pub mod command_block;
pub mod connected;
pub mod containers;
pub mod controller_ui;
pub mod core_shader;
pub mod crash;
pub mod custom_sky;
pub mod datapack;
@@ -62,6 +63,7 @@ pub mod hud_layout;
pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
mod jei_sprites;
pub mod jigsaw_block;
pub mod keypad;
pub mod terrain_model;
@@ -8,6 +8,7 @@ pub use crate::settings::Opt;
use crate::{
gamepad::{self, GlyphStyle},
hdr_calibration::Picture,
hud::{HudAtlas, HudBatch, rgba},
keybinds::{self, CATEGORIES, KeyBinds, all_mappings},
lang::{self, LanguageInfo},
@@ -1682,8 +1683,8 @@ pub struct OptionsUi {
pub split_profiles: crate::split_profiles::SplitProfiles,
/// HDR calibration while its page is open.
pub calibration: Option<crate::hdr_calibration::Calibration>,
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
pub calibration_fullscreen: Option<bool>,
/// The window as calibration found it before making it fullscreen (the game keeps it).
pub calibration_window: Option<crate::hdr_calibration::WindowState>,
pub split_setup: crate::split_ui::SplitUi,
pub studio: crate::studio_ui::StudioUi,
/// The AI Agent page's status lines (set by the game while it shows).
@@ -1762,7 +1763,7 @@ impl Default for OptionsUi {
pack_configs: Default::default(),
split_profiles: Default::default(),
calibration: None,
calibration_fullscreen: None,
calibration_window: None,
split_setup: Default::default(),
studio: Default::default(),
agent_status: Vec::new(),
@@ -4030,7 +4031,7 @@ impl OptionsUi {
if right {
return Vec::new();
}
let outcome = c.click((x, y), size, drag);
let outcome = c.click(&atlas.font, (x, y), size, drag);
return self.calibration_outcome(s, outcome);
}
if self.shell().is_some() {
@@ -4632,13 +4633,12 @@ impl OptionsUi {
}
}
}
/// HDR calibration's test picture, while its page is open: the step, its nits and
/// where the controls start (a share of the height `h`).
pub fn calibration_picture(&self, h: f32) -> Option<(crate::hdr_calibration::Step, f32, f32)> {
/// HDR calibration's test picture, while its page is open, in a GUI of `size`.
pub fn calibration_picture(&self, atlas: &HudAtlas, size: (f32, f32)) -> Option<Picture> {
self.calibration
.as_ref()
.filter(|_| self.page() == Page::HdrCalibration)
.map(|c| c.picture(h))
.map(|c| c.picture(&atlas.font, size))
}
pub fn back(&mut self, s: &mut Settings) -> Vec<Event> {
let mut events = Vec::new();
@@ -143,3 +143,34 @@ fn the_graphics_screens_wheel_scrolls_even_over_a_slider_being_changed() {
assert_eq!(s.horizon.chunk_updates, 16);
assert!(ui.graphics.scroll_amount() > before);
}
/// A window holding HDR output back (a start failed on its driver) shows it on the HDR page
/// as soon as the graphics screen opens, with no setting applied: the row's line, and
/// Calibrate blocked, where the same window not holding it back calibrates.
#[test]
fn a_held_hdr_start_shows_as_the_graphics_screen_opens() {
use crate::gfx::output::tests::{snapshot, window};
let atlas = atlas();
let mut s = Settings::default();
s.horizon.hdr = crate::graphics::HdrMode::Auto;
s.horizon.hdr_failed_start = Some("Vulkan 10de:2684 GPU; driver; 1.0; os".into());
let (mut caps, reading) = window(Some(crate::gfx::HdrOutput::Pq), Some(true));
for held in [true, false] {
caps.held = held;
let mut ui = OptionsUi {
size: (427.0, 240.0),
..Default::default()
};
let output = snapshot(&s.horizon, Default::default(), caps, reading);
ui.draw(
&s,
output,
&mut HudBatch::new([427, 240]),
&atlas,
(-1.0, -1.0),
);
ui.show(Page::Graphics);
assert!(calibrates(&mut ui, &mut s, &atlas) != held, "held {held}");
assert_eq!(ui.graphics.screen.output.held, held);
}
}
+208 -12
View File
@@ -8,8 +8,8 @@
//!
//! [`TouchControls`] turns fingers into the controller state the game already
//! reads ([`PadState`]). Each finger gets a role when it lands (the joystick or
//! D-pad, a button, or a camera drag) and keeps it until it lifts, so moving,
//! looking and pressing never take each other's fingers.
//! D-pad, a button, or a look gesture on the world) and keeps it until it
//! lifts, so moving, looking and pressing never take each other's fingers.
pub mod aim;
pub mod layout;
pub use aim::{
@@ -22,6 +22,10 @@ use crate::gamepad::{Button, PadState};
use std::collections::BTreeMap;
use std::time::{Duration, Instant};
/// A world touch held this long without dragging becomes a hold (mining, eating).
const HOLD_DELAY: Duration = Duration::from_millis(350);
/// How long the marker of a short world tap stays.
const MARKER_TIME: Duration = Duration::from_millis(180);
/// Two taps on the D-pad's sneak button closer than this toggle crouching.
const DOUBLE_TAP: Duration = Duration::from_millis(300);
/// A look finger that moves farther than this (a fraction of the surface
@@ -35,8 +39,19 @@ const SWAP_HANDS: &str = "action:key.swapOffhand";
enum Role {
/// Drives the joystick or D-pad, measured from this anchor.
Move([f32; 2]),
/// Landed on the world: turns the camera once it moves past [`DRAG_SLOP`].
Look { start: [f32; 2], dragged: bool },
/// Turns the camera; without a drag, a short tap or a long hold acts on
/// the world where it landed.
Look {
start: [f32; 2],
since: Instant,
/// Moved past [`DRAG_SLOP`]: a camera turn, not a tap.
dragged: bool,
/// Whether a hold uses the item (eat, draw a bow) instead of mining,
/// as [`TouchControls::hold_uses_item`] said when the finger landed.
held_use: bool,
/// Became a hold before it dragged; it keeps acting while it turns.
committed: bool,
},
/// Landed on a control that acted at once; nothing more until it lifts.
Ignore,
/// Holds the shield up.
@@ -52,6 +67,8 @@ pub struct TouchControls {
pub settings: Settings,
/// The game needs the crosshair whatever the scheme.
pub force_crosshair: bool,
/// The held item is aimed along the crosshair ([`projectile_crosshair`]).
pub projectile_crosshair: bool,
/// The player has a shield, so the shield button may show.
pub shield_available: bool,
/// Only the shortcut buttons (chat, pause, settings, F3, F5): a keyboard
@@ -64,6 +81,16 @@ pub struct TouchControls {
pub vertical_movement: bool,
pub swimming: bool,
pub mounted: bool,
/// A long hold on the world uses the held item instead of mining. Read when
/// a finger lands, so a change mid-hold (the last apple eaten) can't turn
/// eating into mining.
pub hold_uses_item: bool,
/// Where this tick's world action aims (0..1 of the surface), set by
/// [`Self::poll_state`].
pub aim: Option<[f32; 2]>,
/// A world hold is active this tick, and whether it uses the item.
pub holding: bool,
pub holding_use: bool,
/// A button was pressed; the game plays the click and clears it.
pub clicked: bool,
pub chat_requested: bool,
@@ -75,6 +102,10 @@ pub struct TouchControls {
pending: PadState,
/// Key or action presses for the game's key bindings: (token, down).
key_events: Vec<(String, bool)>,
tap_aim: Option<[f32; 2]>,
/// A short world tap waiting for the game, with the finger's `held_use`.
pending_tap: Option<bool>,
last_marker: Option<([f32; 2], Instant)>,
last_sneak_tap: Option<Instant>,
sprint_latched: bool,
aspect: f32,
@@ -99,6 +130,27 @@ impl TouchControls {
}
}
/// Where a dynamic joystick may appear, as `[x, y, w, h]`: a strip
/// `dynamic_area_x` of the width wide on the joystick's side, and
/// `dynamic_area_y` of the height tall centred on it. Touches outside it
/// stay camera gestures.
pub fn dynamic_area(&self) -> [f32; 4] {
let home = self.placement(Control::Move);
let width = self.settings.dynamic_area_x.clamp(0.0, 1.0);
let height = self.settings.dynamic_area_y.clamp(0.0, 1.0);
let y = (home.center[1] - height / 2.0).clamp(0.0, 1.0 - height);
let x = if home.center[0] < 0.5 {
0.0
} else {
1.0 - width
};
[x, y, width, height]
}
fn dynamic_joystick(&self) -> bool {
self.settings.dynamic_joystick && self.settings.mode != Mode::Dpad
}
/// The shortcuts that stay with [`Self::auxiliary_only`].
fn auxiliary_control(c: Control) -> bool {
matches!(
@@ -123,7 +175,17 @@ impl TouchControls {
/// The visible control under `point`, the last-drawn one first.
pub fn control_at(&self, point: [f32; 2]) -> Option<Control> {
Control::ALL.into_iter().rev().find(|&c| {
self.visible(c) && layout::contains(self.placement(c).rect(self.aspect()), point)
if !self.visible(c) {
return false;
}
if c == Control::Move && self.dynamic_joystick() {
let area = self.dynamic_area();
return area[2] > 0.0
&& area[3] > 0.0
&& !self.moving()
&& layout::contains(area, point);
}
layout::contains(self.placement(c).rect(self.aspect()), point)
})
}
@@ -262,12 +324,41 @@ impl TouchControls {
.any(|(r, _)| matches!(r, Role::Shield))
}
/// Where the touch marker goes in the tap schemes: under a look finger, or
/// briefly where a short tap landed.
pub fn pointer(&self) -> Option<[f32; 2]> {
if self.crosshair() {
return None;
}
self.fingers
.values()
.find_map(|(r, point)| matches!(r, Role::Look { .. }).then_some(*point))
.or_else(|| {
self.last_marker
.filter(|(_, t)| t.elapsed() < MARKER_TIME)
.map(|(p, _)| p)
})
}
/// The aim point of a world action: the crosshair, or where the finger is.
fn aim_at(&self, point: [f32; 2]) -> [f32; 2] {
if self.settings.crosshair() || self.projectile_crosshair {
[0.5, 0.5]
} else {
point
}
}
fn world_gestures(&self) -> bool {
!self.crosshair() || self.settings.crosshair_gestures
}
/// One touch event: finger `id` at `point` (0..1 of the surface), as it
/// lands (`started`), moves, or lifts (`ended`). Returns how far the
/// camera should turn, in surface fractions (see [`camera_delta`]).
pub fn update(&mut self, id: u64, point: [f32; 2], started: bool, ended: bool) -> [f32; 2] {
if ended {
self.fingers.remove(&id);
self.lift(id);
return [0.0; 2];
}
if started {
@@ -275,14 +366,26 @@ impl TouchControls {
return [0.0; 2];
}
let aspect = self.aspect();
let attack_look = self.settings.attack_button_look && self.crosshair();
let Some((role, previous)) = self.fingers.get_mut(&id) else {
return [0.0; 2];
};
let turns = match role {
Role::Look { start, dragged } => {
Role::Look {
start,
since,
dragged,
committed,
..
} => {
if !*dragged && since.elapsed() >= HOLD_DELAY {
*committed = true;
}
*dragged |= ((point[0] - start[0]) * aspect).hypot(point[1] - start[1]) > DRAG_SLOP;
*dragged
}
// In the crosshair schemes the attack button can steer while it mines.
Role::Button(Button::RightTrigger) => attack_look,
_ => false,
};
let delta = [point[0] - previous[0], point[1] - previous[1]];
@@ -290,11 +393,34 @@ impl TouchControls {
if turns { delta } else { [0.0; 2] }
}
fn lift(&mut self, id: u64) {
let Some((
Role::Look {
start,
since,
dragged,
held_use,
..
},
_,
)) = self.fingers.remove(&id)
else {
return;
};
if self.world_gestures() && !dragged && since.elapsed() < HOLD_DELAY {
self.tap_aim = Some(self.aim_at(start));
self.last_marker = Some((start, Instant::now()));
self.pending_tap = Some(held_use);
}
}
fn land(&mut self, id: u64, point: [f32; 2]) {
let role = match self.control_at(point) {
Some(Control::Move) if !self.moving() => {
Role::Move(self.placement(Control::Move).center)
}
Some(Control::Move) if !self.moving() => Role::Move(if self.dynamic_joystick() {
point
} else {
self.placement(Control::Move).center
}),
Some(Control::Shield) => {
self.clicked = true;
self.pending.triggers[0] = 1.0;
@@ -348,7 +474,10 @@ impl TouchControls {
Some(c) => Self::button(c).map_or(Role::Ignore, Role::Button),
None => Role::Look {
start: point,
since: Instant::now(),
dragged: false,
held_use: self.hold_uses_item,
committed: false,
},
};
self.fingers.insert(id, (role, point));
@@ -367,20 +496,87 @@ impl TouchControls {
self.last_sneak_tap = None;
self.sprint_latched = false;
self.pending = PadState::default();
self.aim = None;
self.tap_aim = None;
self.pending_tap = None;
self.last_marker = None;
self.holding_use = false;
self.holding = false;
self.chat_requested = false;
self.settings_requested = false;
self.debug_requested = false;
self.perspective_requested = false;
}
/// A short world tap since the last call, and whether it uses the item.
/// A tap never holds attack or use into the next tick: the game acts once
/// at [`Self::aim`].
pub fn take_tap(&mut self) -> Option<bool> {
self.pending_tap.take()
}
/// Key or action presses for the game's key bindings since the last call.
pub fn key_events(&mut self) -> Vec<(String, bool)> {
std::mem::take(&mut self.key_events)
}
/// This tick's controller state, with presses that already lifted.
/// This tick's controller state, with presses that already lifted, and
/// sets [`Self::aim`], [`Self::holding`] and [`Self::holding_use`].
pub fn poll_state(&mut self) -> PadState {
self.state().merge(&std::mem::take(&mut self.pending))
let mut state = self.state().merge(&std::mem::take(&mut self.pending));
self.aim = self.tap_aim.take();
self.holding_use = false;
self.holding = false;
if !self.world_gestures() {
return state;
}
let mut hold = None;
for (role, point) in self.fingers.values_mut() {
if let Role::Look {
since,
dragged,
held_use,
committed,
..
} = role
{
if !*dragged && since.elapsed() >= HOLD_DELAY {
*committed = true;
}
if !*dragged || *committed {
hold = Some((*point, *committed, *held_use));
if *committed {
break;
}
}
}
}
if let Some((point, committed, held_use)) = hold {
self.aim = Some(self.aim_at(point));
if committed {
self.holding = true;
self.holding_use = held_use;
state.triggers[usize::from(!held_use)] = 1.0;
}
}
state
}
/// Keeps the current hold using the item, even after the last one was
/// eaten or the target changed.
pub fn latch_use(&mut self) {
if let Some((Role::Look { held_use, .. }, _)) = self.fingers.values_mut().find(|(r, _)| {
matches!(
r,
Role::Look {
committed: true,
..
}
)
}) {
*held_use = true;
}
self.holding_use = true;
}
/// The controller state the fingers hold right now.
@@ -176,9 +176,21 @@ pub struct Settings {
pub sensitivity_vertical: f32,
/// The sprint button toggles sprinting instead of holding it.
pub sprint_toggle: bool,
/// In the crosshair schemes, taps and holds on the world also attack and
/// use (at the crosshair), as in the tap schemes.
pub crosshair_gestures: bool,
/// In the crosshair schemes, dragging the attack button turns the camera.
pub attack_button_look: bool,
/// In the tap schemes, the use button shows while it would act on what
/// the crosshair points at.
pub contextual_interact: bool,
/// The joystick appears where the finger lands, within the dynamic area.
pub dynamic_joystick: bool,
/// The dynamic area's width: a fraction of the surface, from the
/// joystick's edge inwards.
pub dynamic_area_x: f32,
/// The dynamic area's height, centred on the joystick (1 is the whole height).
pub dynamic_area_y: f32,
/// Scales every control without changing the saved sizes (0.5-2).
pub button_scale: f32,
}
@@ -220,7 +232,12 @@ impl Default for Settings {
sensitivity_horizontal: 1.0,
sensitivity_vertical: 1.0,
sprint_toggle: false,
crosshair_gestures: false,
attack_button_look: true,
contextual_interact: false,
dynamic_joystick: false,
dynamic_area_x: 0.25,
dynamic_area_y: 1.0,
button_scale: 1.0,
}
}
@@ -265,6 +282,8 @@ impl Settings {
*v = finite_or(*v, 1.0).clamp(0.1, 3.0);
}
self.button_scale = finite_or(self.button_scale, 1.0).clamp(0.5, 2.0);
self.dynamic_area_x = finite_or(self.dynamic_area_x, 0.25).clamp(0.0, 1.0);
self.dynamic_area_y = finite_or(self.dynamic_area_y, 1.0).clamp(0.0, 1.0);
}
/// Whether attack and use act on the crosshair rather than on a tap.
@@ -437,14 +456,19 @@ mod tests {
#[test]
fn control_options_default_safely_and_stay_in_range() {
let read: Settings = serde_json::from_str(r#"{"mode":"tap"}"#).unwrap();
assert!(!read.sprint_toggle && !read.contextual_interact);
assert_eq!(read.button_scale, 1.0);
assert!(read.attack_button_look && !read.sprint_toggle && !read.dynamic_joystick);
assert_eq!([read.dynamic_area_x, read.dynamic_area_y], [0.25, 1.0]);
let mut s = Settings {
button_scale: f32::NAN,
dynamic_area_x: -1.0,
dynamic_area_y: f32::INFINITY,
..Settings::default()
};
s.sanitize();
assert_eq!(s.button_scale, 1.0);
assert_eq!(
[s.button_scale, s.dynamic_area_x, s.dynamic_area_y],
[1.0, 0.0, 1.0]
);
s.button_scale = 9.0;
s.sanitize();
assert_eq!(s.button_scale, 2.0);
@@ -0,0 +1,285 @@
//! Taps and holds on the world, and the dynamic joystick.
use super::*;
/// Backdates a look finger, as if it had been held `ms` milliseconds.
fn age(t: &mut TouchControls, id: u64, ms: u64) {
if let Some((Role::Look { since, .. }, _)) = t.fingers.get_mut(&id) {
*since = Instant::now() - Duration::from_millis(ms);
}
}
#[test]
fn dynamic_area_follows_the_configured_width_and_height() {
let mut t = TouchControls::default();
t.settings.dynamic_area_x = 0.25;
t.settings.dynamic_area_y = 0.5;
let area = t.dynamic_area();
assert_eq!([area[0], area[2], area[3]], [0.0, 0.25, 0.5]);
let move_y = t.placement(Control::Move).center[1];
assert!((area[1] - (move_y - 0.25).clamp(0.0, 0.5)).abs() < 1e-4);
// Far above the joystick, a touch stays a camera gesture.
t.settings.dynamic_joystick = true;
t.update(1, [0.1, 0.05], true, false);
assert!(matches!(role(&t, 1), Role::Look { .. }));
t.update(1, [0.1, 0.05], false, true);
t.update(2, [0.1, move_y], true, false);
assert!(matches!(role(&t, 2), Role::Move(_)));
// The strip stays on the surface and follows a left-handed layout.
t.settings.dynamic_area_y = 1.0;
t.settings.left_handed = true;
let area = t.dynamic_area();
assert_eq!([area[0], area[1], area[3]], [0.75, 0.0, 1.0]);
}
#[test]
fn dynamic_joystick_spawns_in_its_strip_and_keeps_its_frame() {
let mut t = TouchControls::default();
t.settings.dynamic_joystick = true;
t.update(1, [0.5, 0.5], true, false);
assert!(matches!(role(&t, 1), Role::Look { .. }));
t.update(1, [0.5, 0.5], false, true);
// The frame is where the finger first landed.
t.update(2, [0.12, 0.6], true, false);
assert!(matches!(role(&t, 2), Role::Move(a) if a == [0.12, 0.6]));
t.update(2, [0.20, 0.6], false, false);
assert!(matches!(role(&t, 2), Role::Move(a) if a == [0.12, 0.6]));
assert!(t.state().left[0] > 0.0);
t.update(2, [0.20, 0.6], false, true);
t.settings.left_handed = true;
t.update(3, [0.80, 0.6], true, false);
assert!(matches!(role(&t, 3), Role::Move(_)));
}
#[test]
fn dynamic_joystick_respects_hidden_controls_and_one_owner() {
let mut t = TouchControls::default();
t.settings.dynamic_joystick = true;
t.settings.dynamic_area_x = 0.05;
// The fixed joystick's home is outside a narrow strip: a camera gesture.
t.update(1, t.placement(Control::Move).center, true, false);
assert!(matches!(role(&t, 1), Role::Look { .. }));
t.clear();
let edge = [0.025, 0.5];
assert_eq!(t.control_at(edge), Some(Control::Move));
t.update(2, edge, true, false);
assert!(matches!(role(&t, 2), Role::Move(_)));
// A second finger can't take the joystick.
t.update(3, [0.03, 0.6], true, false);
assert!(matches!(role(&t, 3), Role::Look { .. }));
t.update(2, [0.05, 0.5], false, false);
assert!(t.state().left[0] > 0.0);
t.update(2, [0.05, 0.5], false, true);
assert_eq!(t.state().left, [0.0; 2]);
t.clear();
t.settings.button_visibility[Control::Move as usize] = layout::HIDDEN;
t.update(4, edge, true, false);
assert!(matches!(role(&t, 4), Role::Look { .. }));
t.clear();
t.settings.button_visibility[Control::Move as usize] = layout::FOLLOW_SCHEME;
t.settings.dynamic_area_x = 0.0;
t.update(5, [0.0, 0.5], true, false);
assert!(matches!(role(&t, 5), Role::Look { .. }));
}
#[test]
fn fixed_joystick_dpad_and_buttons_win_over_the_dynamic_strip() {
let mut t = TouchControls::default();
let home = t.placement(Control::Move).center;
t.update(1, [home[0] + 0.01, home[1]], true, false);
assert!(matches!(role(&t, 1), Role::Move(a) if a == home));
t.clear();
t.settings.dynamic_joystick = true;
t.settings.mode = Mode::Dpad;
let home = t.placement(Control::Move).center;
let up = [home[0], home[1] - t.placement(Control::Move).size / 3.0];
t.update(2, up, true, false);
assert!(matches!(role(&t, 2), Role::Move(a) if a == home));
t.clear();
t.settings.mode = Mode::Crosshair;
t.settings.dynamic_area_x = 1.0;
t.update(3, t.placement(Control::Jump).center, true, false);
assert!(t.state().held(Button::South));
assert!(!t.moving());
}
#[test]
fn projectiles_aim_short_taps_at_the_crosshair() {
let mut t = TouchControls::default();
t.settings.mode = Mode::Tap;
t.projectile_crosshair = true;
t.hold_uses_item = true;
t.update(1, [0.6, 0.4], true, false);
t.update(1, [0.6, 0.4], false, true);
t.poll_state();
assert_eq!(t.aim, Some([0.5, 0.5]));
assert_eq!(t.take_tap(), Some(true));
}
#[test]
fn world_taps_never_hold_attack_or_use_and_keep_the_landing_item() {
for (mode, split) in Mode::SCHEMES.map(|(m, s, _)| (m, s)) {
for held_use in [false, true] {
let mut t = TouchControls::default();
t.settings.mode = mode;
t.settings.split_controls = split;
t.settings.crosshair_gestures = true;
t.hold_uses_item = held_use;
t.update(1, [0.6, 0.4], true, false);
t.hold_uses_item = !held_use;
t.update(1, [0.6, 0.4], false, true);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert_eq!(t.take_tap(), Some(held_use));
assert_eq!(t.take_tap(), None);
t.update(2, [0.6, 0.4], true, false);
t.update(2, [0.6, 0.4], false, true);
t.clear();
assert_eq!(t.take_tap(), None);
}
}
}
#[test]
fn looking_around_with_food_never_starts_eating() {
let mut t = TouchControls::default();
t.settings.mode = Mode::Tap;
t.hold_uses_item = true;
t.update(1, [0.6, 0.4], true, false);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
t.update(1, [0.7, 0.4], false, false);
age(&mut t, 1, 1000);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert_eq!(t.pointer(), Some([0.7, 0.4]));
}
#[test]
fn crosshair_gestures_aim_at_the_centre() {
for (mode, split) in [(Mode::Crosshair, false), (Mode::Dpad, true)] {
let mut t = TouchControls::default();
t.settings.mode = mode;
t.settings.split_controls = split;
t.update(1, [0.6, 0.4], true, false);
t.update(1, [0.6, 0.4], false, true);
assert_eq!(t.take_tap(), None, "off by default");
t.settings.crosshair_gestures = true;
t.update(2, [0.6, 0.4], true, false);
t.update(2, [0.6, 0.4], false, true);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert_eq!(t.take_tap(), Some(false));
assert_eq!(t.aim, Some([0.5, 0.5]));
}
}
#[test]
fn a_tap_aims_where_it_lands_and_a_drag_only_turns() {
let mut t = TouchControls::default();
t.settings.mode = Mode::Tap;
t.update(1, [0.6, 0.4], true, false);
t.update(1, [0.6, 0.4], false, true);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert_eq!(t.take_tap(), Some(false));
assert_eq!(t.aim, Some([0.6, 0.4]));
t.poll_state();
assert_eq!(t.aim, None);
t.update(2, [0.6, 0.4], true, false);
assert_eq!(t.update(2, [0.605, 0.4], false, false), [0.0; 2]);
assert_ne!(t.update(2, [0.7, 0.4], false, false), [0.0; 2]);
t.update(2, [0.7, 0.4], false, true);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert_eq!(t.take_tap(), None);
}
#[test]
fn the_attack_button_can_turn_the_camera_while_mining() {
for enabled in [true, false] {
let mut t = TouchControls::default();
t.settings.attack_button_look = enabled;
let start = t.placement(Control::Attack).center;
t.update(1, start, true, false);
assert_eq!(t.poll_state().triggers[1], 1.0);
let end = [start[0] - 0.2, start[1] - 0.1];
assert_eq!(t.update(1, end, false, false) != [0.0; 2], enabled);
assert_eq!(t.poll_state().triggers[1], 1.0);
t.update(1, end, false, true);
assert_eq!(t.poll_state().triggers[1], 0.0);
}
}
#[test]
fn a_long_hold_keeps_mining_while_the_camera_turns() {
let mut t = TouchControls::default();
t.settings.mode = Mode::Tap;
t.update(1, [0.6, 0.4], true, false);
age(&mut t, 1, 400);
assert_eq!(t.poll_state().triggers[1], 1.0);
assert_eq!(t.aim, Some([0.6, 0.4]));
assert!(t.holding && !t.holding_use);
assert_ne!(t.update(1, [0.7, 0.4], false, false), [0.0; 2]);
assert_eq!(t.poll_state().triggers[1], 1.0);
assert_eq!(t.aim, Some([0.7, 0.4]));
t.update(1, [0.7, 0.4], false, true);
assert_eq!(t.poll_state().triggers[1], 0.0);
assert_eq!(t.take_tap(), None);
}
#[test]
fn eating_survives_drift_and_ends_with_its_finger() {
let mut t = TouchControls::default();
t.settings.mode = Mode::Tap;
t.hold_uses_item = true;
t.update(1, [0.6, 0.4], true, false);
age(&mut t, 1, 400);
assert_eq!(t.poll_state().triggers, [1.0, 0.0]);
assert!(t.holding_use);
assert_ne!(t.update(1, [0.66, 0.46], false, false), [0.0; 2]);
t.hold_uses_item = false; // the last item eaten can't turn into mining
assert_eq!(t.poll_state().triggers, [1.0, 0.0]);
assert_eq!(t.aim, Some([0.66, 0.46]));
// A second finger turning the camera doesn't change the hold.
t.update(2, [0.4, 0.3], true, false);
assert_ne!(t.update(2, [0.45, 0.3], false, false), [0.0; 2]);
assert_eq!(t.poll_state().triggers, [1.0, 0.0]);
t.update(1, [0.66, 0.46], false, true);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert!(!t.holding_use);
}
#[test]
fn latch_use_turns_a_hold_into_use_but_not_a_tap() {
let mut t = TouchControls::default();
t.settings.mode = Mode::Tap;
t.update(1, [0.6, 0.4], true, false);
t.poll_state();
assert!(!t.holding);
age(&mut t, 1, 400);
assert_eq!(t.poll_state().triggers, [0.0, 1.0]);
assert!(t.holding);
t.latch_use();
assert_eq!(t.poll_state().triggers, [1.0, 0.0]);
t.update(1, [0.66, 0.4], false, true);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert!(!t.holding);
t.update(2, [0.6, 0.4], true, false);
t.update(2, [0.6, 0.4], false, true);
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert_eq!(t.take_tap(), Some(false));
}
#[test]
fn the_marker_follows_a_look_finger_only_in_tap_schemes() {
let mut t = TouchControls::default();
t.settings.mode = Mode::Tap;
assert_eq!(t.pointer(), None);
t.update(1, [0.6, 0.4], true, false);
assert_eq!(t.pointer(), Some([0.6, 0.4]));
assert_eq!(t.poll_state().triggers, [0.0; 2]);
assert_eq!(t.aim, Some([0.6, 0.4]));
t.update(1, [0.7, 0.4], false, false);
t.poll_state();
assert_eq!(t.pointer(), Some([0.7, 0.4]));
assert_eq!(t.aim, None);
t.update(1, [0.7, 0.4], false, true);
t.clear();
t.settings.mode = Mode::Crosshair;
t.update(1, [0.6, 0.4], true, false);
assert_eq!(t.pointer(), None);
}
@@ -1,6 +1,8 @@
// Adapted from Starling's touch.rs tests (see the header of touch.rs).
use super::*;
mod gestures;
fn role(t: &TouchControls, id: u64) -> Role {
t.fingers[&id].0
}
+101 -14
View File
@@ -1227,6 +1227,13 @@ pub struct EditBox {
/// Grey completion shown after the text (EditBox.setSuggestion).
pub suggestion: Option<String>,
pub text_color: u32,
/// `EditBox.textColorUneditable`: the colour of a box `setEditable(false)`
/// made read-only, which still shows its value and caret.
pub text_color_uneditable: u32,
/// `EditBox.isEditable`: a read-only box keeps the focus, the caret keys
/// and copy, but takes no typed text, Backspace/Delete or paste (`AnvilScreen`
/// sets it by its input slot).
pub editable: bool,
/// `getMessage`: what the narrator calls the box ("<message> edit box:
/// <value>").
pub message: String,
@@ -1518,6 +1525,8 @@ impl EditBox {
focused_at: Instant::now(),
suggestion: None,
text_color: rgba(224, 224, 224, 255),
text_color_uneditable: rgba(112, 112, 112, 255),
editable: true,
message: String::new(),
frame: None,
}
@@ -1562,6 +1571,12 @@ impl EditBox {
}
/// EditBox.insertText: replace the selection, respecting the maximum length.
pub fn insert_text(&mut self, text: &str) {
// `EditBox.charTyped` only inserts through `canConsumeInput`, which a
// read-only box fails; the screens here hand typed text to this method
// directly.
if !self.editable {
return;
}
let (a, b) = self.selected();
let filtered = crate::clipboard::filter_text(text, self.multiline);
let room = self
@@ -1573,8 +1588,12 @@ impl EditBox {
let pos = a + char_len(&insert);
self.set_cursor(pos, false);
}
/// Delete `n` characters (negative: backwards) or the selection.
/// Delete `n` characters (negative: backwards) or the selection. A
/// read-only box consumes the key without changing its value.
pub fn delete_chars(&mut self, n: isize) {
if !self.editable {
return;
}
if self.cursor != self.highlight {
self.insert_text("");
return;
@@ -1677,20 +1696,24 @@ impl EditBox {
use winit::keyboard::KeyCode as K;
match key {
K::Backspace => {
let n = if ctrl {
self.word_position(-1) as isize - self.cursor as isize
} else {
-1
};
self.delete_chars(n);
if self.editable {
let n = if ctrl {
self.word_position(-1) as isize - self.cursor as isize
} else {
-1
};
self.delete_chars(n);
}
}
K::Delete => {
let n = if ctrl {
self.word_position(1) as isize - self.cursor as isize
} else {
1
};
self.delete_chars(n);
if self.editable {
let n = if ctrl {
self.word_position(1) as isize - self.cursor as isize
} else {
1
};
self.delete_chars(n);
}
}
K::ArrowLeft => {
let p = if ctrl {
@@ -1724,6 +1747,19 @@ impl EditBox {
shortcut: crate::clipboard::EditShortcut,
clipboard: &mut dyn crate::clipboard::Clipboard,
) -> bool {
use crate::clipboard::EditShortcut;
if !self.editable {
match shortcut {
// EditBox.keyPressed: Cut copies the selection, then does not
// take it out of a read-only box.
EditShortcut::Cut => {
clipboard.set(&self.selected_text());
return false;
}
EditShortcut::Paste => return false,
EditShortcut::SelectAll | EditShortcut::Copy => {}
}
}
let before = self.value.clone();
crate::clipboard::apply(self, shortcut, clipboard);
self.reset_blink();
@@ -1777,7 +1813,11 @@ impl EditBox {
u32::MAX,
);
}
let color = self.text_color;
let color = if self.editable {
self.text_color
} else {
self.text_color_uneditable
};
let rest: String = self.value.chars().skip(self.display).collect();
let shown_len = atlas.font.fit(&rest, self.inner_width());
let displayed: String = rest.chars().take(shown_len).collect();
@@ -2531,4 +2571,51 @@ mod tests {
(112, 180)
);
}
/// `EditBox.setEditable(false)`: the box keeps the focus, the caret keys
/// and copy, but `charTyped`/paste/Backspace/Delete do nothing, and Cut
/// copies without taking the text out. A value and the caret still show,
/// in `textColorUneditable`.
#[test]
fn a_read_only_edit_box_keeps_the_caret_and_copy_but_refuses_editing() {
use crate::clipboard::{EditShortcut, MemoryClipboard};
let mut edit = EditBox::new(0.0, 0.0, 100.0, 20.0);
edit.set_value("name");
edit.editable = false;
let mut clipboard = MemoryClipboard("pasted".into());
// The caret keys still work.
assert!(edit.key(winit::keyboard::KeyCode::ArrowLeft, false, false));
assert_eq!(edit.cursor, 3);
assert!(edit.key(winit::keyboard::KeyCode::Home, false, false));
assert_eq!(edit.cursor, 0);
// Backspace and Delete are consumed but change nothing.
assert!(edit.key(winit::keyboard::KeyCode::Backspace, false, false));
assert!(edit.key(winit::keyboard::KeyCode::Delete, false, false));
assert_eq!(edit.value, "name");
// Typed text (and an IME commit) goes through insert_text: refused.
edit.insert_text("typed");
assert_eq!(edit.value, "name");
edit.delete_chars(-1);
assert_eq!(edit.value, "name");
// Paste changes nothing; Cut copies and keeps the text; Copy copies.
edit.set_cursor(0, false);
edit.set_cursor(4, true);
assert!(!edit.shortcut(EditShortcut::Paste, &mut clipboard));
assert_eq!(edit.value, "name");
assert!(!edit.shortcut(EditShortcut::Cut, &mut clipboard));
assert_eq!(edit.value, "name");
assert_eq!(clipboard.0, "name");
edit.set_cursor(0, false);
edit.set_cursor(4, true);
assert!(!edit.shortcut(EditShortcut::Copy, &mut clipboard));
assert_eq!(clipboard.0, "name");
// An editable box does all of it again. (Set the caret past the
// selection first: insertion replaces a selection.)
edit.editable = true;
edit.set_cursor(4, false);
edit.insert_text("x");
assert_eq!(edit.value, "namex");
assert!(edit.key(winit::keyboard::KeyCode::Backspace, false, false));
assert_eq!(edit.value, "name");
}
}
+5 -6
View File
@@ -20,6 +20,8 @@ horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# Extension points for the crates above the engine (docs/layers.md, "How a Horizon crate plugs in").
horizon-hooks = { path = "../horizon-hooks" }
# OptiFine-format pack files: custom entity models, random entity textures.
horizon-optifine = { path = "../horizon-optifine" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
@@ -67,7 +69,7 @@ chrono = { version = "0.4.42", default-features = false, features = ["clock", "s
flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs", "runtime_detection"] }
horizon-theme = { path = "../horizon-theme" }
# Opt-in data collection: the game keeps, the launcher sends (docs/telemetry.md). The
# anonymous "I'm playing" ping is horizon-client-lib's (`heartbeat.rs`), the `heartbeat` feature.
# anonymous "I'm playing" ping is the Horizon layer's (`horizon-heartbeat`, in the official executable).
horizon-telemetry = { path = "../horizon-telemetry", default-features = false }
horizon-bench = { path = "../horizon-bench" }
crc32fast = "1"
@@ -117,18 +119,15 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# Horizon's own extras over the vanilla game, for a fork that wants them out (docs/forking.md):
# cosmetics and emotes, the "Horizon player" presence, the official server added to
# the server list, the Horizon Settings and Mods pages, Discord, Play with Friends and the
# heartbeat. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# the server list, the Horizon Settings and Mods pages, Discord and Play with Friends. Players always get them; there is no switch. `grep -rn horizon-extras crates/` lists
# every place it gates. Without it (`--no-default-features --features audio,controllers`) the
# client is best effort: CI builds only the default.
horizon-extras = ["discord", "friends", "heartbeat"]
horizon-extras = ["discord", "friends"]
# Online extras a server or a port can leave out; the server build has none of them. "Playing Horizon"
# on Discord (game/discord.rs).
discord = ["horizon-client-lib/discord"]
# Play with Friends: sharing a world behind an invite code (game/friends.rs) and joining one.
friends = ["horizon-client-lib/friends", "dep:horizon-friends"]
# The anonymous "I'm playing" ping (docs/telemetry.md).
heartbeat = ["horizon-client-lib/heartbeat"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace", "horizon-client-lib/rng-trace"]
# Rust's allocations go to the system heap instead of mimalloc, where heap profilers that replace
@@ -447,6 +447,7 @@
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
"HDR output stopped the game the last time it started with this graphics driver. Choose a mode to try again.": "HDR output stopped the game the last time it started with this graphics driver. Choose a mode to try again.",
"Lower it until the logo just disappears into the black.": "Lower it until the logo just disappears into the black.",
"Raise it until the logo just disappears into the bright square.": "Raise it until the logo just disappears into the bright square.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Make the card a white that's comfortable to look at for a long time, in the light you play in.",
+6 -118
View File
@@ -1,6 +1,8 @@
//! Embeds Horizon's icon (`packaging/icons/horizon.ico`) in the Windows executables, as icon
//! resource 1: Explorer and the task bar show it, and the window takes it as its icon.
//! The optional Segment Heap manifest is embedded in the same resource compilation.
//! Generates the native mod API, and gives the Windows executable Horizon's icon and its stack
//! (`packaging/windows/exe.rs`, shared with the official executable's build script).
#[path = "../../packaging/windows/exe.rs"]
mod windows_exe;
fn main() {
let root =
std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap()).join("../..");
@@ -19,119 +21,5 @@ fn main() {
if !staged.is_empty() {
println!("cargo:rustc-env=HORIZON_MIXIN_MODS={}", staged.join(","));
}
let manifest_dir = std::path::PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap());
let ico = manifest_dir
.join("../..")
.join("packaging/icons/horizon.ico");
let target_os =
std::env::var("CARGO_CFG_TARGET_OS").expect("Cargo must provide the build target OS");
println!("cargo:rerun-if-changed={}", ico.display());
// The game's main thread gets 8 MB of stack on Windows as on macOS and Linux (Windows gives 1
// MB): splitscreen builds a joining player's `Game`, ~67 KB, deep in the event loop.
if target_os == "windows" {
println!("cargo:rerun-if-env-changed=WINDRES");
println!("cargo:rerun-if-env-changed=AR");
println!("cargo:rerun-if-env-changed=RC_PATH");
let target_env = std::env::var("CARGO_CFG_TARGET_ENV")
.expect("Cargo must provide the target environment");
let arg = match target_env.as_str() {
"msvc" => "/STACK:8388608",
_ => "-Wl,--stack,8388608",
};
println!("cargo:rustc-link-arg-bin=horizon-client={arg}");
let (resource_manifest_dir, resource_icon) = match resource_path(&manifest_dir, &target_env)
.and_then(|dir| Ok((dir, resource_path(&ico, &target_env)?)))
{
Ok(paths) => paths,
// #10398: a local GNU build in a folder whose path has a space or a non-ASCII letter,
// on a volume without 8.3 names, still builds (the launcher's native mod builds use
// that toolchain), without the icon. CI never does.
Err(e) if std::env::var_os("CI").is_none() => {
println!("cargo:warning=building without Horizon's icon: {e}");
return;
}
Err(e) => panic!("{e}"),
};
let mut resource = winresource::WindowsResource::new();
resource.set_icon(&resource_icon);
if std::env::var_os("CARGO_FEATURE_WINDOWS_SEGMENT_HEAP").is_some() {
let manifest = manifest_dir.join("windows-segment-heap.manifest");
println!("cargo:rerun-if-changed={}", manifest.display());
let resource_manifest =
resource_path(&manifest, &target_env).unwrap_or_else(|e| panic!("{e}"));
resource.set_manifest_file(&resource_manifest);
}
let original_manifest_dir = std::env::var_os("CARGO_MANIFEST_DIR").unwrap();
if cfg!(windows) && target_env == "gnu" {
// GNU windres can split paths with spaces or fail on non-ASCII paths while
// preprocessing the .rc file. Pass an 8.3 alias for the include directory; the icon
// and optional manifest use the same alias.
// This build script has no other Rust threads, and winresource reads this variable
// only while compiling the resource; restore the original value immediately after.
unsafe { std::env::set_var("CARGO_MANIFEST_DIR", &resource_manifest_dir) };
}
let result = resource.compile();
if cfg!(windows) && target_env == "gnu" {
unsafe { std::env::set_var("CARGO_MANIFEST_DIR", original_manifest_dir) };
}
result.expect("embedding Horizon's icon in Windows resources");
}
}
/// `path` as the resource compiler takes it: an error only when GNU windres would need an 8.3 alias
/// that the volume doesn't have.
fn resource_path(path: &std::path::Path, target_env: &str) -> Result<String, String> {
let path = path.canonicalize().unwrap_or_else(|error| {
panic!(
"resolving Windows resource path {}: {error}",
path.display()
)
});
#[cfg(windows)]
let path = {
let path_string = path.to_string_lossy();
if target_env == "gnu"
&& (!path_string.is_ascii() || path_string.chars().any(char::is_whitespace))
{
short_windows_path(&path).map_err(|error| {
format!(
"GNU windres needs an ASCII Windows resource path for {}: {error}",
path.display()
)
})?
} else {
path
}
};
#[cfg(not(windows))]
let _ = target_env;
Ok(path.to_string_lossy().replace('\\', "/"))
}
#[cfg(windows)]
fn short_windows_path(path: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
use std::os::windows::ffi::{OsStrExt, OsStringExt};
#[link(name = "kernel32")]
unsafe extern "system" {
fn GetShortPathNameW(input: *const u16, output: *mut u16, capacity: u32) -> u32;
}
let input: Vec<u16> = path.as_os_str().encode_wide().chain(Some(0)).collect();
let mut output = vec![0u16; 32_768];
let length =
unsafe { GetShortPathNameW(input.as_ptr(), output.as_mut_ptr(), output.len() as u32) };
if length == 0 || length as usize >= output.len() {
return Err(std::io::Error::last_os_error());
}
let short_path = std::ffi::OsString::from_wide(&output[..length as usize]);
let short_path_string = short_path.to_string_lossy();
if !short_path_string.is_ascii() || short_path_string.chars().any(char::is_whitespace) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"the volume has no ASCII 8.3 path for this file",
));
}
Ok(short_path.into())
windows_exe::resources("horizon-client");
}
+9 -9
View File
@@ -2,6 +2,8 @@
//! progress (`PlayerAdvancements`) and the predicates criteria test
//! (`EntityPredicate`, `ItemPredicate`, `LocationPredicate`, the loot
//! conditions advancements use).
mod state_ranges;
use horizon_world::lazy_json::LazyJson;
use serde_json::Value;
use std::{
@@ -619,6 +621,10 @@ pub trait World {
fn structures(&self, pos: [i32; 3]) -> Vec<String>;
/// Block name and properties.
fn block(&self, pos: [i32; 3]) -> (String, BTreeMap<String, String>);
/// Legal values of the actual registered property, unavailable without registry access.
fn block_property_values(&self, _block: &str, _property: &str) -> Option<Vec<String>> {
None
}
fn fluid(&self, pos: [i32; 3]) -> Option<String>;
fn can_see_sky(&self, pos: [i32; 3]) -> bool;
fn light(&self, pos: [i32; 3]) -> i32;
@@ -914,7 +920,9 @@ pub fn block(v: &Value, pos: [i32; 3], w: &dyn World) -> bool {
Value::String(s) => have == *s,
Value::Bool(b) => have == b.to_string(),
Value::Number(n) => have == n.to_string(),
Value::Object(_) => have.parse::<f64>().is_ok_and(|h| range_f(want, h)),
Value::Object(bounds) => w
.block_property_values(&name, k)
.is_some_and(|domain| state_ranges::matches(bounds, &have, &domain)),
_ => false,
};
if !ok {
@@ -1955,7 +1963,6 @@ impl Tree {
mod tests {
use super::*;
use serde_json::json;
#[test]
fn advancement_reward_function_is_optional_and_namespaced() {
for (raw, expected) in [
@@ -1980,7 +1987,6 @@ mod tests {
.unwrap();
assert!(without.rewards.function.is_none());
}
/// The criteria an inventory change and a recipe unlock test, on vanilla's tree:
/// `cargo test -p horizon-client --lib -- --ignored --nocapture index_on_vanilla_tree`
/// with HORIZON_RESOURCES set to the prepared resources.
@@ -2188,7 +2194,6 @@ mod tests {
&Flat
));
}
/// A `recipe_crafted` criterion names `minecraft:x`; the craft hands
/// the recipe book and the trigger the same whole id (`award_crafted`
/// qualifies). The bare name the crafting copy uses never matched —
@@ -2211,7 +2216,6 @@ mod tests {
"the whole id award_crafted passes matches"
);
}
/// `LockCode.canUnlock` for a chest with
/// `lock:{items:"minecraft:gold_ingot"}`: a gold ingot opens it, an empty
/// hand or another item does not, and a spectator always does.
@@ -3360,7 +3364,6 @@ mod tests {
);
assert_eq!(date_millis("not a date"), None);
}
#[test]
fn simulate_progression_advancements() {
let mut tree = Tree::default();
@@ -3447,7 +3450,6 @@ mod tests {
tree.by_id.insert(id, tree.list.len());
tree.list.push(a);
}
fn get_list(tree: &Tree, target: Option<&str>, mode: &str) -> Vec<usize> {
match target {
None => (0..tree.list.len()).collect(),
@@ -3476,7 +3478,6 @@ mod tests {
}
}
}
fn grant(tree: &Tree, prog: &mut Progress, list: &[usize]) -> i32 {
let mut count = 0;
for &i in list {
@@ -3491,7 +3492,6 @@ mod tests {
}
count
}
fn revoke(tree: &Tree, prog: &mut Progress, list: &[usize]) -> i32 {
let mut count = 0;
for &i in list {
@@ -0,0 +1,157 @@
//! `StatePropertiesPredicate.RangedMatcher`: parse through the actual legal domain.
//!
//! The pinned vanilla registry's property kinds and all enum comparisons are checked
//! independently. Direction's possible-values order differs from Comparable order;
//! other vanilla enums preserve it. This does not establish modded/future enum order.
use horizon_core::{geometry::Direction, text::parse_java_integer};
use serde_json::{Map, Value};
fn direction(value: &str) -> Option<Direction> {
Some(match value {
"down" => Direction::Down,
"up" => Direction::Up,
"north" => Direction::North,
"south" => Direction::South,
"west" => Direction::West,
"east" => Direction::East,
_ => return None,
})
}
/// StatePropertiesPredicate.RangedMatcher.match: inclusive typed, legal-domain comparisons.
pub(super) fn matches(bounds: &Map<String, Value>, actual: &str, domain: &[String]) -> bool {
if domain.is_empty() || !domain.iter().any(|v| v == actual) {
return false;
}
let boolean = domain.len() == 2
&& domain.iter().any(|v| v == "true")
&& domain.iter().any(|v| v == "false");
// The existing feature-codec integer-family inference, independently checked
// against actual IntegerProperty kinds for every pinned registered property.
let integer = domain.iter().all(|v| {
!v.is_empty()
&& v.trim_start_matches('-')
.bytes()
.all(|b| b.is_ascii_digit())
});
let directional = domain.iter().all(|v| direction(v).is_some());
let rank = |value: &str| -> Option<i32> {
if integer {
let value = parse_java_integer(value)?;
return domain
.iter()
.any(|v| *v == value.to_string())
.then_some(value);
}
// Membership precedes comparison, including for filtered Direction domains.
let index = domain.iter().position(|v| v == value)?;
if boolean {
Some(i32::from(value == "true"))
} else if directional {
Some(i32::from(direction(value)? as u8))
} else {
// Only the separately validated remaining pinned vanilla enums.
i32::try_from(index).ok()
}
};
let Some(actual) = rank(actual) else {
return false;
};
for (key, lower) in [("min", true), ("max", false)] {
if let Some(bound) = bounds.get(key) {
let Some(bound) = bound.as_str().and_then(rank) else {
return false;
};
if (lower && actual < bound) || (!lower && actual > bound) {
return false;
}
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn check(domain: &[&str], actual: &str, range: Value, expected: bool) {
let domain: Vec<_> = domain.iter().map(|v| (*v).to_owned()).collect();
assert_eq!(
matches(range.as_object().unwrap(), actual, &domain),
expected,
"actual={actual}, range={range}, domain={domain:?}"
);
}
#[test]
fn boolean_uses_comparable_not_values_order() {
let d = ["true", "false"];
check(&d, "false", json!({"max":"false"}), true);
check(&d, "true", json!({"max":"false"}), false);
check(&d, "true", json!({"min":"true"}), true);
check(&d, "false", json!({"min":"true"}), false);
check(&d, "true", json!({"min":"true","max":"false"}), false);
check(&d, "true", json!({"min":"True"}), false);
}
#[test]
fn integer_bounds_parse_then_require_legal_values() {
let d = ["0", "1", "2", "3", "4", "5", "6", "7"];
check(&d, "5", json!({"min":"2","max":"06"}), true);
check(&d, "7", json!({"min":"2","max":"06"}), false);
check(&d, "5", json!({"min":"6","max":"2"}), false);
for bound in ["10", "-1", "2147483648", "1e0", " 2"] {
check(&d, "5", json!({"min":bound}), false);
}
check(
&["-2", "-1", "0"],
"-1",
json!({"min":"-02","max":"0"}),
true,
);
}
#[test]
fn full_direction_compares_existing_native_enum() {
// Actual six-facing Property values; west follows south in Comparable.
let d = ["north", "east", "south", "west", "up", "down"];
for actual in d {
check(
&d,
actual,
json!({"min":"north","max":"west"}),
matches!(actual, "north" | "south" | "west"),
);
check(&d, actual, json!({"min":"up","max":"down"}), false);
}
}
#[test]
fn filtered_direction_membership_is_not_widened() {
let d = ["up", "down"];
check(&d, "down", json!({"max":"down"}), true);
check(&d, "up", json!({"max":"down"}), false);
check(&d, "up", json!({"min":"up"}), true);
check(&d, "down", json!({"min":"up"}), false);
check(&d, "up", json!({"min":"north"}), false);
check(
&["north", "south", "west", "east"],
"north",
json!({"min":"down"}),
false,
);
}
#[test]
fn other_enum_preserves_validated_nonlexical_order() {
let d = ["single", "left", "right"];
check(&d, "single", json!({"min":"left"}), false);
check(&d, "right", json!({"min":"left"}), true);
check(&d, "single", json!({"min":"unknown"}), false);
}
#[test]
fn unavailable_or_unknown_values_and_present_invalid_bounds_fail() {
check(&[], "north", json!({}), false);
check(&["north", "south"], "missing", json!({}), false);
check(&["north", "south"], "north", json!({}), true);
for bound in [json!(null), json!(2), json!(false), json!({})] {
check(&["north", "south"], "north", json!({"min":bound}), false);
}
}
}
+2 -5
View File
@@ -899,11 +899,8 @@ impl Ticker<'_, '_, '_, '_> {
/// `AbstractArrow.startFalling`.
fn start_falling(&mut self) {
self.st.in_ground = false;
let v = self.p.velocity;
let x = f64::from(self.st.random.next_float() * 0.2);
let y = f64::from(self.st.random.next_float() * 0.2);
let z = f64::from(self.st.random.next_float() * 0.2);
self.p.velocity = Vec3::new(v.x * x, v.y * y, v.z * z);
self.p.velocity =
crate::projectiles::start_falling_velocity(self.p.velocity, &mut self.st.random);
self.st.life = 0;
}
/// `AbstractArrow.stepMoveAndHit`: the nearest entity on the way, else the
@@ -253,6 +253,19 @@ fn an_arrow_owned_by_an_armor_stand_needs_mob_griefing_to_prime_tnt() {
assert!(primes_tnt(None, false));
}
#[test]
fn client_arrow_tick_waits_for_a_non_self_move_to_leave_its_block() {
let steps = run_with(&[], 1, false, |p| {
let arrow = p.arrow.as_mut().unwrap();
arrow.in_ground = true;
arrow.last_state = Some(BlockStateId(1));
});
let p = &steps[0];
let arrow = p.arrow.as_ref().unwrap();
assert!(arrow.in_ground);
assert_eq!(p.velocity, Vec3::new(1.0, 0.2, 0.0));
}
#[test]
#[ignore = "run by client-arrow differential harness"]
fn client_arrow_parity_fixture() {
-2
View File
@@ -77,8 +77,6 @@ pub mod game_rules_screen;
pub mod gizmos;
pub mod gossip;
pub mod health;
#[cfg(feature = "heartbeat")]
pub mod heartbeat;
pub mod hotbar;
pub mod hunger;
pub mod inv_sort;
@@ -93,6 +93,23 @@ impl Button {
}
}
/// `AbstractContainerScreen.mouseClicked`'s double click: the same slot and
/// the same button within 250 ms (`MouseHandler`'s `doubleClick`), with
/// `lastClickSlot` replaced by every click that reaches the container logic —
/// `None` for a click outside the window, which ends the chain.
pub fn double_click(
last: &mut Option<(std::time::Instant, Option<usize>, bool)>,
slot: Option<usize>,
right: bool,
now: std::time::Instant,
) -> bool {
let double = last.is_some_and(|(at, was_slot, was_right)| {
was_slot == slot && was_right == right && now.duration_since(at).as_millis() < 250
});
*last = Some((now, slot, right));
double
}
/// The input state `mouseClicked`/`mouseReleased` keep between events.
#[derive(Clone, Debug, Default)]
pub struct Clicks {
@@ -381,4 +398,28 @@ mod tests {
Some(Input::Pickup)
);
}
/// `AbstractContainerScreen.mouseClicked`: `this.lastClickSlot = slot`
/// runs for every click that reaches the container logic, and
/// `doubleclick` is the same slot and button within `MouseHandler`'s
/// 250 ms. A click outside is a click on no slot (`null`), so the next
/// click on a real slot is not a double click.
#[test]
fn a_click_on_no_slot_ends_the_double_click_chain() {
use std::time::{Duration, Instant};
let start = Instant::now();
let mut last = None;
let at = |ms| start + Duration::from_millis(ms);
assert!(!double_click(&mut last, Some(4), false, at(0)));
assert!(double_click(&mut last, Some(4), false, at(100)));
// The click outside records null: the slot under the pointer is no
// longer the last click's slot.
assert!(!double_click(&mut last, None, false, at(120)));
assert!(!double_click(&mut last, Some(4), false, at(140)));
// The same slot from another button is not the same double click.
assert!(!double_click(&mut last, Some(4), true, at(150)));
assert!(double_click(&mut last, Some(4), true, at(200)));
// Over 250 ms is two clicks, not a double click.
assert!(!double_click(&mut last, Some(4), true, at(500)));
}
}
+3 -8
View File
@@ -621,14 +621,9 @@ pub fn mob_to_nbt(
("Air", Tag::Short(m.air as i16)),
("Health", Tag::Float(m.health.max(0.0).to_bits())),
("AbsorptionAmount", Tag::Float(m.absorption.to_bits())),
(
"HurtTime",
Tag::Short(m.hurt_time.clamp(0, i32::from(i16::MAX)) as i16),
),
(
"DeathTime",
Tag::Short(m.death_time.clamp(0, i32::from(i16::MAX)) as i16),
),
// LivingEntity.addAdditionalSaveData: (short) casts, nothing clamped.
("HurtTime", Tag::Short(m.hurt_time as i16)),
("DeathTime", Tag::Short(m.death_time as i16)),
// Mob.addAdditionalSaveData writes the current virtual getter, not raw input.
("CanPickUpLoot", Tag::Byte(i8::from(m.can_pick_up_loot()))),
("PersistenceRequired", Tag::Byte(i8::from(m.persistent))),
+4
View File
@@ -21,6 +21,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
horizon_client::telemetry::mark_start();
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Crates above the engine registered their hooks before this (horizon-official's `main`);
// none registers after this point.
horizon_hooks::engine::seal();
horizon_client::spans::startup::init(std::time::Instant::now());
horizon_client::spans::trace::start_from_env();
let args: Vec<String> = std::env::args().skip(1).collect();
@@ -303,6 +306,7 @@ pub fn main_with(mods: crate::mod_api::Mods) {
game::exit_code::ERROR
});
}
horizon_hooks::engine::shutdown();
logging::flush();
}
File diff suppressed because it is too large. Load diff
@@ -26,7 +26,6 @@ use std::{
sync::Arc,
time::Instant,
};
pub(super) struct Toast {
icon: String,
frame: Frame,
@@ -214,6 +213,10 @@ impl adv::World for View<'_, '_> {
.unwrap_or_default();
(name, props)
}
fn block_property_values(&self, block: &str, property: &str) -> Option<Vec<String>> {
let values = self.0.pack.assets.registry.property_values(block, property);
(!values.is_empty()).then(|| values.into_iter().map(str::to_owned).collect())
}
fn fluid(&self, p: [i32; 3]) -> Option<String> {
match self
.0
@@ -1886,7 +1889,6 @@ impl Game<'_> {
adv::conditions(&c["player"], &ctx, w) && adv::block(c, pos, w)
});
}
/// `TargetBlockTrigger`: signal and projectile conditions are evaluated in
/// the shooter's context, including distance from the shooter.
pub(super) fn trigger_target_hit(&mut self, projectile: &EntityView, strength: i32) {
@@ -1908,7 +1910,6 @@ impl Game<'_> {
&& adv::conditions(&c["projectile"], &target, w)
});
}
/// `ItemUsedOnLocationTrigger` (placed_block, item_used_on_block).
pub(super) fn trigger_block(&mut self, name: &str, pos: [i32; 3], stack: &ItemStack) {
let me = self.player_entity_for(name);
@@ -2550,7 +2551,6 @@ impl Game<'_> {
pub(super) fn text_of(v: &Value) -> Text {
horizon_client::scoreboard::comp_text(v)
}
/// An advancement's display as a server mod reads it: its title and
/// description as text (the components as the datapack wrote them), its
/// frame (`task`, `goal` or `challenge`), and whether it is announced in
@@ -131,6 +131,26 @@ struct View {
fn window(w: f32, h: f32) -> (f32, f32) {
(((w - 252.0) / 2.0).floor(), ((h - 140.0) / 2.0).floor())
}
/// `ServerboundSeenAdvancementsPacket.openedTab`: `ClientAdvancements.setSelectedTab`
/// with `showToast` true — the player picked a tab — tells the server which
/// one, so a LAN server can keep it (`handleSeenAdvancements`) and answer with
/// the tab to reopen. The tab the screen opens on is picked without the flag
/// and says nothing.
pub(super) fn opened_tab(id: &str) -> horizon_protocol::packets::play::SeenAdvancements {
horizon_protocol::packets::play::SeenAdvancements {
action: 0,
tab: Some(id.to_owned()),
}
}
/// `ServerboundSeenAdvancementsPacket.closedScreen()` (`AdvancementsScreen.removed`):
/// action 1, which carries no tab.
pub(super) fn closed_screen() -> horizon_protocol::packets::play::SeenAdvancements {
horizon_protocol::packets::play::SeenAdvancements {
action: 1,
tab: None,
}
}
impl Game<'_> {
pub(super) fn open_advancements(&mut self, from_pause: bool) {
self.load_advancements();
@@ -145,6 +165,8 @@ impl Game<'_> {
self.capture(false);
}
pub(super) fn close_advancements(&mut self) {
// AdvancementsScreen.removed: the server hears the screen closed.
self.send(horizon_protocol::packets::play::Serverbound::SeenAdvancements(closed_screen()));
if self.adv_ui.from_pause {
self.screen = Screen::Paused;
} else {
@@ -779,6 +801,13 @@ impl Game<'_> {
let (x, y) = (left + x, top + y);
let (cx, cy) = self.cursor;
if cx > x && cx < x + ty.1 && cy > y && cy < y + ty.2 {
// `ClientAdvancements.setSelectedTab(tab, true)`: the packet
// goes out before the tab is compared, so a click on the tab
// already open tells the server again.
let id = self.adv.tree.list[t].id.clone();
self.send(
horizon_protocol::packets::play::Serverbound::SeenAdvancements(opened_tab(&id)),
);
if self.adv_ui.tab != Some(t) {
self.adv_ui.tab = Some(t);
self.adv_ui.scroll = None;
@@ -808,3 +837,57 @@ impl Game<'_> {
self.adv_scroll_by(&view, f64::from(x) * 16.0, f64::from(y) * 16.0);
}
}
#[cfg(test)]
mod seen_advancement_tests {
use super::*;
use horizon_protocol::buf::{Codec, Reader, Writer};
use horizon_protocol::packets::play::SeenAdvancements;
/// `ServerboundSeenAdvancementsPacket.openedTab` and `closedScreen`: an
/// opened tab is action 0 with its advancement id, a closed screen action
/// 1 with no id at all.
#[test]
fn an_opened_tab_carries_its_id_and_a_close_carries_none() {
assert_eq!(
opened_tab("minecraft:story/root"),
SeenAdvancements {
action: 0,
tab: Some("minecraft:story/root".into()),
}
);
assert_eq!(
closed_screen(),
SeenAdvancements {
action: 1,
tab: None,
}
);
}
/// The wire form `handleSeenAdvancements` reads back: the id follows only
/// action 0, so a close is two bytes and a read of it yields no tab.
#[test]
fn a_closed_screen_writes_no_identifier() {
let mut w = Writer::new();
SeenAdvancements::write(&closed_screen(), &mut w);
let bytes = w.data;
assert_eq!(bytes, vec![1]);
let mut r = Reader::new(&bytes);
assert_eq!(
SeenAdvancements::read(&mut r).unwrap(),
SeenAdvancements {
action: 1,
tab: None,
}
);
let mut w = Writer::new();
SeenAdvancements::write(&opened_tab("minecraft:story/root"), &mut w);
let bytes = w.data;
let mut r = Reader::new(&bytes);
assert_eq!(
SeenAdvancements::read(&mut r).ok(),
Some(opened_tab("minecraft:story/root"))
);
}
}
@@ -0,0 +1,121 @@
//! Breaking blocks: a held tool mining a block, `break_block` and the player's break with its drops
//! (`Block.popResource` for a broken block), the destroy-progress cracks shown to everyone, and a
//! bundle dropping its content.
use super::*;
impl Game<'_> {
/// `ItemStack.mineBlock` for the held stack once a survival player has
/// mined the block `name`: an item with a `tool` component counts as used
/// and loses its `damage_per_block` on a block that doesn't break
/// instantly (`ShearsItem`: on any block but fire).
pub(super) fn mine_block_with_held(&mut self, name: &str, hardness: f32) {
let Some(stack) = self.inventory[self.selected].clone() else {
return;
};
let Some(per_block) = self.items().mining_damage(&stack) else {
return;
};
let max = self.items().mining_damage_capacity(&stack);
let wears = if &*stack.item == "minecraft:shears" {
!self.items().block_in_tag("minecraft:fire", name)
} else {
hardness != 0.0
};
if wears && per_block > 0 && max > 0 {
self.damage_held_with_max(per_block, max);
}
self.award_stat("used", &stack.item, 1);
}
pub(super) fn break_block(&mut self, pos: [i32; 3]) -> Result<()> {
self.player_break_block(pos).map(|_| ())
}
/// `ServerPlayerGameMode.destroyBlock`: whether the block was removed.
/// A break inside another (a mod breaking more blocks from its
/// `native_destroy_block`) keeps the outer break's own events.
pub(super) fn player_break_block(&mut self, pos: [i32; 3]) -> Result<bool> {
let outer = self.lan_break_pos.replace(pos);
let broken = self.with_player_actor(|game| game.break_block_as_player(pos));
self.lan_break_pos = outer;
broken
}
/// `Block.popResource` for a player's break, or into `break_collect`
/// when a mod takes the drops to the player's inventory: the stack still
/// gets what the block entity copies into it, and nothing is kept where
/// blocks don't drop.
pub(super) fn pop_break_drop(&mut self, pos: [i32; 3], mut stack: ItemStack) {
if self.break_collect.is_none() {
return self.pop_resource(pos, stack);
}
self.pending.attach(pos, &mut stack);
if self.ui.rules.bool("block_drops")
&& let Some(kept) = self.break_collect.as_mut()
{
kept.push(stack);
}
}
/// `BundleItem.dropContent`: `removeOneItemFromBundle` and `Player.drop`,
/// then the drop sound and the used statistic.
pub(super) fn drop_bundle_content(&mut self, slot: usize) {
let items = self.items();
let Some(mut bundle) = self.stack_in(slot).cloned() else {
return;
};
let Some(mut contents) = horizon_client::bundle::Contents::of(items, &bundle) else {
return;
};
let Some(removed) = contents.remove_one(items) else {
return;
};
contents.store(&mut bundle);
let item = Arc::clone(&bundle.item);
*self.stack_in_mut(slot) = Some(bundle);
let Some(position) = self.player.as_ref().map(|p| p.position) else {
return;
};
let at = [position.x, position.y, position.z];
// playRemoveOneSound, then Player.drop and playDropContentsSound at
// the player's block.
let pitch = 0.8 + self.world.random.next_float() * 0.4;
self.audio
.play(horizon_client::bundle::REMOVE_ONE, Some(at), 0.8, pitch);
self.player_drop(removed);
let block = BlockPos::containing(position);
let pitch = 0.8 + self.world.random.next_float() * 0.4;
self.audio.play(
horizon_client::bundle::DROP_CONTENTS,
Some([block.x, block.y, block.z].map(|v| f64::from(v) + 0.5)),
0.8,
pitch,
);
self.award_stat("used", &item, 1);
}
/// `ServerPlayerGameMode.tick` while a player digs: level event 2020 every
/// fourth tick since the START (the hit sound), else 2019 (the crack only),
/// for everyone around, the digger included (`levelEvent(null, ...)`).
pub(super) fn destroy_progress_event(&mut self, pos: [i32; 3], face: [i32; 3], ticks: u32) {
let id = if ticks.is_multiple_of(4) { 2020 } else { 2019 };
let direction = horizon_core::geometry::Direction::ALL
.iter()
.position(|d| d.normal() == face)
.unwrap_or(1);
self.level_event_at(id, pos, direction as i32);
}
/// `LevelRenderer.destroyBlockProgress`: a player's crack on a block (0..=9), or none
/// (any other progress). Cracks not updated for 400 ticks are dropped.
pub(crate) fn destroy_block_progress(&mut self, breaker: i32, pos: [i32; 3], progress: i32) {
let now = self.world.game_time;
self.destroying
.retain(|_, c| now.saturating_sub(c.2) <= 400);
if (0..10).contains(&progress) {
self.destroying.insert(breaker, (pos, progress as u8, now));
} else {
self.destroying.remove(&breaker);
}
}
}
@@ -1,4 +1,5 @@
//! Unit tests that lived in game.rs's inline `tests` module.
use super::inventory_input::drop_whole_stack;
use super::*;
use horizon_client::container_screen::{Button, Clicks, Release, Target};
use horizon_client::keymapping::{KeyAction, SNEAK, SPRINT, ToggleKeys};
+3 -88
View File
@@ -2,6 +2,7 @@
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
mod projectile_world;
mod pufferfish_player;
use super::{
@@ -148,92 +149,6 @@ pub(super) struct ProjWorld<'a, 'w> {
/// block would (`clipIncludingBorder`); None: no border.
pub border: Option<[f64; 4]>,
}
impl ProjectileWorld for ProjWorld<'_, '_> {
fn block_contacts(&self, entity: i32, kind: &str, from: Vec3, to: Vec3, bounds: Aabb) {
horizon_client::block_contact::swept_inside(entity, kind, from, to, bounds, |p| {
let state = self.w.world.block(p.x, p.y, p.z);
(state, &self.w.facts.get(state).name)
});
}
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
let mut scene = SceneCollisions {
world: self.w.world,
binding: self.w.collision,
subject: None,
};
let context = horizon_world::collision_context::CollisionContext::position(from.y);
let r = traverse_blocks(from, to, 256, |p: BlockPos| {
let state = self.w.world.block(p.x, p.y, p.z);
if self.w.facts.is_air(state) {
return None;
}
// ClipContext hands CollisionContext.of(projectile): scaffolding
// is open for a projectile below it.
let shape = self
.w
.collision
.collision_shape_with_context(&mut scene, state, p, &context)
.ok()?;
shape.clip(from, to, p)
});
let hit = r.ok().flatten().map(|hit| {
(
hit.location,
[hit.block.x, hit.block.y, hit.block.z],
hit.direction.normal(),
)
});
match self.border {
Some(bounds) => clip_including_border(hit, from, to, bounds),
None => hit,
}
}
/// Entity.updateFluidInteraction over the projectile's box, not the
/// block at its feet (#3072).
fn in_water(&self, bounds: Aabb) -> bool {
self.w.fluids(bounds, bounds.max_position().y).in_water
}
fn targets(&self) -> &[(Who, Aabb)] {
&self.targets
}
/// `BaseFireBlock.entityInside` for a thrown projectile: fire and soul
/// fire set it alight for 160 ticks.
fn ignites(&self, p: [i32; 3]) -> bool {
let state = self.w.world.block(p[0], p[1], p[2]);
matches!(
&*self.w.facts.get(state).name,
"minecraft:fire" | "minecraft:soul_fire"
)
}
fn empty(&self, p: [i32; 3]) -> bool {
self.w.facts.is_air(self.w.world.block(p[0], p[1], p[2]))
}
/// `WebBlock.entityInside` and `PowderSnowBlock.entityInside`.
fn stuck_speed(&self, p: [i32; 3]) -> Option<Vec3> {
horizon_client::projectiles::stuck_speed_of(
&self.w.facts.get(self.w.world.block(p[0], p[1], p[2])).name,
)
}
fn bubble_column(&self, p: [i32; 3]) -> Option<(bool, bool)> {
horizon_client::motion::bubble_column_at(
self.w.world,
self.w.registry,
self.w.facts,
self.w.collision,
BlockPos::new(p[0], p[1], p[2]),
)
.map(|c| (c.drag, c.surface))
}
fn can_stand_on(&self, p: [i32; 3], entity_y: f64) -> bool {
horizon_client::collisions::projectile_can_stand_on(
self.w.world,
self.w.collision,
BlockPos::new(p[0], p[1], p[2]),
entity_y,
)
}
}
/// `Projectile.isPickable`: a projectile in `#minecraft:redirectable_projectile`
/// (a ghast's fireball and both wind charges) is pickable, so another
/// projectile's `ProjectileUtil.getEntityHitResult` can strike it
@@ -1228,8 +1143,8 @@ impl Game<'_> {
/// `DistanceManager.inEntityTickingRange` in an entity-ticking chunk
/// (block coordinates), from every player's tickets (`update_chunk_tickets`).
pub(super) fn entity_ticking_area(&self) -> impl Fn(i32, i32) -> bool + use<> {
let levels = self.tickets.levels();
move |x, z| levels.entity_ticking_at(x, z)
self.tickets
.entity_ticking_area(&self.world.chunks, self.remote.is_none())
}
/// The entity phase of `tick_creatures`: every mob's tick, deaths, and the
/// projectiles and effects that follow from them.
@@ -0,0 +1,89 @@
//! What a flying projectile asks of the world (`ProjectileWorld` for `ProjWorld`): block
//! contacts, collisions, fluids and the world border.
use super::*;
impl ProjectileWorld for ProjWorld<'_, '_> {
fn block_contacts(&self, entity: i32, kind: &str, from: Vec3, to: Vec3, bounds: Aabb) {
horizon_client::block_contact::swept_inside(entity, kind, from, to, bounds, |p| {
let state = self.w.world.block(p.x, p.y, p.z);
(state, &self.w.facts.get(state).name)
});
}
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
let mut scene = SceneCollisions {
world: self.w.world,
binding: self.w.collision,
subject: None,
};
let context = horizon_world::collision_context::CollisionContext::position(from.y);
let r = traverse_blocks(from, to, 256, |p: BlockPos| {
let state = self.w.world.block(p.x, p.y, p.z);
if self.w.facts.is_air(state) {
return None;
}
// ClipContext hands CollisionContext.of(projectile): scaffolding
// is open for a projectile below it.
let shape = self
.w
.collision
.collision_shape_with_context(&mut scene, state, p, &context)
.ok()?;
shape.clip(from, to, p)
});
let hit = r.ok().flatten().map(|hit| {
(
hit.location,
[hit.block.x, hit.block.y, hit.block.z],
hit.direction.normal(),
)
});
match self.border {
Some(bounds) => clip_including_border(hit, from, to, bounds),
None => hit,
}
}
/// Entity.updateFluidInteraction over the projectile's box, not the
/// block at its feet (#3072).
fn in_water(&self, bounds: Aabb) -> bool {
self.w.fluids(bounds, bounds.max_position().y).in_water
}
fn targets(&self) -> &[(Who, Aabb)] {
&self.targets
}
/// `BaseFireBlock.entityInside` for a thrown projectile: fire and soul
/// fire set it alight for 160 ticks.
fn ignites(&self, p: [i32; 3]) -> bool {
let state = self.w.world.block(p[0], p[1], p[2]);
matches!(
&*self.w.facts.get(state).name,
"minecraft:fire" | "minecraft:soul_fire"
)
}
fn empty(&self, p: [i32; 3]) -> bool {
self.w.facts.is_air(self.w.world.block(p[0], p[1], p[2]))
}
/// `WebBlock.entityInside` and `PowderSnowBlock.entityInside`.
fn stuck_speed(&self, p: [i32; 3]) -> Option<Vec3> {
horizon_client::projectiles::stuck_speed_of(
&self.w.facts.get(self.w.world.block(p[0], p[1], p[2])).name,
)
}
fn bubble_column(&self, p: [i32; 3]) -> Option<(bool, bool)> {
horizon_client::motion::bubble_column_at(
self.w.world,
self.w.registry,
self.w.facts,
self.w.collision,
BlockPos::new(p[0], p[1], p[2]),
)
.map(|c| (c.drag, c.surface))
}
fn can_stand_on(&self, p: [i32; 3], entity_y: f64) -> bool {
horizon_client::collisions::projectile_can_stand_on(
self.w.world,
self.w.collision,
BlockPos::new(p[0], p[1], p[2]),
entity_y,
)
}
}
+2 -68
View File
@@ -4805,74 +4805,8 @@ impl Game<'_> {
}
}
/// The dying model's colour, `ARGB.white(1 - deathTime / 200)`: `Mth.floor` of the alpha
/// shifted as a Java int, so past 200 it wraps as vanilla's does.
fn dissolve_color(death: f32) -> u32 {
let alpha = ((1.0 - death / 200.0) * 255.0).floor() as i32;
(alpha << 24) as u32 | 0x00FF_FFFF
}
/// `DissolveMaskSampler` at the vertex's `UV0`: the same place in the mask's sprite as in the
/// skin's, as the dissolve overlay reads it from `glint_uv` (entity.wgsl).
fn dissolve_mask(quads: &mut [Vertex], skin: [f32; 4], mask: [f32; 4]) {
for v in quads {
let u = (v.uv[0] - skin[0]) / (skin[2] - skin[0]);
let w = (v.uv[1] - skin[1]) / (skin[3] - skin[1]);
v.glint_uv = [
mask[0] + (mask[2] - mask[0]) * u,
mask[1] + (mask[3] - mask[1]) * w,
];
}
}
/// `submitRays`: the death rays from a fixed seed, fading in white to magenta.
fn dragon_rays(base: glam::Mat4, t: f32, white: horizon_client::hud::Rect, out: &mut Vec<Vertex>) {
// Vanilla's dragonRays has no texture. Sample inside the white sprite:
// its border can round into transparent neighbours during interpolation.
let uv = [(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0];
let over = if t > 0.8 {
((t - 0.8) / 0.2).min(1.0)
} else {
0.0
};
let inner = (((1.0 - over) * 255.0) as u32) << 24 | 0x00FF_FFFF;
let outer = 0x00FF_00FF;
let mut random = LegacyRandom::new(432);
let mut m = base;
let count = ((t + t * t) / 2.0 * 60.0).floor() as i32;
let half_sqrt3 = 3f32.sqrt() / 2.0;
let tau = std::f32::consts::TAU;
for _ in 0..count {
let mut f = || random.next_float() * tau;
let q1 = glam::Quat::from_rotation_x(f())
* glam::Quat::from_rotation_y(f())
* glam::Quat::from_rotation_z(f());
let (a, b, c) = (f(), f(), f());
let q2 = glam::Quat::from_rotation_x(a)
* glam::Quat::from_rotation_y(b)
* glam::Quat::from_rotation_z(c + t * std::f32::consts::FRAC_PI_2);
m *= glam::Mat4::from_quat(q1 * q2);
let length = random.next_float() * 20.0 + 5.0 + over * 10.0;
let width = random.next_float() * 2.0 + 1.0 + over * 2.0;
let o = glam::Vec3::ZERO;
let left = glam::Vec3::new(-half_sqrt3 * width, length, -0.5 * width);
let right = glam::Vec3::new(half_sqrt3 * width, length, -0.5 * width);
let bottom = glam::Vec3::new(0.0, length, width);
for (p1, p2) in [(left, right), (right, bottom), (bottom, left)] {
// A triangle as a quad with its last corner repeated.
for (p, c) in [(o, inner), (p1, outer), (p2, outer), (p2, outer)] {
out.push(Vertex {
normal: [0; 4],
pos: m.transform_point3(p).to_array(),
uv,
layer: horizon_client::gfx::DRAGON_RAYS_OVERLAY,
color: c,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
}
}
mod death_render;
use death_render::{dissolve_color, dissolve_mask, dragon_rays};
/// `Entity.push` on the server's copy of a player, then `hurt`. A player's
/// own client hears of its velocity only when a hurt marks it
@@ -0,0 +1,76 @@
//! The dying dragon's dissolve and death rays (`EnderDragonRenderer`).
use super::*;
/// The dying model's colour, `ARGB.white(1 - deathTime / 200)`: `Mth.floor` of the alpha
/// shifted as a Java int, so past 200 it wraps as vanilla's does.
pub(super) fn dissolve_color(death: f32) -> u32 {
let alpha = ((1.0 - death / 200.0) * 255.0).floor() as i32;
(alpha << 24) as u32 | 0x00FF_FFFF
}
/// `DissolveMaskSampler` at the vertex's `UV0`: the same place in the mask's sprite as in the
/// skin's, as the dissolve overlay reads it from `glint_uv` (entity.wgsl).
pub(super) fn dissolve_mask(quads: &mut [Vertex], skin: [f32; 4], mask: [f32; 4]) {
for v in quads {
let u = (v.uv[0] - skin[0]) / (skin[2] - skin[0]);
let w = (v.uv[1] - skin[1]) / (skin[3] - skin[1]);
v.glint_uv = [
mask[0] + (mask[2] - mask[0]) * u,
mask[1] + (mask[3] - mask[1]) * w,
];
}
}
/// `submitRays`: the death rays from a fixed seed, fading in white to magenta.
pub(super) fn dragon_rays(
base: glam::Mat4,
t: f32,
white: horizon_client::hud::Rect,
out: &mut Vec<Vertex>,
) {
// Vanilla's dragonRays has no texture. Sample inside the white sprite:
// its border can round into transparent neighbours during interpolation.
let uv = [(white.u0 + white.u1) / 2.0, (white.v0 + white.v1) / 2.0];
let over = if t > 0.8 {
((t - 0.8) / 0.2).min(1.0)
} else {
0.0
};
let inner = (((1.0 - over) * 255.0) as u32) << 24 | 0x00FF_FFFF;
let outer = 0x00FF_00FF;
let mut random = LegacyRandom::new(432);
let mut m = base;
let count = ((t + t * t) / 2.0 * 60.0).floor() as i32;
let half_sqrt3 = 3f32.sqrt() / 2.0;
let tau = std::f32::consts::TAU;
for _ in 0..count {
let mut f = || random.next_float() * tau;
let q1 = glam::Quat::from_rotation_x(f())
* glam::Quat::from_rotation_y(f())
* glam::Quat::from_rotation_z(f());
let (a, b, c) = (f(), f(), f());
let q2 = glam::Quat::from_rotation_x(a)
* glam::Quat::from_rotation_y(b)
* glam::Quat::from_rotation_z(c + t * std::f32::consts::FRAC_PI_2);
m *= glam::Mat4::from_quat(q1 * q2);
let length = random.next_float() * 20.0 + 5.0 + over * 10.0;
let width = random.next_float() * 2.0 + 1.0 + over * 2.0;
let o = glam::Vec3::ZERO;
let left = glam::Vec3::new(-half_sqrt3 * width, length, -0.5 * width);
let right = glam::Vec3::new(half_sqrt3 * width, length, -0.5 * width);
let bottom = glam::Vec3::new(0.0, length, width);
for (p1, p2) in [(left, right), (right, bottom), (bottom, left)] {
// A triangle as a quad with its last corner repeated.
for (p, c) in [(o, inner), (p1, outer), (p2, outer), (p2, outer)] {
out.push(Vertex {
normal: [0; 4],
pos: m.transform_point3(p).to_array(),
uv,
layer: horizon_client::gfx::DRAGON_RAYS_OVERLAY,
color: c,
light: 0,
glint_uv: [0.0, 0.0],
});
}
}
}
}
+25 -4
View File
@@ -272,6 +272,11 @@ pub(super) trait Entity: EntityRules {
let position = self.position_mut();
*position = position.add_vector(movement);
}
/// Bounds for `AbstractArrow.shouldFall` after a non-SELF piston move.
fn piston_release_bounds(&self) -> Option<Aabb> {
None
}
fn release_after_piston(&mut self, _no_block_collision: bool) {}
/// `setOnGroundWithMovement` at the end of a piston's or shulker lid's
/// `Entity.move`, for the kinds that keep the flag: on a server it runs
/// for every push, sideways ones and those the collision turned away included.
@@ -1816,12 +1821,16 @@ impl Entity for Projectile {
Projectile::bounds(self)
}
fn move_by_piston(&mut self, movement: Vec3) {
self.position = self.position.add_vector(movement);
self.needs_sync = true;
Projectile::move_by_piston(self, movement);
}
fn piston_release_bounds(&self) -> Option<Aabb> {
Projectile::piston_release_bounds(self)
}
fn release_after_piston(&mut self, no_block_collision: bool) {
Projectile::release_after_piston(self, no_block_collision);
}
fn piston_slime_bounce(&mut self, movement: Direction) {
self.velocity = piston_push::bounce_velocity(movement, self.velocity);
self.needs_sync = true;
Projectile::piston_slime_bounce(self, movement);
}
}
@@ -1964,6 +1973,18 @@ impl Entity for ThrownTrident {
Vec3::new(p.x + 0.25, p.y + 0.5, p.z + 0.25),
)
}
fn move_by_piston(&mut self, movement: Vec3) {
ThrownTrident::move_by_piston(self, movement);
}
fn piston_release_bounds(&self) -> Option<Aabb> {
ThrownTrident::piston_release_bounds(self)
}
fn release_after_piston(&mut self, no_block_collision: bool) {
ThrownTrident::release_after_piston(self, no_block_collision);
}
fn piston_slime_bounce(&mut self, movement: Direction) {
ThrownTrident::piston_slime_bounce(self, movement);
}
/// `GameLevel::tick_blocks` sweeps tridents itself.
fn sweeps_blocks_inside(&self) -> bool {
false
@@ -10,6 +10,17 @@ use horizon_core::geometry::{Aabb, Vec3};
const MAX_ENTITY_SECTION_COVERAGE: i64 = 50;
/// The orb quad is asymmetric about its translated centre: its farthest corner
/// is sqrt(0.15² + 0.225²) blocks away. A sphere covers every billboard rotation
/// and pickup-animation position; include rounding of camera-relative vertices.
fn orb_billboard_radius(origin: glam::Vec3) -> f32 {
0.3 + origin.abs().max_element() * f32::EPSILON * 4.0
}
pub(super) fn orb_in_view(view: &horizon_client::gfx::Frustum, origin: glam::Vec3) -> bool {
view.intersects_sphere(origin, orb_billboard_radius(origin))
}
/// The inclusive range of sections an entity's padded render bounds can touch,
/// in the layout whose section 0 starts at `bottom_y`. `None` when the bounds
/// are not finite, hold block coordinates outside `i32`, or span more sections
@@ -638,3 +649,43 @@ mod vanilla_entity_tests {
#[cfg(test)]
#[path = "entity_culling_tests.rs"]
mod tests;
#[cfg(test)]
mod orb_tests {
use super::orb_billboard_radius;
#[test]
fn sphere_contains_the_asymmetric_billboard_including_vertex_rounding() {
use glam::Vec3;
use horizon_client::entities::{ORB_QUAD, ORB_QUAD_SCALE};
for origin in [
Vec3::ZERO,
Vec3::new(-47.25, 12.5, 81.0),
Vec3::new(30_000_000.0, -30_000_000.0, 256.0),
] {
let radius = orb_billboard_radius(origin);
for yaw in (0..360).step_by(15) {
for pitch in [-90.0_f32, -89.99, -45.0, 0.0, 45.0, 89.99, 90.0] {
let (yaw, pitch) = ((yaw as f32).to_radians(), pitch.to_radians());
let forward = Vec3::new(
-yaw.sin() * pitch.cos(),
-pitch.sin(),
yaw.cos() * pitch.cos(),
);
let right = forward.cross(Vec3::Y).normalize_or_zero();
let up = right.cross(forward).normalize_or_zero();
for corner in ORB_QUAD {
let vertex = origin
+ right * (corner[0] * ORB_QUAD_SCALE)
+ up * (corner[1] * ORB_QUAD_SCALE);
assert!(
vertex.distance(origin) <= radius,
"{origin:?}, yaw {yaw}, pitch {pitch}: {vertex:?} outside {radius}"
);
}
}
}
}
}
}
@@ -1197,6 +1197,51 @@ impl Game<'_> {
}
Ok(())
}
/// ServerExplosion: rays from a 16^3 grid lose (resistance + 0.3) * 0.3 per step.
/// A mob explosion (`ServerLevel.explode` with `ExplosionInteraction.MOB`:
/// blocks are kept without mobGriefing, else the mob_explosion_drop_decay
/// rule picks decay).
pub(super) fn explode(
&mut self,
center: [f64; 3],
radius: f32,
source: ExplosionSource,
) -> Result<()> {
self.explode_mob(center, radius, false, source)
}
/// `ServerLevel.explode` with `ExplosionInteraction.MOB` and the caller's fire flag.
pub(super) fn explode_mob(
&mut self,
center: [f64; 3],
radius: f32,
fire: bool,
source: ExplosionSource,
) -> Result<()> {
let interaction = if !self.ui.rules.bool("mob_griefing") {
BlockInteraction::Keep
} else {
self.destroy_type("mob_explosion_drop_decay")
};
self.server_explode_from(center, radius, interaction, fire, source)
}
/// `SulfurCube.tickFuse`: `ExplosionInteraction.TNT` (the
/// tnt_explosion_drop_decay rule) with mobGriefing, `NONE` without.
pub(super) fn explode_sulfur_cube(
&mut self,
center: [f64; 3],
radius: f32,
source: ExplosionSource,
) -> Result<()> {
let interaction = if !self.ui.rules.bool("mob_griefing") {
BlockInteraction::Keep
} else {
self.destroy_type("tnt_explosion_drop_decay")
};
self.server_explode_from(center, radius, interaction, false, source)
}
}
#[cfg(test)]
@@ -2753,3 +2753,6 @@ mod required_tag_tests {
std::fs::remove_dir_all(dir).unwrap();
}
}
#[cfg(test)]
include!("functions/typed_state_range_tests.rs");
@@ -0,0 +1,300 @@
#[cfg(test)]
mod typed_state_range_read_tests {
use super::*;
#[test]
#[ignore = "needs prepared resources, actual pack and scratch homes/cwd"]
fn loaded_pack_state_ranges_match_typed_controls() {
let mut game = crate::game::native_machines::tests::server_game();
let source =
std::path::PathBuf::from(std::env::var_os("HORIZON_TYPED_RANGE_PACK").unwrap());
game.world_root = std::env::current_dir().unwrap().join("typed-range-world");
let target = game.world_root.join("datapacks/typed_ranges");
let preds = target.join("data/ranges/predicate");
std::fs::create_dir_all(&preds).unwrap();
std::fs::copy(source.join("pack.mcmeta"), target.join("pack.mcmeta")).unwrap();
for entry in std::fs::read_dir(source.join("data/ranges/predicate")).unwrap() {
let path = entry.unwrap().path();
std::fs::copy(&path, preds.join(path.file_name().unwrap())).unwrap();
}
game.build_registries();
game.set_allow_commands(true);
assert!(game.run_command("reload"));
assert!(game.fns.enabled.contains(&"file/typed_ranges".to_owned()));
assert!(
game.fns.store.errors.is_empty(),
"{:?}",
game.fns.store.errors
);
assert_eq!(
game.fns
.store
.predicates
.keys()
.filter(|id| id.starts_with("ranges:"))
.count(),
13
);
for entry in std::fs::read_dir(source.join("data/ranges/predicate")).unwrap() {
let path = entry.unwrap().path();
let id = format!("ranges:{}", path.file_stem().unwrap().to_str().unwrap());
let expected: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(game.fns.store.predicates.get(&id), Some(&expected), "{id}");
}
// The helper's empty resident column has not passed server loading.
// Use actual streaming and forced-ticket paths, never a lit/status override.
game.world.chunks.remove(&(0, 0));
// Game::new pauses its placeholder streamer until world opening.
game.streaming.paused = false;
assert!(game.run_command("forceload add 0 0 15 15"));
game.update_chunk_tickets();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
while !game.streaming.is_lit(0, 0) || !game.world.chunks.contains_key(&(0, 0)) {
let _ = game.streaming.update(&mut game.world);
game.spawn_loaded_entities();
assert!(
std::time::Instant::now() < deadline,
"server column did not finish loading"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
game.spawn_loaded_entities();
game.update_chunk_tickets();
assert!(game.run_command("execute if loaded 0 200 0"));
assert!(game.run_command("scoreboard objectives add ranges dummy"));
assert!(game.run_command(
"setblock 0 200 0 minecraft:chest[facing=north,type=single,waterlogged=false]"
));
assert!(game.run_command("item replace block 0 200 0 container.0 with minecraft:stone 3"));
let chest = game.block_entity_nbt([0, 200, 0]).unwrap();
let (expected_chest, _) = horizon_world::nbt::parse_snbt(r#"{components:{},x:0,y:200,z:0,id:"minecraft:chest",Items:[{count:3,Slot:0b,id:"minecraft:stone"}]}"#).unwrap();
assert_eq!(chest, expected_chest);
let clock = game.world.game_time;
let mut failures = Vec::new();
let mut callback = |game: &mut Game<'_>, x: i32, name: &str, expected: bool| {
for (sign, expected) in [("if", expected), ("unless", !expected)] {
for h in ["#r", "#s"] {
assert!(game.run_command(&format!("scoreboard players set {h} ranges -99")));
}
game.run_command(&format!("execute store result score #r ranges store success score #s ranges positioned {x} 200 0 {sign} predicate ranges:{name}"));
for h in ["#r", "#s"] {
let actual = game.score.board.score(h, "ranges").unwrap().value;
if actual != i32::from(expected) {
failures.push(format!(
"x={x} {name}/{sign}/{h}: expected {}, got {actual}",
i32::from(expected)
));
}
}
assert_eq!(game.block_entity_nbt([0, 200, 0]).as_ref(), Some(&chest));
assert_eq!(game.world.game_time, clock);
}
};
callback(&mut game, 0, "always", true);
callback(&mut game, 0, "never", false);
for sign in ["if", "unless"] {
for h in ["#r", "#s"] {
assert!(game.run_command(&format!("scoreboard players set {h} ranges -99")));
}
assert!(!game.run_command(&format!("execute store result score #r ranges store success score #s ranges positioned 0 200 0 {sign} predicate ranges:missing")));
for h in ["#r", "#s"] {
assert_eq!(game.score.board.score(h, "ranges").unwrap().value, -99);
}
}
for (i, facing) in ["north", "south", "west", "east"].iter().enumerate() {
let x = 2 + 2 * i as i32;
let wet = i % 2 == 1;
assert!(game.run_command(&format!(
"setblock {x} 200 0 minecraft:chest[facing={facing},type=single,waterlogged={wet}]"
)));
assert!(game.run_command(&format!("execute if loaded {x} 200 0")));
assert!(game.run_command(&format!("execute if block {x} 200 0 minecraft:chest[facing={facing},type=single,waterlogged={wet}]")));
for (name, want) in [
("enum", matches!(*facing, "south" | "west")),
("enum_max", *facing != "east"),
("exact", *facing == "west"),
("invalid", false),
("inverted", false),
("boolean", !wet),
] {
callback(&mut game, x, name, want);
}
}
assert!(game.run_command("setblock 10 199 0 minecraft:farmland"));
for age in [0, 2, 5, 7] {
assert!(game.run_command(&format!("setblock 10 200 0 minecraft:wheat[age={age}]")));
assert!(game.run_command("execute if loaded 10 200 0"));
assert!(game.run_command(&format!(
"execute if block 10 200 0 minecraft:wheat[age={age}]"
)));
assert!(game.run_command("execute if block 10 199 0 minecraft:farmland"));
for (name, want) in [
("age", (2..=6).contains(&age)),
("age_padded", (2..=6).contains(&age)),
("age_invalid", false),
("age_inverted", false),
("age_exact", age == 5),
] {
callback(&mut game, 10, name, want);
}
}
assert_eq!(game.block_entity_nbt([0, 200, 0]).as_ref(), Some(&chest));
assert_eq!(game.world.game_time, clock);
assert!(
failures.is_empty(),
"typed state range failures:\n{}",
failures.join("\n")
);
}
#[test]
#[ignore = "needs prepared resources, actual pack and scratch homes/cwd"]
fn loaded_pack_direction_ranges_keep_full_and_filtered_domains() {
let mut game = crate::game::native_machines::tests::server_game();
let source =
std::path::PathBuf::from(std::env::var_os("HORIZON_TYPED_RANGE_PACK").unwrap());
game.world_root = std::env::current_dir()
.unwrap()
.join("typed-direction-world");
let target = game.world_root.join("datapacks/typed_ranges");
let preds = target.join("data/ranges/predicate");
std::fs::create_dir_all(&preds).unwrap();
std::fs::copy(source.join("pack.mcmeta"), target.join("pack.mcmeta")).unwrap();
for entry in std::fs::read_dir(source.join("data/ranges/predicate")).unwrap() {
let path = entry.unwrap().path();
std::fs::copy(&path, preds.join(path.file_name().unwrap())).unwrap();
}
game.build_registries();
game.set_allow_commands(true);
assert!(game.run_command("reload"));
assert!(game.fns.enabled.contains(&"file/typed_ranges".to_owned()));
assert!(
game.fns.store.errors.is_empty(),
"{:?}",
game.fns.store.errors
);
assert_eq!(
game.fns
.store
.predicates
.keys()
.filter(|id| id.starts_with("ranges:"))
.count(),
13
);
for entry in std::fs::read_dir(source.join("data/ranges/predicate")).unwrap() {
let path = entry.unwrap().path();
let id = format!("ranges:{}", path.file_stem().unwrap().to_str().unwrap());
let expected: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
assert_eq!(game.fns.store.predicates.get(&id), Some(&expected), "{id}");
}
// The helper's empty resident column has not passed server loading.
// Use actual streaming and forced-ticket paths, never a lit/status override.
game.world.chunks.remove(&(0, 0));
// Game::new pauses its placeholder streamer until world opening.
game.streaming.paused = false;
assert!(game.run_command("forceload add 0 0 15 15"));
game.update_chunk_tickets();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
while !game.streaming.is_lit(0, 0) || !game.world.chunks.contains_key(&(0, 0)) {
let _ = game.streaming.update(&mut game.world);
game.spawn_loaded_entities();
assert!(
std::time::Instant::now() < deadline,
"server column did not finish loading"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
game.spawn_loaded_entities();
game.update_chunk_tickets();
assert!(game.run_command("execute if loaded 0 200 0"));
let cases = [
(
"direction_span",
"facing",
serde_json::json!({"min":"north","max":"west"}),
),
(
"direction_reversed",
"facing",
serde_json::json!({"min":"up","max":"down"}),
),
(
"vertical_up",
"vertical_direction",
serde_json::json!({"min":"up"}),
),
(
"vertical_invalid",
"vertical_direction",
serde_json::json!({"min":"north"}),
),
];
for (name, prop, bounds) in &cases {
std::fs::write(preds.join(format!("{name}.json")), serde_json::json!({"type":"minecraft:location_check","predicate":{"block":{"state":{prop.to_string():bounds}}}}).to_string()).unwrap();
}
assert!(game.run_command("reload"));
assert_eq!(
game.fns
.store
.predicates
.keys()
.filter(|id| id.starts_with("ranges:"))
.count(),
17
);
assert!(game.run_command("scoreboard objectives add ranges dummy"));
assert!(game.run_command("setblock 0 200 0 minecraft:chest"));
assert!(game.run_command("item replace block 0 200 0 container.0 with minecraft:stone 3"));
let chest = game.block_entity_nbt([0, 200, 0]).unwrap();
let clock = game.world.game_time;
let callback = |game: &mut Game<'_>, x: i32, name: &str, expected: bool| {
for (sign, want) in [("if", expected), ("unless", !expected)] {
for h in ["#r", "#s"] {
assert!(game.run_command(&format!("scoreboard players set {h} ranges -99")));
}
game.run_command(&format!("execute store result score #r ranges store success score #s ranges positioned {x} 200 0 {sign} predicate ranges:{name}"));
for h in ["#r", "#s"] {
assert_eq!(
game.score.board.score(h, "ranges").unwrap().value,
i32::from(want),
"{name}/{sign}/{h}"
);
}
assert_eq!(game.block_entity_nbt([0, 200, 0]).as_ref(), Some(&chest));
assert_eq!(game.world.game_time, clock);
}
};
for facing in ["down", "up", "north", "south", "west", "east"] {
assert!(game.run_command(&format!(
"setblock 2 200 0 minecraft:barrel[facing={facing}]"
)));
assert!(game.run_command(&format!(
"execute if block 2 200 0 minecraft:barrel[facing={facing}]"
)));
assert!(game.run_command("execute if loaded 2 200 0"));
callback(
&mut game,
2,
"direction_span",
matches!(facing, "north" | "south" | "west"),
);
callback(&mut game, 2, "direction_reversed", false);
}
for (x, block) in [(4, "pointed_dripstone"), (6, "sulfur_spike")] {
for vertical in ["up", "down"] {
assert!(game.run_command(&format!(
"setblock {x} 200 0 minecraft:{block}[vertical_direction={vertical}]"
)));
assert!(game.run_command(&format!(
"execute if block {x} 200 0 minecraft:{block}[vertical_direction={vertical}]"
)));
assert!(game.run_command(&format!("execute if loaded {x} 200 0")));
callback(&mut game, x, "vertical_up", vertical == "up");
callback(&mut game, x, "vertical_invalid", false);
}
}
}
}
@@ -32,6 +32,19 @@ impl Game<'_> {
}
}
/// `GameModeSwitcherScreen.mouseReleased`: releasing the debug
/// modifier's own mouse button applies the hovered mode and closes the
/// screen, as the modifier key's release does (`keyReleased`). A modifier
/// bound to a keyboard key matches no mouse button.
pub(super) fn switcher_release(&mut self, button: winit::event::MouseButton) {
if horizon_client::game_mode_switcher::release_applies(
&keybinds::mouse_name(button),
self.settings.keys.get("key.debug.modifier"),
) {
self.close_gamemode_switcher(true);
}
}
pub(super) fn draw_gamemode_switcher(&mut self, batch: &mut HudBatch) {
let (w, h) = (batch.width as i32, batch.height as i32);
let Some(switcher) = &mut self.gamemode_switcher else {
@@ -0,0 +1,118 @@
//! The player's hands and what it wears: the stack in the acting hand (`getItemInHand`), the hand of
//! the item in use and running code with the other hand acting, `Inventory.add`, and the worn glider
//! and freeze-immune gear.
use super::*;
impl Game<'_> {
/// The stack in the hand that acts (`getItemInHand(hand)`).
pub(super) fn held(&self) -> Option<&ItemStack> {
self.stack_in(self.held_slot())
}
pub(super) fn held_mut(&mut self) -> &mut Option<ItemStack> {
self.stack_in_mut(self.held_slot())
}
/// The slot of the hand that acts: the selected one, or `OFFHAND_SLOT`.
/// `LivingEntity.canGlide`: some worn piece `gliding_pieces` lets glide,
/// or a stack in a hand that glides there (`canGlideUsing` walks every
/// `EquipmentSlot`).
pub(super) fn glider_worn(&self) -> bool {
let items = self.res.items();
let hand = |stack: Option<&ItemStack>, slot: &str| {
stack.is_some_and(|s| horizon_client::item_sync::can_glide_using(s, slot, items))
};
gliding_pieces(items, &self.armor).contains(&true)
|| hand(self.inventory[self.selected].as_ref(), "mainhand")
|| hand(self.offhand.as_ref(), "offhand")
}
/// `LivingEntity.updateFallFlying`: the glide's wear. `Util.getRandom`
/// picks one of the gliders that will not break (from the entity's own
/// random), and
/// `ItemStack.hurtAndBreak` takes `points` of its durability (Unbreaking
/// may save it). Callers skip creative.
pub(super) fn wear_glider(&mut self, points: u32) {
let gliding = gliding_pieces(self.res.items(), &self.armor);
let pieces: Vec<usize> = (0..4).filter(|&i| gliding[i]).collect();
if pieces.is_empty() {
return;
}
// `Util.getRandom(slotsWithGliders, this.random)`: the entity's own
// random, which never touches the level's stream.
let pick = random_source::entity().next_int_bound(pieces.len() as i32);
let pick = pieces[pick.unwrap_or(0) as usize];
if let Some(stack) = self.armor[pick].clone() {
let loss = self.wear(&stack, points);
if let Some(piece) = self.armor[pick].as_mut() {
piece.damage += loss;
}
}
}
/// `LivingEntity.canFreeze`'s armor half: any worn piece in
/// `freeze_immune_wearables`.
pub(crate) fn wears_freeze_immune(&self) -> bool {
let immune = self
.res
.items()
.item_tags
.get("minecraft:freeze_immune_wearables");
self.armor
.iter()
.flatten()
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)))
}
pub(super) fn held_slot(&self) -> usize {
match self.hand {
Hand::Main => self.selected,
Hand::Off => shield::OFFHAND_SLOT,
}
}
/// `LivingEntity.getUsedItemHand`: the hand of the item in use.
pub(super) fn used_hand(&self) -> Option<Hand> {
self.using.map(|(slot, _)| {
if slot == shield::OFFHAND_SLOT {
Hand::Off
} else {
Hand::Main
}
})
}
/// Run `f` with `hand` acting (`held`, `consume_held`, `swing_arm`).
pub(super) fn with_hand<T>(&mut self, hand: Hand, f: impl FnOnce(&mut Self) -> T) -> T {
let prior = std::mem::replace(&mut self.hand, hand);
let out = f(self);
self.hand = prior;
out
}
/// Inventory.add: returns the stack that did not fit.
pub(super) fn give(&mut self, mut stack: ItemStack) -> Option<ItemStack> {
let tutorial_item = matches!(
self.tutorial.step,
tutorial::Step::FindTree | tutorial::Step::PunchTree | tutorial::Step::CraftPlanks
)
.then(|| stack.clone());
let max = self
.res
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack);
let before = stack.count;
let left = inventory_add(
&mut self.inventory,
&mut self.offhand,
self.selected,
max,
&mut stack,
);
if stack.count < before {
self.tutorial_get_item(tutorial_item.as_ref());
}
left.then_some(stack)
}
}
@@ -0,0 +1,37 @@
//! The HUD-render gates `Hud`/`GameRenderer` apply before drawing.
/// GameRenderer.render3dHud: the debug crosshair, first person with the HUD shown, and only over
/// a level. The F3 overlay stays on when the world closes, so it must not reach the panorama.
pub(super) fn debug_crosshair_drawn(
enabled: bool,
first_person: bool,
hide_gui: bool,
panorama: bool,
) -> bool {
enabled && first_person && !hide_gui && !panorama
}
/// GameRenderer.shouldRenderBlockOutline, and only over a level. A panorama frame (the title
/// screen, LevelLoadingScreen's Reason.OTHER) replaces the level vanilla draws under it, so the
/// player's target must not leave its outline there.
pub(super) fn block_outline_drawn(
hide_gui: bool,
entity_targeted: bool,
can_build: bool,
panorama: bool,
) -> bool {
!hide_gui && !entity_targeted && can_build && !panorama
}
/// Blocks whose class implements `GameMasterBlock`: the command blocks, structure
/// block, jigsaw and the test blocks.
pub(super) fn is_game_master_block(name: &str) -> bool {
horizon_client::command_block::Mode::of_block(name).is_some()
|| matches!(
name,
"minecraft:structure_block"
| "minecraft:jigsaw"
| "minecraft:test_block"
| "minecraft:test_instance_block"
)
}
@@ -2,6 +2,12 @@
//! drags, throws and the press/release of a slot click.
use super::*;
/// Q throws the whole stack with Control down: the same modifier word the
/// screens read (`has_control_down`), never a latched sprint toggle.
pub(super) fn drop_whole_stack(modifiers: winit::keyboard::ModifiersState) -> bool {
modifiers.control_key()
}
impl Game<'_> {
/// `CreativeModeInventoryScreen.keyPressed` on the search tab: a hotbar
/// key over a slot swaps before the search box sees it.
@@ -118,6 +124,15 @@ impl Game<'_> {
return;
}
let (slot, outside) = self.inventory_target();
// mouseClicked: `lastClickSlot = slot` for every press that reaches
// the container, and the double click is the same slot and button
// within 250 ms. A click outside records no slot, ending the chain.
let double = horizon_client::container_screen::double_click(
&mut self.last_inv_click,
slot,
right,
std::time::Instant::now(),
);
let shift = self.has_shift_down();
let target = Target::of(slot, outside);
let input = self.inv_clicks.press(
@@ -146,11 +161,6 @@ impl Game<'_> {
&& shift
&& slot != 200
{
let now = std::time::Instant::now();
let double = self.last_inv_click.is_some_and(|(at, last, was_right)| {
last == slot && was_right == right && now.duration_since(at).as_millis() < 250
});
self.last_inv_click = Some((now, slot, right));
// mouseClicked's QUICK_MOVE (and Horizon's at-press QUICK_MOVE
// of a carried stack, mouseReleased's in vanilla) remember the
// stack it moves.
@@ -190,16 +200,12 @@ impl Game<'_> {
{
// An item tab's display slots take no drags or PICKUP_ALL.
let display = slot >= Creative::DISPLAY;
let now = std::time::Instant::now();
let double = !shift
let double = double
&& !shift
&& !right
&& !display
&& self.carried.is_some()
&& self.last_inv_click.is_some_and(|(at, last, was_right)| {
last == slot && !was_right && now.duration_since(at).as_millis() < 250
})
&& self.pick_all_allowed(slot);
self.last_inv_click = Some((now, slot, right));
if double && self.menu_click(Some(slot), 0, Input::PickupAll) {
self.last_inv_click = None;
// The double click's own release (`mouseReleased`'s PICKUP_ALL).
@@ -374,4 +374,138 @@ impl Game<'_> {
self.draw_slot_item(batch, drag.as_ref(), i, &stack, ox + sx, oy + sy);
}
}
/// Slot ids: 0..36 player inventory, 100..109 crafting grid, 200 result.
pub(super) fn container_kind(&self) -> Option<&BlockEntity> {
self.container
.and_then(|k| self.world.block_entities.get(&k))
}
/// 300.. are the open container's slots.
pub(super) fn slots(&self) -> Vec<usize> {
if self.creative_inventory_open() {
return Creative::inventory_slots().collect();
}
if self.creative_tab_open() {
return Creative::tab_slots().collect();
}
if let Some(v) = self.station_slots().or_else(|| self.mount_slots()) {
return v;
}
let mut v: Vec<usize> = (0..36).collect();
if let Some(shown) = self.native_screen_slots() {
v.extend(shown);
return v;
}
match self.container_kind() {
Some(BlockEntity::Chest(_)) => v.extend((0..self.container_size()).map(|i| 300 + i)),
Some(BlockEntity::Furnace(_)) => v.extend(300..303),
Some(BlockEntity::Brewing(_)) => v.extend(300..305),
Some(BlockEntity::Crafter(_)) => v.extend(300..310),
Some(
BlockEntity::Sign(_)
| BlockEntity::Jukebox(_)
| BlockEntity::Beacon(_)
| BlockEntity::Lectern(_)
| BlockEntity::Sculk(_)
| BlockEntity::Banner(_)
| BlockEntity::CopperGolemStatue(_)
| BlockEntity::CreakingHeart(_)
| BlockEntity::Spawner(_)
| BlockEntity::TrialSpawner(_)
| BlockEntity::Vault(_)
| BlockEntity::Beehive(_)
| BlockEntity::CommandBlock(_)
| BlockEntity::EndGateway(_)
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Bell
| BlockEntity::Conduit(_)
| BlockEntity::EndPortal
| BlockEntity::EnchantingTable
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_)
| BlockEntity::Brushable(_)
| BlockEntity::Shelf(_)
| BlockEntity::Mod(_)
| BlockEntity::PotentSulfur(_)
| BlockEntity::StructureBlock(_)
| BlockEntity::JigsawBlock(_)
| BlockEntity::TestBlock(_)
| BlockEntity::TestInstanceBlock(_),
) => {}
None => {
v.extend((0..self.craft_size * self.craft_size).map(|i| 100 + i));
v.push(200);
if self.craft_size == 2 {
v.extend(500..505);
}
}
}
v
}
/// How many slots `slots` lists, counted without building the list: the
/// screen render hook asks once a frame and must not allocate. Same
/// branches in the same order, so the two stay in step.
pub(super) fn slot_count(&self) -> usize {
if self.creative_inventory_open() {
return Creative::inventory_slots().count();
}
if self.creative_tab_open() {
return Creative::tab_slots().count();
}
if let Some(n) = self
.station_slot_count()
.or_else(|| self.mount_slot_count())
{
return n;
}
let inventory = 36;
if let Some(shown) = self.native_screen_slot_count() {
return inventory + shown;
}
let container = match self.container_kind() {
Some(BlockEntity::Chest(_)) => self.container_size(),
Some(BlockEntity::Furnace(_)) => 3,
Some(BlockEntity::Brewing(_)) => 5,
Some(BlockEntity::Crafter(_)) => 10,
Some(
BlockEntity::Sign(_)
| BlockEntity::Jukebox(_)
| BlockEntity::Beacon(_)
| BlockEntity::Lectern(_)
| BlockEntity::Sculk(_)
| BlockEntity::Banner(_)
| BlockEntity::CopperGolemStatue(_)
| BlockEntity::CreakingHeart(_)
| BlockEntity::Spawner(_)
| BlockEntity::TrialSpawner(_)
| BlockEntity::Vault(_)
| BlockEntity::Beehive(_)
| BlockEntity::CommandBlock(_)
| BlockEntity::EndGateway(_)
| BlockEntity::Skull(_)
| BlockEntity::DaylightDetector
| BlockEntity::Bell
| BlockEntity::Conduit(_)
| BlockEntity::EndPortal
| BlockEntity::EnchantingTable
| BlockEntity::Campfire(_)
| BlockEntity::ChiseledBookshelf(_)
| BlockEntity::Brushable(_)
| BlockEntity::Shelf(_)
| BlockEntity::Mod(_)
| BlockEntity::PotentSulfur(_)
| BlockEntity::StructureBlock(_)
| BlockEntity::JigsawBlock(_)
| BlockEntity::TestBlock(_)
| BlockEntity::TestInstanceBlock(_),
) => 0,
None => {
self.craft_size * self.craft_size + 1 + if self.craft_size == 2 { 5 } else { 0 }
}
};
inventory + container
}
}
@@ -573,6 +573,259 @@ impl Game<'_> {
self.give_orb_points(gained);
}
}
/// `Player.dropEquipment` for the player who died: unless keep_inventory
/// is on, `destroyVanishingCursedItems`, then `Inventory.dropAll` from
/// `at` (the eyes less 0.3): slots 0..36, armor feet to head (36..39),
/// the off hand (40), each thrown in a random direction. The mods
/// (`native_loot_drops`, the dying player `who`: uuid and entity id)
/// may add to, change or take out the stacks before they are thrown.
pub(super) fn drop_inventory(&mut self, at: [f64; 3], who: (u128, i32)) {
if self.ui.rules.bool("keep_inventory") {
return;
}
let mut stacks = death_drops(&mut self.inventory, &mut self.offhand, &mut self.armor);
self.apply_equipment();
let context = horizon_client::mod_api::NativeLootDrops {
table: "",
dimension: self.dim.id(),
origin: at,
block: None,
entity: Some((who.1 as u32, "minecraft:player")),
player: Some(who.0),
tool: None,
explosion: None,
used: false,
};
self.native_loot_drops(&context, &mut stacks);
stacks.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
for stack in stacks {
let r = &mut self.world.random;
let v = horizon_client::entities::drop_motion(r, self.yaw, self.pitch, true);
self.spawn_item(stack, at, v, 40);
}
}
/// Item entity `i` leaves the level (`Entity.discard`); mods hear it.
pub(super) fn remove_dropped(&mut self, i: usize) -> ItemEntity {
let e = self.dropped.remove(i);
self.native_entity_left(e.id);
e
}
/// Experience orb `i` leaves the level (`Entity.discard`); mods hear it.
pub(super) fn remove_orb(&mut self, i: usize) -> ExperienceOrb {
let o = self.orbs.remove(i);
self.native_entity_left(o.id);
o
}
/// The item entity `spawn_item` just added, unless a native mod kept it
/// out of the level (`native_entity_join_level`).
pub(super) fn just_spawned_item(&mut self) -> Option<&mut ItemEntity> {
let id = self.next_entity;
self.dropped.last_mut().filter(|d| d.id == id)
}
/// `new ItemEntity(level, x, y, z, stack, dx, dy, dz)`.
pub(super) fn spawn_item(
&mut self,
stack: ItemStack,
position: [f64; 3],
velocity: [f64; 3],
pickup_delay: u32,
) {
self.add_item_entity(stack, position, Some(velocity), pickup_delay);
}
/// `new ItemEntity(level, x, y, z, stack)`: the item leaves with its own
/// random's motion, which this returns.
pub(super) fn spawn_item_own(
&mut self,
stack: ItemStack,
position: [f64; 3],
pickup_delay: u32,
) -> [f64; 3] {
self.add_item_entity(stack, position, None, pickup_delay)
}
pub(super) fn add_item_entity(
&mut self,
stack: ItemStack,
position: [f64; 3],
velocity: Option<[f64; 3]>,
pickup_delay: u32,
) -> [f64; 3] {
let (item, count) = (stack.item.clone(), stack.count as i32);
self.next_entity += 1;
let p = Vec3::new(position[0], position[1], position[2]);
// The item's own random, in vanilla's order: Entity's UUID, ItemEntity's
// bobOffs and yaw, then (without a given motion) the constructor's motion.
let mut own = random_source::entity();
let uuid = horizon_client::save::insecure_uuid(&mut own);
self.persist.uuids.insert(self.next_entity, uuid);
let bob = own.next_float() * std::f32::consts::PI * 2.0;
let yaw = own.next_float() * 360.0;
let velocity = velocity.unwrap_or_else(|| {
let x = own.next_double() * 0.2 - 0.1;
[x, 0.2, own.next_double() * 0.2 - 0.1]
});
self.dropped.push(ItemEntity {
rotation: [yaw, 0.0],
pickup_delay: i64::from(pickup_delay),
random: own,
..ItemEntity::new(
self.next_entity,
stack,
p,
Vec3::new(velocity[0], velocity[1], velocity[2]),
bob,
)
});
// ServerLevel.addFreshEntity.
if self.native_joins_flush(false, self.next_entity) {
self.native_item_spawn(self.next_entity, &item, count, position);
}
velocity
}
/// Block.popResource
pub(super) fn pop_resource(&mut self, pos: [i32; 3], mut stack: ItemStack) {
self.pending.attach(pos, &mut stack);
// Block.popResource: Mth.nextDouble offsets from the level random; the
// motion is `new ItemEntity(..)`'s own, from the item's random.
let p = spill::pop_position(&mut self.world.random, pos);
// Block.popResource draws its offsets before the private overload checks the gamerule.
if !self.ui.rules.bool("block_drops") {
return;
}
self.spawn_item_own(stack, p, 10);
}
/// `Block.popResourceFromFace`: the loot leaves the clicked face with the
/// face's own motion instead of the item's random one.
pub(super) fn pop_resource_from_face(
&mut self,
pos: [i32; 3],
face: [i32; 3],
mut stack: ItemStack,
) {
self.pending.attach(pos, &mut stack);
let (p, v) = spill::pop_position_from_face(&mut self.world.random, pos, face);
// The private overload checks the gamerule after the offsets are drawn.
if !self.ui.rules.bool("block_drops") {
return;
}
self.spawn_item(stack, p, v, 10);
}
/// Player.drop: thrown from the eyes along the view direction.
pub(super) fn drop_held(&mut self, all: bool) {
if self.eye_position().is_none() {
return;
}
let spectator = self.spectator();
let Some(thrown) = horizon_client::items::take_held_drop(
&mut self.inventory[self.selected],
all,
spectator,
) else {
return;
};
self.player_drop(thrown);
}
/// `Player.createItemStackToDrop`: an item entity thrown from the eyes
/// along the view direction. `thrown_from_hand` (`drop(stack, true)`, the
/// drop key) names the player as the thrower; `drop(stack, false)`, for
/// what no longer fits in the inventory, does not.
pub(super) fn throw_from_eyes(
&mut self,
thrown: ItemStack,
eye: [f64; 3],
thrown_from_hand: bool,
) {
let r = &mut self.world.random;
let v = horizon_client::entities::drop_motion(r, self.yaw, self.pitch, false);
// `getEyeY() - 0.3F`.
let y = eye[1] - f64::from(0.3f32);
self.spawn_item(thrown, [eye[0], y, eye[2]], v, 40);
// A native mod can keep the item out of the level (native_entity_join_level).
if thrown_from_hand {
let uuid = self.acting_player_uuid();
if let Some(item) = self.just_spawned_item() {
item.thrower = Some(uuid);
}
}
}
/// DropExperienceBlock / RedStoneOreBlock / SculkBlock experience ranges.
pub(super) fn block_experience(&mut self, name: &str) -> u32 {
let (min, max) = match name.trim_start_matches("minecraft:") {
"coal_ore" | "deepslate_coal_ore" => (0, 2),
"nether_gold_ore" => (0, 1),
"lapis_ore" | "deepslate_lapis_ore" | "nether_quartz_ore" => (2, 5),
"diamond_ore" | "deepslate_diamond_ore" | "emerald_ore" | "deepslate_emerald_ore" => {
(3, 7)
}
"redstone_ore" | "deepslate_redstone_ore" => (1, 5),
"sculk" => (1, 1),
"sculk_catalyst" | "sculk_sensor" | "calibrated_sculk_sensor" | "sculk_shrieker" => {
(5, 5)
}
// SpawnerBlock.spawnAfterBreak: 15 + nextInt(15) + nextInt(15).
"spawner" => {
let r = &mut self.world.random;
let a = r.next_int_bound(15).unwrap_or(0);
return (15 + a + r.next_int_bound(15).unwrap_or(0)) as u32;
}
_ => (0, 0),
};
// ConstantInt.sample (sculk) does not advance the level random.
if min == max {
return min as u32;
}
(min + self.world.random.next_int_bound(max - min + 1).unwrap_or(0)) as u32
}
/// `FurnaceResultSlot.checkTakeAchievements`: the player took from the output of the
/// furnace at `pos`, which pops the experience of what it made at them
/// (`awardUsedRecipesAndPopExperience`) and forgets it.
pub(super) fn award_furnace_recipes(&mut self, pos: (i32, i32, i32), at: [f64; 3]) {
let used = match self.world.block_entities.get_mut(&pos) {
Some(BlockEntity::Furnace(f)) => {
let used = f.recipe_experience(world_recipes!(self));
f.recipes_used.clear();
used
}
_ => return,
};
for (count, experience) in used {
let xp = horizon_client::containers::create_experience(
count,
experience,
&mut self.world.random,
);
self.spawn_orbs(at, xp);
}
}
/// `Player.giveExperiencePoints`, and `giveExperienceLevels`' level-up
/// sound every fifth level (at most every 100 ticks).
pub(super) fn give_orb_points(&mut self, gained: u32) {
let Some(gained) = self.xp_change(gained as i32) else {
return;
};
let gained = gained.max(0);
let old_level = self.experience.level;
let gave = self.experience.give(gained);
self.native_player_level_changed(old_level);
// Player.giveExperienceLevels runs per level, so the first multiple of 5
// crossed plays the sound (and starts the cooldown for the rest).
if let Some(level) = gave {
self.play_level_up(1, level);
}
}
}
#[cfg(test)]
+2 -3
View File
@@ -2729,9 +2729,8 @@ impl Game<'_> {
for p in &mut self.creatures.projectiles {
p.needs_sync = false;
}
for d in &mut self.dropped {
d.needs_sync = false;
}
self.tridents.iter_mut().for_each(|t| t.needs_sync = false);
self.dropped.iter_mut().for_each(|d| d.needs_sync = false);
// The step command reaches clients before the events its tick
// generated, as ServerTickRateManager.updateStepTicks does.
host.hub.sync_ticking(
+2 -25
View File
@@ -6296,31 +6296,8 @@ fn eye_columns(at: [f64; 3], body: Dimensions) -> [std::ops::RangeInclusive<i32>
[range(at[0]), range(at[2])]
}
/// What `ServerPlayerGameMode.handleBlockBreakAction`'s STOP_DESTROY_BLOCK branch does with a
/// guest's STOP.
#[derive(Clone, Copy, Debug, PartialEq)]
enum StopDig {
/// A block the last START did not name: the STOP is not this dig's.
Other,
/// Already broken, by the guest's own tick: only the crack goes.
Gone,
/// The dig the START counts is at least 70 % done: the block breaks outright.
Break,
/// Less than that: the server finishes it alone from that same START.
Delay(u64),
}
/// The STOP branch's decision: a STOP is measured from the START that named its block
/// (`destroyPos`, which an abort or a STOP from out of reach leaves alone), whose ticks times
/// `getDestroyProgress` decide between the break and the delayed dig (#4124).
fn stop_dig(play: &GuestPlay, pos: [i32; 3], air: bool, ticks: u64, per_tick: f32) -> StopDig {
match play.dig_start_for(pos) {
None => StopDig::Other,
Some(_) if air => StopDig::Gone,
Some(start) if per_tick * (ticks - start + 1) as f32 >= 0.7 => StopDig::Break,
Some(start) => StopDig::Delay(start),
}
}
mod dig_stop;
use dig_stop::{StopDig, stop_dig};
/// ServerPlayer.swingAndResetAttackStrength resets only when a use starts a swing.
fn reset_use_attack_strength(ticker: &mut u32, before: u32, after: u32) {
@@ -0,0 +1,34 @@
//! A guest's STOP_DESTROY_BLOCK (`ServerPlayerGameMode.handleBlockBreakAction`).
use super::*;
/// What `ServerPlayerGameMode.handleBlockBreakAction`'s STOP_DESTROY_BLOCK branch does with a
/// guest's STOP.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) enum StopDig {
/// A block the last START did not name: the STOP is not this dig's.
Other,
/// Already broken, by the guest's own tick: only the crack goes.
Gone,
/// The dig the START counts is at least 70 % done: the block breaks outright.
Break,
/// Less than that: the server finishes it alone from that same START.
Delay(u64),
}
/// The STOP branch's decision: a STOP is measured from the START that named its block
/// (`destroyPos`, which an abort or a STOP from out of reach leaves alone), whose ticks times
/// `getDestroyProgress` decide between the break and the delayed dig (#4124).
pub(super) fn stop_dig(
play: &GuestPlay,
pos: [i32; 3],
air: bool,
ticks: u64,
per_tick: f32,
) -> StopDig {
match play.dig_start_for(pos) {
None => StopDig::Other,
Some(_) if air => StopDig::Gone,
Some(start) if per_tick * (ticks - start + 1) as f32 >= 0.7 => StopDig::Break,
Some(start) => StopDig::Delay(start),
}
}
@@ -881,6 +881,7 @@ impl Game<'_> {
);
e.pitch = t.pitch;
e.velocity = at(t.velocity);
e.needs_sync = t.needs_sync;
// Projectile.getAddEntityPacket: the owner's id, the guest's that
// threw it.
e.data = data;
+64 -3
View File
@@ -544,6 +544,16 @@ impl Game<'_> {
let free_after = scene
.unobstructed(moved_box.deflate(Vec3::new(1.0E-5, 1.0E-5, 1.0E-5)))
.unwrap_or(true);
// Entity.move in `vehicle.move`, before the checks: a long move through
// water or a reset block resets the fall, which a move put back keeps
// (`absSnapTo` restores only the position).
if let Some(i) = mount {
self.mobs[i].fall_distance =
self.vehicle_fall_reset(self.mobs[i].fall_distance, old, resolved);
} else if let Some(i) = boat {
self.boats[i].fall_distance =
self.vehicle_fall_reset(self.boats[i].fall_distance, old, resolved);
}
if free_before && (wrongly || !free_after) {
log::warn!("vehicle {id} moved wrongly");
back(host, old, yaw);
@@ -660,9 +670,7 @@ impl Game<'_> {
.push_by_fluid(&self.world, &self.res.motion().fluids)
.0
> 0.0;
// Entity.move: a long move through water or a reset block resets it first.
self.boats[i].fall_distance =
self.vehicle_fall_reset(self.boats[i].fall_distance, old, resolved);
// (A long move's reset ran before the checks above.)
let fall = if in_water {
0.0
} else {
@@ -935,12 +943,25 @@ mod controller_tests {
.default_state("minecraft:stone")
.unwrap()
.0;
let cobweb = game
.pack
.assets
.registry
.default_state("minecraft:cobweb")
.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);
}
}
// Off the fall's line: only the long moves at the end cross them.
chunk.set(6, 205, 4, cobweb);
chunk.set(8, 205, 4, cobweb);
for y in 205..=207 {
chunk.set(10, y, 4, stone);
}
game.world.chunks.insert((0, 0), Arc::new(chunk));
game.block_ticks
.add_empty_chunk(ChunkPos::new(0, 0))
@@ -1031,5 +1052,45 @@ mod controller_tests {
_ => false,
});
assert!(dust, "the guest sees the landing dust");
// Entity.move in `vehicle.move`: a move of a block or more casts along
// it (FALLDAMAGE_RESETTING, WATER), and the cobweb it crosses resets
// the fall (a fall loaded from its save, here) before the move counts.
game.guest_move_vehicle(&mut host, 2, &move_to(205.5, false));
assert_eq!(game.mobs[0].position.y, 205.5, "the guest lifts the horse");
game.mobs[0].fall_distance = 4.0;
game.guest_move_vehicle(
&mut host,
2,
&play::MoveVehicleRequest {
position: [6.5, 205.5, 4.5],
yaw: 0.0,
pitch: 0.0,
on_ground: false,
},
);
assert_eq!(game.mobs[0].position.x, 6.5, "the guest moves the horse on");
assert_eq!(
game.mobs[0].fall_distance, 0.0,
"a long move through a cobweb resets the fall"
);
// handleMoveVehicle moves the horse before it checks the move: one
// into a wall is put back (moved wrongly), and keeps the reset.
game.mobs[0].fall_distance = 4.0;
game.guest_move_vehicle(
&mut host,
2,
&play::MoveVehicleRequest {
position: [11.5, 205.5, 4.5],
yaw: 0.0,
pitch: 0.0,
on_ground: false,
},
);
assert_eq!(game.mobs[0].position.x, 6.5, "the move is put back");
assert_eq!(
game.mobs[0].fall_distance, 0.0,
"a move put back keeps the cobweb's reset"
);
}
}
+3 -53
View File
@@ -184,59 +184,9 @@ pub(super) const HEIGHT: f64 = 0.7_f32 as f64;
/// `EntityEvent.TNT_PRIME`. `MinecartTNT.primeFuse` broadcasts it;
/// `MinecartTNT.handleEntityEvent` answers by calling `primeFuse`.
pub(super) const TNT_PRIME_EVENT: i8 = 70;
/// `AbstractMinecart.getDefaultDisplayOffset` (6), in sixteenths of a block, as
/// `MinecartChest` (8) and `MinecartHopper` (1) override it.
pub(super) fn default_display_offset(kind: CartKind) -> f64 {
match kind {
CartKind::Chest => 8.0,
CartKind::Hopper => 1.0,
CartKind::Rideable
| CartKind::Furnace
| CartKind::Tnt
| CartKind::CommandBlock
| CartKind::Spawner => 6.0,
}
}
/// `AbstractMinecart.getDisplayOffset`: the saved `DisplayOffset`, else the default.
fn display_offset(c: &Minecart) -> f64 {
c.custom_offset
.map_or_else(|| default_display_offset(c.kind), f64::from)
}
/// World-space envelope of the cart model and its optional displayed block,
/// based on the same root position and display offset used by the renderer.
fn cart_render_bounds(position: Vec3, offset: f64, has_display: bool) -> Aabb {
let (bottom, top) = if has_display {
let display_shift = 0.75 * (offset - 8.0) / 16.0;
(
(position.y - 0.5).min(position.y + 0.375 - 0.4 + display_shift),
(position.y + 0.9).max(position.y + 0.375 + 0.4 + display_shift + 0.9),
)
} else {
(position.y - 0.5, position.y + 0.9)
};
Aabb::new(
Vec3::new(position.x - 1.0, bottom, position.z - 1.0),
Vec3::new(position.x + 1.0, top, position.z + 1.0),
)
}
/// A display state that is air shows nothing.
fn shown_unless_air(
state: Option<BlockStateId>,
is_air: impl Fn(BlockStateId) -> bool,
) -> Option<BlockStateId> {
state.filter(|&s| !is_air(s))
}
/// `readAdditionalSaveData`'s `input.read("DisplayState", CODEC)` is empty
/// when the state does not decode: the cart forgets it (and no longer saves it).
pub(super) fn forget_unreadable_display(
c: &mut Minecart,
resolves: impl Fn(&horizon_world::nbt::Tag) -> bool,
) {
if c.custom_display.as_ref().is_some_and(|t| !resolves(t)) {
c.custom_display = None;
}
}
mod display;
use display::{cart_render_bounds, display_offset, shown_unless_air};
pub(super) use display::{default_display_offset, forget_unreadable_display};
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
/// every mob of the level. Both answer in slot order, as the scans did.
@@ -0,0 +1,57 @@
//! Where a cart draws its display block (`AbstractMinecart.getDisplayOffset`), and display
//! states that show nothing or don't decode.
use super::*;
/// `AbstractMinecart.getDefaultDisplayOffset` (6), in sixteenths of a block, as
/// `MinecartChest` (8) and `MinecartHopper` (1) override it.
pub(in super::super) fn default_display_offset(kind: CartKind) -> f64 {
match kind {
CartKind::Chest => 8.0,
CartKind::Hopper => 1.0,
CartKind::Rideable
| CartKind::Furnace
| CartKind::Tnt
| CartKind::CommandBlock
| CartKind::Spawner => 6.0,
}
}
/// `AbstractMinecart.getDisplayOffset`: the saved `DisplayOffset`, else the default.
pub(super) fn display_offset(c: &Minecart) -> f64 {
c.custom_offset
.map_or_else(|| default_display_offset(c.kind), f64::from)
}
/// World-space envelope of the cart model and its optional displayed block,
/// based on the same root position and display offset used by the renderer.
pub(super) fn cart_render_bounds(position: Vec3, offset: f64, has_display: bool) -> Aabb {
let (bottom, top) = if has_display {
let display_shift = 0.75 * (offset - 8.0) / 16.0;
(
(position.y - 0.5).min(position.y + 0.375 - 0.4 + display_shift),
(position.y + 0.9).max(position.y + 0.375 + 0.4 + display_shift + 0.9),
)
} else {
(position.y - 0.5, position.y + 0.9)
};
Aabb::new(
Vec3::new(position.x - 1.0, bottom, position.z - 1.0),
Vec3::new(position.x + 1.0, top, position.z + 1.0),
)
}
/// A display state that is air shows nothing.
pub(super) fn shown_unless_air(
state: Option<BlockStateId>,
is_air: impl Fn(BlockStateId) -> bool,
) -> Option<BlockStateId> {
state.filter(|&s| !is_air(s))
}
/// `readAdditionalSaveData`'s `input.read("DisplayState", CODEC)` is empty
/// when the state does not decode: the cart forgets it (and no longer saves it).
pub(in super::super) fn forget_unreadable_display(
c: &mut Minecart,
resolves: impl Fn(&horizon_world::nbt::Tag) -> bool,
) {
if c.custom_display.as_ref().is_some_and(|t| !resolves(t)) {
c.custom_display = None;
}
}
+90 -8
View File
@@ -94,13 +94,36 @@ pub(super) fn register_layer_definitions() {
pub(super) fn content_root(
root: &std::path::Path,
enabled: &std::collections::BTreeSet<String>,
disabled: &std::collections::BTreeSet<String>,
dev: &[std::path::PathBuf],
) -> std::path::PathBuf {
let here = std::env::current_dir().unwrap_or_default();
let version = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|p| p.to_owned()));
content_root_at(root, enabled, disabled, dev, &here, version.as_deref())
}
fn content_root_at(
root: &std::path::Path,
enabled: &std::collections::BTreeSet<String>,
disabled: &std::collections::BTreeSet<String>,
dev: &[std::path::PathBuf],
here: &std::path::Path,
version: Option<&std::path::Path>,
) -> std::path::PathBuf {
use horizon_client::mod_content::{self, ContentMod};
use horizon_mods::{Capability, Package};
let _step = horizon_client::spans::startup::step("mods.content_root");
let here = std::env::current_dir().unwrap_or_default();
let mut paths = horizon_mods::find(&here.join(mod_list::DIR));
let native = version
.map(|p| horizon_mods::find(&p.join("native-content")))
.unwrap_or_default();
let native_ids: std::collections::BTreeSet<_> = native
.iter()
.filter_map(|path| Package::read(path).ok().map(|p| p.manifest.id))
.collect();
let mut paths = native.clone();
paths.extend(horizon_mods::find(&here.join(mod_list::DIR)));
paths.retain(|p| !dev.contains(p));
paths.extend(dev.iter().cloned());
let mut known = None;
@@ -113,8 +136,17 @@ pub(super) fn content_root(
.filter_map(|path| Some((path, Package::read(path).ok()?)))
.filter(|(path, package)| {
let m = &package.manifest;
(enabled.contains(&m.id) || dev.contains(path))
&& m.capabilities.contains(&Capability::Registries)
// A legacy content copy kept for an older build cannot replace this build's
// snapshot, including when the native mod is off.
if !native.contains(path) && native_ids.contains(&m.id) && !dev.contains(path) {
return false;
}
let on = if native.contains(path) {
!disabled.contains(&m.id)
} else {
enabled.contains(&m.id)
} || dev.contains(path);
on && m.capabilities.contains(&Capability::Registries)
})
.collect();
let read = read_contents(&packages);
@@ -843,6 +875,50 @@ mod tests {
use super::*;
use std::io::Write;
#[test]
#[ignore = "prepared resources: run explicitly for native content"]
fn built_native_sound_content_reaches_the_resource_root_and_obeys_disable() {
let base = std::path::PathBuf::from(
std::env::var_os("HORIZON_TEST_RESOURCES").expect("prepared resources required"),
);
let scratch =
std::env::temp_dir().join(format!("horizon-native-content-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&scratch);
let version = scratch.join("version");
let home = scratch.join("game");
std::fs::create_dir_all(&home).unwrap();
let content = version.join("native-content/soundproof");
std::fs::create_dir_all(content.join("assets/soundproof/sounds")).unwrap();
std::fs::write(content.join("horizon-mod.toml"), "id=\"soundproof\"\nname=\"Sound proof\"\nversion=\"1\"\napi=\"0.2\"\ncapabilities=[\"registries\"]\n").unwrap();
std::fs::write(
content.join("assets/soundproof/sounds.json"),
r#"{"proof":{"sounds":["soundproof:proof"]}}"#,
)
.unwrap();
std::fs::write(
content.join("assets/soundproof/sounds/proof.ogg"),
b"synthetic existence marker; no audio decode",
)
.unwrap();
let legacy = home.join(mod_list::DIR).join("soundproof");
std::fs::create_dir_all(&legacy).unwrap();
std::fs::copy(
content.join("horizon-mod.toml"),
legacy.join("horizon-mod.toml"),
)
.unwrap();
let enabled = ["soundproof".to_owned()].into();
let empty = Default::default();
let root = content_root_at(&base, &empty, &empty, &[], &home, Some(&version));
assert_ne!(root, base);
let sounds = horizon_client::sound_events::SoundRegistry::load(&root);
assert!(!sounds.tracks("soundproof:proof").is_empty());
let disabled = ["soundproof".to_owned()].into();
let off = content_root_at(&base, &enabled, &disabled, &[], &home, Some(&version));
assert_eq!(off, base, "disabled native mods contribute no assets");
std::fs::remove_dir_all(scratch).unwrap();
}
/// A content mod's living type is a mob of its template's species under
/// its own id, with its own attributes: it saves its id with every field
/// its template's class saves, loads back as itself, and rolls its own
@@ -969,10 +1045,16 @@ mod tests {
assert_eq!(verdict(&ruby, &manifest, true), switches_on_restart);
assert_eq!(verdict(&ruby, &manifest, false), Some(Ok(())));
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(5);
std::fs::File::open(&ruby)
.unwrap()
.set_modified(later)
.unwrap();
let mut folder = std::fs::OpenOptions::new();
folder.read(true);
// Windows opens a folder only with FILE_FLAG_BACKUP_SEMANTICS, and sets its time only
// with write access.
#[cfg(windows)]
{
use std::os::windows::fs::OpenOptionsExt;
folder.write(true).custom_flags(0x0200_0000);
}
folder.open(&ruby).unwrap().set_modified(later).unwrap();
assert_eq!(verdict(&ruby, &manifest, false), switches_on_restart);
std::fs::remove_dir_all(&dir).unwrap();
}
@@ -104,7 +104,7 @@ fn placements(
NativeStructureBlock {
pos: std::array::from_fn(|i| relative[i] + at[i]),
state: transforms.rotate(transforms.mirror(block.state, mirror), rotation),
nbt: block.nbt.clone(),
nbt: block.nbt.as_deref().cloned(),
}
})
.collect();
+26 -12
View File
@@ -3,6 +3,7 @@
use super::{Game, Screen};
use horizon_client::{
Result,
hdr_calibration::WindowState,
hud::HudBatch,
lang,
options_ui::{Event, Page},
@@ -188,7 +189,7 @@ impl Game<'_> {
.map_or(Default::default(), |r| *r.output());
ui.draw(&self.settings, output, batch, &self.gui.atlas, self.cursor);
self.ui.options = ui;
self.show_calibration(batch.height);
self.show_calibration((batch.width, batch.height));
}
/// Whose controller settings these are and what this game sees (`controller_ui`): the
/// player's badge in splitscreen with its name, and its own controller.
@@ -206,10 +207,10 @@ impl Game<'_> {
}
}
/// HDR calibration's test picture to the renderer, full screen while it runs: the
/// window goes fullscreen for it and back after, if it wasn't already.
pub(super) fn show_calibration(&mut self, h: f32) {
/// window goes fullscreen for it and back after, as it was (maximized, say).
pub(super) fn show_calibration(&mut self, size: (f32, f32)) {
let picture = (self.screen == Screen::Options)
.then(|| self.ui.options.calibration_picture(h))
.then(|| self.ui.options.calibration_picture(&self.gui.atlas, size))
.flatten();
if let Some(r) = self.renderer.as_mut() {
r.set_hdr_calibration(picture.map(|(step, nits, band)| {
@@ -223,20 +224,25 @@ impl Game<'_> {
let Some(window) = &self.window else {
return;
};
match (picture.is_some(), self.ui.options.calibration_fullscreen) {
match (picture.is_some(), self.ui.options.calibration_window) {
(true, None) => {
let was = window.fullscreen().is_some();
if !was {
// Before entering fullscreen, which un-maximizes a window on Windows.
let was = WindowState::of(window.fullscreen().is_some(), window.is_maximized());
if was != WindowState::Fullscreen {
let borderless = horizon_client::window::borderless_fullscreen(window);
horizon_client::window::enter_fullscreen(window, borderless);
}
self.ui.options.calibration_fullscreen = Some(was);
self.ui.options.calibration_window = Some(was);
}
(false, Some(was)) => {
if !was {
let (leave_fullscreen, maximize) = was.leaving();
if leave_fullscreen {
window.set_fullscreen(None);
}
self.ui.options.calibration_fullscreen = None;
if maximize {
window.set_maximized(true);
}
self.ui.options.calibration_window = None;
}
_ => {}
}
@@ -397,7 +403,7 @@ impl Game<'_> {
}
self.dragging = false;
// Closed from calibration (horizon-ctl, a lost window): out of it.
self.show_calibration(0.0);
self.show_calibration((0.0, 0.0));
}
Event::Language(code) => {
self.load_language(&code);
@@ -551,6 +557,14 @@ impl Game<'_> {
self.reload_packs();
}
self.apply_fullscreen();
// A failed HDR start that named no driver becomes the window's driver's, and another
// driver's is dropped, so HDR output is tried again.
if let Some(driver) = self.renderer.as_ref().and_then(|r| r.hdr_driver())
&& self.settings.horizon.settle_failed_start(driver)
&& let Some(dir) = Self::options_path().parent()
{
self.settings.horizon.save(dir);
}
let s = &self.settings;
if let Some(r) = self.renderer.as_mut() {
r.gamma = s.gamma;
@@ -628,7 +642,7 @@ impl Game<'_> {
pub(super) fn observe_fullscreen(&mut self) {
// HDR calibration's own fullscreen is not the player's option: it
// goes back to what it was when the calibration ends.
if self.ui.options.calibration_fullscreen.is_some() {
if self.ui.options.calibration_window.is_some() {
return;
}
let Some(w) = &self.window else { return };
+6 -4
View File
@@ -306,10 +306,11 @@ impl Game<'_> {
if self.ui.vanilla_root.as_os_str().is_empty() {
self.ui.vanilla_root = vanilla.clone();
}
let (selected, server, mods, dev_mods) = (
let (selected, server, mods, disabled, dev_mods) = (
self.settings.resource_packs.clone(),
self.server_packs.active.clone(),
self.applied_mods(),
self.settings.mods.disabled.clone(),
self.options.dev_mods.clone(),
);
let (assets, res, context) = (
@@ -320,8 +321,8 @@ impl Game<'_> {
let (unicode, japanese) = (self.settings.force_unicode, self.settings.japanese_glyphs);
self.pack_reload = Some(PackReload::spawn(kind, move |progress| {
stage_pack(
&vanilla, &selected, &server, &mods, &dev_mods, &assets, &res, &context, unicode,
japanese, progress,
&vanilla, &selected, &server, &mods, &disabled, &dev_mods, &assets, &res, &context,
unicode, japanese, progress,
)
.map_err(|e| e.to_string())
})?);
@@ -522,6 +523,7 @@ pub(super) fn stage_pack(
selected: &[String],
server: &[Pack],
mods_enabled: &BTreeSet<String>,
mods_disabled: &BTreeSet<String>,
dev_mods: &[PathBuf],
assets: &Arc<Assets>,
res: &Resources,
@@ -530,7 +532,7 @@ pub(super) fn stage_pack(
japanese: bool,
progress: &super::Progress,
) -> horizon_client::Result<StagedPack> {
let base = super::mods::content_root(vanilla_root, mods_enabled, dev_mods);
let base = super::mods::content_root(vanilla_root, mods_enabled, mods_disabled, dev_mods);
let root = startup_root(vanilla_root, &base, selected, server)?;
progress.set(1);
// The world's models and sprites and the GUI atlas read the same root and nothing of
@@ -13,6 +13,7 @@ use std::time::{Duration, Instant};
mod face_buttons;
mod hid_layout;
mod sdl_row;
mod trigger_effects;
mod triggers;
/// The controllers gilrs sees and the DualSense's output.
@@ -576,7 +577,7 @@ impl Game<'_> {
};
let lights = self.settings.controller.lightbar != gamepad::Lightbar::Off;
let state = horizon_dualsense::device::State {
triggers: self.settings.controller.trigger_effects(using, attacking),
triggers: trigger_effects::effects(&self.settings.controller, using, attacking),
lightbar: lightbar.filter(|_| lights),
player_leds: lights.then_some(horizon_dualsense::PLAYER_1),
};
@@ -0,0 +1,109 @@
//! What a DualSense's adaptive triggers do for a [`TriggerFeel`] (docs/controllers.md "Adaptive
//! triggers" for the choices). Only the pad's official modes: Nielk1's notes warn the others may
//! leave the firmware.
use horizon_client::gamepad::{Button, PadSettings, TriggerFeel};
use horizon_dualsense::{TRIGGER_OFF, TriggerEffect, trigger};
/// The effect, for a trigger that presses its action at `threshold` (0..1) of its travel.
pub(super) fn effect(feel: TriggerFeel, threshold: f32) -> TriggerEffect {
// The zone (of ten along the travel) where the action presses: a catch gives way there,
// so the click and the shot coincide.
let press = ((threshold.clamp(0.0, 1.0) * 10.0) as u8).clamp(3, 8);
match feel {
TriggerFeel::Free => TRIGGER_OFF,
// From the third zone, so the press itself stays light; at most 6 of 8, as the
// draw is held.
TriggerFeel::Draw(charge) => {
trigger::feedback(2, 1 + (5.0 * charge.clamp(0.0, 1.0)).round() as u8)
}
TriggerFeel::Click => trigger::weapon((press - 2).max(2), press, 8),
TriggerFeel::Boost => trigger::weapon((press - 2).max(2), press, 5),
TriggerFeel::Brace => trigger::feedback(2, 3),
TriggerFeel::Locked => trigger::feedback(1, 8),
TriggerFeel::Texture(hardness) if hardness > 0.0 => {
// Dirt 2, stone 3, deepslate 4, obsidian 5 of 8.
let amplitude = (1.0 + 1.6 * hardness.sqrt()).round().clamp(1.0, 5.0);
// Sand and dirt a fine 40 Hz grit; stone 30; wood, deepslate and ores 20;
// obsidian a 10 Hz chisel.
let hertz = match hardness {
h if h < 1.0 => 40,
h if h < 2.0 => 30,
h if h < 5.0 => 20,
_ => 10,
};
trigger::vibration(1, amplitude as u8, hertz)
}
TriggerFeel::Texture(_) => TRIGGER_OFF,
}
}
/// The left and right triggers' effects: each feels the action bound to it, `key.use` or
/// `key.attack`.
pub(super) fn effects(
settings: &PadSettings,
using: TriggerFeel,
attacking: TriggerFeel,
) -> [TriggerEffect; 2] {
[Button::LeftTrigger, Button::RightTrigger].map(|button| {
let actions = settings.actions_of(button);
let feel = if !settings.adaptive_triggers {
TriggerFeel::Free
} else if actions.contains(&"key.use") {
using
} else if actions.contains(&"key.attack") {
attacking
} else {
TriggerFeel::Free
};
effect(feel, settings.trigger_threshold)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn triggers_feel_their_action() {
let mut s = PadSettings::default();
// Use is LT, attack RT by default.
let [left, right] = effects(&s, TriggerFeel::Draw(1.0), TriggerFeel::Texture(1.5));
assert_eq!(left, trigger::feedback(2, 6));
assert_eq!(right, trigger::vibration(1, 3, 30));
assert_eq!(
effects(&s, TriggerFeel::Draw(0.0), TriggerFeel::Free),
[trigger::feedback(2, 1), TRIGGER_OFF]
);
// The catch gives way in the zone where the default threshold (0.4) presses: weapon
// 0x25, zones 2 and 4, strength 8 (stored 7) or 5 (stored 4).
let catch = |strength| [0x25, 0x14, 0x00, strength, 0, 0, 0, 0, 0, 0, 0];
assert_eq!(effect(TriggerFeel::Click, 0.4), catch(7));
assert_eq!(effect(TriggerFeel::Boost, 0.4), catch(4));
// A deeper threshold moves it: zones 5 and 7.
assert_eq!(effect(TriggerFeel::Click, 0.7)[1..3], [0xa0, 0x00]);
assert_eq!(effect(TriggerFeel::Click, 0.1), trigger::weapon(2, 3, 8));
assert_eq!(effect(TriggerFeel::Brace, 0.4), trigger::feedback(2, 3));
assert_eq!(effect(TriggerFeel::Locked, 0.4), trigger::feedback(1, 8));
// Harder blocks buzz harder and slower; instant-break blocks don't.
assert_eq!(
effect(TriggerFeel::Texture(0.5), 0.4),
trigger::vibration(1, 2, 40)
);
assert_eq!(
effect(TriggerFeel::Texture(3.0), 0.4),
trigger::vibration(1, 4, 20)
);
assert_eq!(
effect(TriggerFeel::Texture(50.0), 0.4),
trigger::vibration(1, 5, 10)
);
assert_eq!(effect(TriggerFeel::Texture(0.0), 0.4), TRIGGER_OFF);
assert_eq!(effect(TriggerFeel::Texture(-1.0), 0.4), TRIGGER_OFF);
s.adaptive_triggers = false;
assert_eq!(
effects(&s, TriggerFeel::Click, TriggerFeel::Boost),
[TRIGGER_OFF; 2]
);
assert!(!PadSettings::parse(&s.serialize()).adaptive_triggers);
}
}
+20 -2
View File
@@ -13,7 +13,9 @@ use super::{
Game,
entity::{Entity, EntityHandle, with_entity, with_entity_mut},
};
use horizon_client::collisions::{EntityCollisions, SceneCollisions, with_piston_noclip};
use horizon_client::collisions::{
EntityCollisions, SceneCollisions, no_collision_empty, with_piston_noclip,
};
use horizon_client::motion::MotionWorld;
use horizon_core::geometry::{Aabb, Axis, Direction, ShapeOp, Vec3, border::BorderGeometry};
use horizon_core::position::BlockPos;
@@ -834,7 +836,23 @@ impl Game<'_> {
}
}
Target::Entity(handle) => {
with_entity_mut!(self, handle, entity => entity.move_by_piston(movement))
with_entity_mut!(self, handle, entity => entity.move_by_piston(movement));
let Some(bounds) =
with_entity!(self, handle, entity => entity.piston_release_bounds())
else {
return;
};
let no_block_collision = {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
no_collision_empty(&mut scene, bounds)
};
with_entity_mut!(self, handle, entity => {
entity.release_after_piston(no_block_collision)
});
}
}
}
@@ -1,4 +1,5 @@
//! A key press while a world is open (`KeyboardHandler.keyPress` and the gameplay mappings).
use super::inventory_input::drop_whole_stack;
use super::*;
impl Game<'_> {
+40 -1
View File
@@ -2,7 +2,7 @@
//! game event when an armour or off-hand slot takes a different item, from
//! every path that calls it (use, the inventory menu, the creative screen,
//! the swap-hands key, dispensers), for the host and for LAN guests.
use super::Game;
use super::{Game, equipment_modifier_applies};
use horizon_client::items::ItemStack;
/// The equipment slots `onEquipItem` can act on, as `EquipmentSlot` names:
@@ -92,4 +92,43 @@ impl Game<'_> {
self.post_game_event(event, at, ctx);
}
}
/// Equipment attribute modifiers (LivingEntity.collectEquipmentChanges).
pub(super) fn apply_equipment(&mut self) {
let Some(player) = self.player.as_mut() else {
return;
};
for (attribute, id) in std::mem::take(&mut self.applied_modifiers) {
let _ = player.set_modifier_unclamped(&attribute, &id, 0.0, "add_value", false);
}
let slots = ["head", "chest", "legs", "feet"];
let mut equipped: Vec<(Option<&ItemStack>, &str)> = self
.armor
.iter()
.zip(slots)
.map(|(s, n)| (s.as_ref(), n))
.collect();
equipped.push((self.inventory[self.selected].as_ref(), "mainhand"));
equipped.push((self.offhand.as_ref(), "offhand"));
for (stack, slot) in equipped {
let Some(info) = stack.and_then(|s| world_items!(self).get(&s.item)) else {
continue;
};
for (attribute, id, amount, op, group) in &info.modifiers {
// The attack code still reads the held item's attack damage and
// speed directly; the map copy is what `/attribute` reports.
if !equipment_modifier_applies(group, slot) {
continue;
}
if player
.set_modifier_unclamped(attribute, id, *amount, op, true)
.is_ok()
{
self.applied_modifiers.push((attribute.clone(), id.clone()));
}
}
}
let _ = player.clamp_health_to_max();
self.apply_enchantment_attributes();
}
}
+19 -7
View File
@@ -353,6 +353,24 @@ fn panic_text(payload: &(dyn std::any::Any + Send)) -> &str {
.unwrap_or("Box<dyn Any>")
}
/// Resolve vanilla and native content before applying the selected resource packs.
pub(super) fn startup_resource_root(
vanilla_root: &std::path::Path,
settings: &Settings,
dev_mods: &[PathBuf],
) -> (PathBuf, Option<String>) {
let base = mods::content_root(
vanilla_root,
&settings.mods.enabled,
&settings.mods.disabled,
dev_mods,
);
match packs::startup_root(vanilla_root, &base, &settings.resource_packs, &[]) {
Ok(root) => (root, None),
Err(error) => (base, Some(error.to_string())),
}
}
/// Each generation has its own channel and progress. Pack changes wait for the
/// old tail before starting another generation, including cache/global writes.
pub(super) fn spawn_loader(
@@ -371,13 +389,7 @@ pub(super) fn spawn_loader(
let handle = std::thread::Builder::new()
.name("resource-loader".into())
.spawn(move || {
let mut pack_error = None;
let base = mods::content_root(&vanilla_root, &settings.mods.enabled, &dev_mods);
let root = packs::startup_root(&vanilla_root, &base, &settings.resource_packs, &[])
.unwrap_or_else(|error| {
pack_error = Some(error.to_string());
base.clone()
});
let (root, pack_error) = startup_resource_root(&vanilla_root, &settings, &dev_mods);
let result = load_resources(
root,
world.as_deref(),
Loaded 100 of 328 files, more files were not shown because too many files have changed in this diff. Show more