Compare commits

...
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 23293b86b4 Keep the mod screen over other screens out of the big files
The new mod-screen code moves from native_hooks.rs to native_mod_screen.rs
(helpers retargeted in its fragment), its state rides the existing
native_mod_screen field, and draw_screen lives in screens.rs, so game.rs,
native_hooks.rs and remote.rs no longer grow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:41:59 +01:00
Horizon agent 4fa9c5b082 Merge remote-tracking branch 'origin/agent/hooks-outside-native-hooks' into fix/12272 2026-11-09 18:31:09 +01:00
Horizon agent a790d406c7 Merge remote-tracking branch 'origin/main' into fix/12272
# Conflicts:
#	crates/horizon-client/src/game/pointer_capture.rs
2026-11-09 18:31:06 +01:00
Ludovic Marie cc606585ad Merge pull request #14046 from horizon-rust/layers/pages-extract
🤖 Agent: pure move, no behaviour change: native_rows, the native key wait (the two duplicated Act::NativeKey handlers now call one method) and the key binding go to options_ui_pages.rs, a child module that keeps access to the screen's private types; options_ui.rs 8441 -> 8391 lines.
2026-11-09 18:28:42 +01:00
Ludovic Marie 6d1235ca18 Merge pull request #13978 from DUzzL/fix/13936-rider-pickup
🤖 Agent: the riding pickup box is right (Player.aiStep: union with the vehicle's box, inflated 1/0/1). The partial-pickup half was not: Inventory.add returns true once any of the stack goes in, so playerTouch still calls take, awards ITEM_PICKED_UP with the stack's original count and calls onItemPickup. I restored that, using vanilla's count.
2026-11-09 18:23:59 +01:00
Ludovic Marie 8ef5c055b0 Merge pull request #12284 from Cheezer1656/upstream-gap/u-01-registry-name
🤖 Agent: the registry-name read goes through SyncedNames, the same builtin+dynamic tables the hub syncs to clients, so a mod sees the names a vanilla client gets; Hub::synced_name and interact_location moved out (file-size rule).
2026-11-09 18:21:15 +01:00
Ludovic Marie 6be9c07ce4 Merge pull request #13764 from TheMrZZ/fix/spawn-info-generator-sea-level-2
🤖 Agent: the sea level now follows the level's generator through levels.rs's locator swap, so flat worlds send -63 on login and respawn; lan.rs/lan_actions.rs kept from growing with two pure moves (ada4756).
2026-11-09 18:21:11 +01:00
Ludovic Marie d74c95f088 Merge pull request #13804 from Mik00000/perf/parallel-mob-ai
🤖 Agent: the section cache gives the same states as Chunk::block, including air for a missing chunk or section. With the contextual test now on the registry's flag (6ee08b4), the cached shape is used only where collision_shape_with_context would return it too, so each raycast voxel saves an Arc clone and a lookup without changing a hit.
2026-11-09 18:18:11 +01:00
Horizon agentandClaude Opus 5.5 1d12ab67f5 mod-api-gen: committed render hooks ignore rustfmt's trailing commas
xtask mod-api writes the renderer's hooks through rustfmt, which adds a
trailing comma wherever it wraps a list. The build's staleness check compares
tokens with the generator's one-line code, so any render hook with a signature
long enough to wrap (#12277's client_sound) failed the build as "out of date"
right after being regenerated. The check now drops trailing commas before ),
] and } on both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:16:58 +01:00
Ludovic Marie 9d5dbbd2f5 Merge pull request #13995 from horizon-rust/agent/registry-revoked-version-removal
🤖 Agent: a registry version revoked by sha256 may now keep its entry, so a lost signing key can be rotated (baritone #276); the PR's tests, clippy and fmt were clean.
2026-11-09 18:15:20 +01:00
Ludovic Marie 3a2bf1f4f8 Merge pull request #13758 from RINNECODER/up11/mesh-upload-backpressure
🤖 Agent: pending_uploads drains every frame independently of meshing (world_update's budget loop), so counting it against the mesh room is pure backpressure with no stall path; the room counts columns while the backlog counts sections, which only makes the hold stricter at wide distances.
2026-11-09 18:15:16 +01:00
Ludovic Marie d49eeccd3f Merge pull request #13746 from vnsmoreira/packs/polytone-item-bars
🤖 Agent: item_bar_color takes a read lock and scans ITEM_BARS per drawn damage bar; with no Polytone item modifiers that Vec is empty, so vanilla packs pay nothing measurable. The two stacked commits are already on main (#13703, #13704).
2026-11-09 18:15:09 +01:00
Ludovic Marie 7e13db2271 Merge pull request #14036 from horizon-rust/layers/settings-store
🤖 Agent: settings store only routes lines at load/save time: with no store registered Settings::load/save read and write exactly what they did (horizon-extras.txt unchanged, no new file); a store's own file keeps lines no store owns, the shared horizon-extras.txt does not (Extras rewrites it).
2026-11-09 18:11:55 +01:00
Ludovic Marie 389d15bda0 Merge pull request #14028 from horizon-rust/split/discord-standalone
🤖 Agent: discord_presence.rs now uses only horizon_command's GameMode and a max the caller passes (Play::Hosting { players, max }, still lan::MAX_PLAYERS), so the presence client can move to a Horizon crate as a plain file move.
2026-11-09 18:09:04 +01:00
Ludovic Marie 4ae696550f Merge pull request #13769 from MSedgeMC/fix/jukebox-song-component
🤖 Agent: insert path and the client's jukebox_takes now read the stack's jukebox_playable like the comparator and song tick (pushed 2fe3429), so a removed component is refused and any item given one is taken, as JukeboxBlock.useItemOn does.
2026-11-09 18:08:59 +01:00
Ludovic Marie e92dc8108d Merge pull request #13722 from RINNECODER/up10/stats-mined-items
🤖 Agent: rows and the Mined column match ItemStatisticsList (Block.asItem, BlockItem.getBlock) and the comparator's sortOrder × (value, Item.getId); I pushed 8396106b2e: the sort looked each row's registry id up with a linear scan of ~1500 items, several times a frame while the screen is open, now one HashMap per sort.
2026-11-09 18:08:53 +01:00
Ludovic Marie 1d6e973ab8 Merge pull request #13723 from MSedgeMC/fix/hotbar-carried
🤖 Agent: matches checkHotbarKeyPressed's getCarried().isEmpty() gate; merged main in, where a double-click test had landed at the same spot, both tests kept.
2026-11-09 18:03:57 +01:00
Ludovic Marie e2a17164ca Merge pull request #14017 from horizon-rust/fix/prepare-folder-with-libraries
🤖 Agent: prepare now picks a Minecraft folder that holds every library of the version, which is what the azure-json failure in #13872 was missing; the prepare tests pass.
2026-11-09 17:51:37 +01:00
Ludovic Marie 0d24192a23 Merge pull request #13945 from MSedgeMC/fix/container-first-release
🤖 Agent: checked against 26.3's bytecode: AbstractContainerScreen's constructor sets skipNextRelease = true, so the release of the click that opened the chest no longer picks up the slot under the pointer. I merged main (it conflicted with #13946's test) and kept both tests.
2026-11-09 17:51:32 +01:00
Ludovic Marie e5814ae449 Merge pull request #13967 from horizon-rust/split/cosmetics-gather
🤖 Agent: pure move: emotes.rs and emote_wheel.rs keep their bodies under game/cosmetics/, their state joins Cosmetics, and game.rs loses its two emote fields (9 lines shorter)
2026-11-09 17:48:57 +01:00
Ludovic Marie c80f5ae218 Merge pull request #13864 from Skr1zzzz/split-block-particles
🤖 Agent: pure move of crack_particle and particle_sprite into the existing game/block_particles.rs, bodies identical. I merged main in (daf537e): main's game.rs with only these two fns removed, after #13856/#13877 moved their neighbours.
2026-11-09 17:44:43 +01:00
Horizon agentandClaude Opus 5.5 98c8498c8b Native mods: a hook point may live in any module under game/
native_hooks.rs is over the 3000-line limit and may not grow, so every new
hook point was refused. The generator now accepts a hook whose target names
another game/ module, as helpers already do, provided the function there makes
its dispatch_<hook> call itself (xtask mod-api only fills native_hooks.rs) and
holds no macro or cfg, which horizon-mixins cannot patch.
catalog_matches_hook_points checks those hooks against the file their target
names.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:38:48 +01:00
Ludovic Marie e5dfe2162a Merge pull request #13890 from Mik00000/feat/mod-async-worker
🤖 Agent: this answers #13740's review: the queue is per Game (window and server thread apart), jobs run on their own horizon-mod-worker pool, and panics are contained with the mod named. The fragment stamps cleanly (tests.test_stamp_native_hooks OK), and the test-module reorder in game.rs is what keeps that file from growing.
2026-11-09 17:38:44 +01:00
Ludovic Marie 92dfd34610 Merge pull request #13797 from MSedgeMC/fix/tempt-invisible-range
🤖 Agent: matches TargetingConditions.test: range * getVisibilityPercent floored at 2.0, with the targeter's type passed so mob heads count as they do for the other users of visibility_percent. Moving Tempt and the tests out of goals.rs is a pure move; that file is over 3000 lines and may not grow.
2026-11-09 17:37:28 +01:00
Ludovic Marie dea884f9c2 Merge pull request #13809 from vnsmoreira/packs/polytone-colormap-perf
🤖 Agent: reads_only counts any Root or Host expression as reading everything, so a colormap is only shared up a column when its axes can't see the height. Measured 2.16 ms down to 178 µs per section, with the same colour checksum. It carries #13806's commits; land it after that one.
2026-11-09 17:37:23 +01:00
Ludovic Marie 2d3ee9d7ef Merge pull request #13806 from vnsmoreira/packs/polytone-biome-colors
🤖 Agent: everything new runs once, when the mesh tables are built (biome_functions, Colormap::flat), and with no pack setting biome_colors the tables come back unchanged. horizon_height defaults to vanilla's 63, so without a pack the sky disc doesn't move.
2026-11-09 17:37:19 +01:00
Ludovic Marie 2b80362795 Merge pull request #13799 from NikitaMGrimm/parity/piston-falling-block
🤖 Agent: matches FallingBlockEntity.tick: the (0.7, -0.5, 0.7) landing slowdown happens before the MOVING_PISTON test, and the 0.98 drag at the end of the tick. Neither placement nor cancelDrop runs there, so the block stays falling for the piston to push. The new command-exec group is now in CI's parity run.
2026-11-09 17:37:14 +01:00
Ludovic Marie 9c10e5bfe1 Merge pull request #12175 from Cheezer1656/upstream-gap/u-18-own-inventory
🤖 Agent: own_inventory() reads the player's Inventory.items 0-35 straight from the synced inventory, so it answers the same with a chest up or the window's own inventory, where menu_stacks() is None; the client test checks hotbar slot 0 with a menu open. main merged in (docs conflict beside slot_roles).
2026-11-09 17:37:10 +01:00
Ludovic Marie b524e47aa8 Merge pull request #13865 from MSedgeMC/fix/pack-reload-overlay-hold
🤖 Agent: the 1000 ms hold matches LoadingOverlay.isReadyToFadeOut's fadeIn gate, counted from spawn rather than from the first drawn frame (a frame's difference). The new test slept 50 x 1 ms against an 80 ms hold, which a loaded runner can overrun, so I pushed 308bf3d: it holds for an hour, waits for the worker, then lifts the hold.
2026-11-09 17:37:06 +01:00
Ludovic Marie 9a8927755c Merge pull request #13882 from NikitaMGrimm/ci/ratchet-checks-merge-parent
🤖 Agent: verify checks out with fetch-depth 0 and runs on refs/pull/N/merge, so HEAD^1 is the main it lands on; pull_request.base.sha goes stale after a push, which is the false size failure in #13874.
2026-11-09 17:37:01 +01:00
Ludovic Marie f6d492dbb4 Merge pull request #13895 from k3yh/mod-fluid-entities
🤖 Agent: vanilla's paths don't change with no mod fluid loaded (holds() falls back to the caller's tags, add_mod_tags adds nothing). add_mod_tags does run on every entity's fluid update, but it's a 5-row scan then.
2026-11-09 17:36:56 +01:00
Ludovic Marie 515a26c926 Merge pull request #13905 from horizon-rust/split/launcher-server-platform
🤖 Agent: the launcher and horizon-server now reach the engine only through horizon-platform (feature-gated re-exports, so horizon-telemetry still builds without horizon-world); import paths only, no behaviour change; 7 layers.json exceptions removed.
2026-11-09 17:33:01 +01:00
Ludovic Marie 64d6abf771 Merge pull request #13743 from vnsmoreira/packs/polytone-lead-texture
🤖 Agent: no change without a pack, since vanilla 26.3 ships no textures/entity/lead.png and the walked entity/ folder only picks one up from a pack; the per-row UV split keeps both faces' rows consistent (near and far quads swap top and bottom).
2026-11-09 17:31:41 +01:00
Ludovic Marie 950b8ed68b Merge pull request #13904 from NikitaMGrimm/tooling/file-size-skip-test-files
🤖 Agent: the TEST_FILE regex anchors on a path separator, so contests.rs and tests_util.rs still count, and the new test pins both sides.
2026-11-09 17:31:37 +01:00
Ludovic Marie c703484705 Merge pull request #13735 from RINNECODER/up10/chat-filter-mask
🤖 Agent: matches ChatListener.showMessageToPlayer and FilterMask.applyWithFormatting, including narrating the filtered text; one difference left on purpose: vanilla's nextClearBit can run past the text's end and print extra #s for a mask longer than the message, Horizon stops at the text, which no real server sends.
2026-11-09 17:31:32 +01:00
Ludovic Marie 4d300f6aed Merge pull request #13894 from NikitaMGrimm/fix/stations-test-item-table
🤖 Agent: Items::tags_only gave the sword max damage 0, so the anvil test checked nothing past that point. With the full table its trim and enchanting parts run for the first time, on prepared data only (CI has none).
2026-11-09 17:31:27 +01:00
Ludovic Marie 93cee47a13 Merge pull request #13908 from Skr1zzzz/split-buckets
🤖 Agent: pure move into game/buckets.rs; leaving pick_fluid in game.rs for the open PRs that change it is the right call.
2026-11-09 17:30:19 +01:00
Ludovic Marie 627ac3c0ba Merge pull request #13725 from MSedgeMC/fix/screen-pauses
🤖 Agent: checked against 26.3's isPauseScreen overrides: SocialInteractionsScreen is false and the game-master block editors inherit Screen's true, so the table now matches; one match over every variant also makes a new Screen variant a compile error instead of a silent 'keeps ticking'.
2026-11-09 17:30:14 +01:00
Ludovic Marie d178d86b4d Merge pull request #13907 from Skr1zzzz/split-saving
🤖 Agent: pure move (only save_level and tick_world_icon gain pub(super)); the 4 port-map rows follow the code, and it stacks cleanly with #13887 and #13908 on main.
2026-11-09 17:30:09 +01:00
Ludovic Marie c8c0157d79 Merge pull request #13887 from Skr1zzzz/split-block-ticks
🤖 Agent: checked with --color-moved: the only lines that aren't moved are the pub(super) prefixes on sync_tick_chunks and tick_moving_pistons; it merges cleanly with main and with #13907/#13908.
2026-11-09 17:30:05 +01:00
Ludovic Marie c6c60fc163 Merge pull request #13886 from senjinthedragon/shadow-water-fallback
🤖 Agent: the plain shadow_water program is now translated on every pack load, even when the GPU takes the patched one; that's load time only, and the full-outputs GPU test shows the fallback only kicks in at the device's output limit.
2026-11-09 17:30:00 +01:00
Ludovic Marie 20c4ed3274 Merge pull request #13215 from pehe1/fix/11338-blocking-stack-durability
🤖 Agent: a blocking shield now loses durability from the stack it actually holds after a save round trip (item_nbt), and the dispenser harness fixture checks it; the merger waits for CI parity on this fork head.
2026-11-09 17:29:55 +01:00
Ludovic Marie 1610735fcd Merge pull request #13955 from horizon-rust/split/cosmetics-prep
🤖 Agent: anim::setup keeps its None fast path; an emoting player costs one dyn BodyPose call per pose, and set_playing only reallocates when the emote changes, not every tick
2026-11-09 17:29:50 +01:00
Ludovic Marie 139a42ba8b Merge pull request #13798 from NikitaMGrimm/hdr-auto-off-grace
🤖 Agent: GitHub's +1605 is stale. With #13623 merged, the real diff against main is gfx.rs and output_apply.rs, +433/-26. The grace costs one Option check per update_output and a single extra display read at the 500 ms deadline, so a frame with HDR off pays nothing. It also merges cleanly together with #13810.
2026-11-09 17:29:46 +01:00
Ludovic Marie 9477cb6d1e Merge pull request #13965 from MrCloudy2/fix/piston-projectile-bubble
🤖 Agent: reusing #12913's swept bubble-column pass for the piston move is the right shape: moveEntityByPiston ends in applyEffectsFromBlocks, so the push gets onInside/onAboveBubbleColumn as the projectile's own move does. I swapped drain(..).collect() for mem::take to clear clippy; the new harness runs in CI parity.
2026-11-09 17:29:41 +01:00
Ludovic Marie 60f42e7cdf Merge pull request #13979 from horizon-rust/fix/13108-dll-search
🤖 Agent: /DEPENDENTLOADFLAG:0x800 makes Windows load the launcher's import DLLs from System32 only, and tools/test_launcher_dll_search.py checks the flag in the built PE so a build change can't drop it.
2026-11-09 17:29:36 +01:00
Horizon agentandClaude Opus 5.5 1acc70eb43 Options pages: module order as rustfmt keeps it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:26:29 +01:00
Horizon agentandClaude Opus 5.5 6ee08b4a5a Raycasts: take a state's context rule from its collision flag
The name test (scaffolding, powder_snow) missed the liquid rule, so with
an entity's context a lava block took its cached empty shape, where
collision_shape_with_context gives a strider the lava's stable shape.
Read the registry's contextual flag instead, as collision_shape_with_context
does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:15:50 +01:00
Horizon agentandClaude Opus 5.5 50a11a1107 Options: native mod pages in their own module
A pure move out of options_ui.rs (8441 lines, may not grow): the native
mod's page rows, its key wait and its key binding go to
options_ui_pages.rs, a child module, so the page registry can land there
(docs/layers.md, Platform registries).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:11:10 +01:00
Ludovic Marie 7eb057384c Merge pull request #13788 from RINNECODER/up11/ui-pack-glyphs
🤖 Agent: layout now collects a Vec per label even for measure, one small allocation per text per frame; a two-pass over unformatted() would avoid it if a menu ever shows up in a profile.
2026-11-09 17:09:40 +01:00
Ludovic Marie 9976e2053c Merge pull request #13869 from MSedgeMC/fix/inventory-creative-swap
🤖 Agent: the switch to the creative screen was in set_game_mode, which runs on the server thread; the window changes mode through the game event (set_mode_id), so it never saw it. I moved the call into set_mode (pushed 470f9d0), which both paths reach. The way back (CreativeModeInventoryScreen.containerTick) is still a gap. Also, the PR body is the literal text @/tmp/hudbodies/b-inv2.md: the body file never got read.
2026-11-09 17:09:36 +01:00
Ludovic Marie 1875af1a00 Merge pull request #13786 from RINNECODER/up11/sulfur-cube-fields
🤖 Agent: checked SulfurCube's <clinit> in the 26.3 jar: MAX_FUSE (INT) is defined before FROM_BUCKET (BOOLEAN), so 19/20 and the -1 default are right.
2026-11-09 17:09:32 +01:00
Ludovic Marie b9fc2c5539 Merge pull request #13779 from vnsmoreira/packs/polytone-creative-tabs
🤖 Agent: a name given as {translate:...} is shown as the raw key (component_text keeps the key, creative_tab_title never translates it); small follow-up. tag_match scans exported_search per item, fine since it runs only at rebuild.
2026-11-09 17:09:28 +01:00
Ludovic Marie cee8d447f8 Merge pull request #13777 from MSedgeMC/fix/pufferfish-spectator
🤖 Agent: nothing found; the spectator gate sits before the box test, as forNonCombat's canBeSeenByAnyone does.
2026-11-09 17:09:23 +01:00
Ludovic Marie 04880cebe6 Merge pull request #13776 from NikitaMGrimm/fix/timeout-seconds-every-run
🤖 Agent: only test_friends.py (600 s) and test_native_client.py (60 s, frame-limited) pass --timeout-seconds today, so no other harness changes behaviour.
2026-11-09 17:09:18 +01:00
Ludovic Marie c3f0df9033 Merge pull request #13774 from MSedgeMC/fix/enderman-spectator-stare
🤖 Agent: nothing found; can_continue gets the same spectator gate, which forCombat's canBeSeenByAnyone also applies on every re-test.
2026-11-09 17:09:15 +01:00
Ludovic Marie ad1ec8e4f6 Merge pull request #13772 from Mik00000/perf/structure-locate-vanilla-order
🤖 Agent: checked against ChunkGenerator.findNearestMapStructure's bytecode: ring loop outside, placements inside, return on the first ring with a hit; the concentric result already in best plays vanilla's starting nearestDistance.
2026-11-09 17:09:10 +01:00
Ludovic Marie ff66433c34 Merge pull request #13770 from MSedgeMC/fix/brewing-speed-multiplier
🤖 Agent: nothing found; the shared nbt_value::float matches getFloatOr's NumericTag.asFloat for every numeric tag.
2026-11-09 17:09:06 +01:00
Ludovic Marie 61f922fbd5 Merge pull request #13765 from DUzzL/fix/13669-cloud-custom-particle
🤖 Agent: custom_particle_options decodes more of ParticleTypes.CODEC than /particle's own parser in commands/world_cmds.rs; that command could reuse it later. Block/item/vibration types still fall back to the potion swirl, as the body says.
2026-11-09 17:09:00 +01:00
Ludovic Marie 211512aee8 Merge pull request #13763 from TheMrZZ/fix/h282-hopper-pot-insertion-2
🤖 Agent: the pot adapter only ever sits behind hopper_insertion_view, so generic discovery (comparators, pulls) stays untouched; a pot still has no modeled block entity, so hopper extraction from a pot remains a gap for a follow-up. Overlaps #13800 only in is_full/move_in_item signatures.
2026-11-09 17:08:55 +01:00
Ludovic Marie edc5f94957 Merge pull request #13791 from MSedgeMC/port_map/directional-sound
🤖 Agent: confirmed tick_end_flash builds SoundInstance::directional and directional_position adds the 10-block view offset, so the row now cites the real port instead of its caller's caller.
2026-11-09 17:08:50 +01:00
Ludovic Marie 761704ce11 Merge pull request #13790 from MSedgeMC/port_map/block-sync-snap
🤖 Agent: confirmed end_predictions calls block_meets_player then snap_target/snap_player_to (remote.rs:2967-2969, unit-tested at 12726), so the old gap note was wrong.
2026-11-09 17:08:45 +01:00
Ludovic Marie 74d6a69a0e Merge pull request #13789 from MSedgeMC/port_map/sprite-animate
🤖 Agent: checked on main: animate.rs has only SpriteAnimator::animate_with now, so both rows pointed at a function that no longer exists; TextureManager.tick as inline matches its own note.
2026-11-09 17:08:41 +01:00
Horizon agentandClaude Opus 5.5 5edd79feaa Settings store: rustfmt, Cargo.lock
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:07:26 +01:00
Horizon agentandClaude Opus 5.5 c5121f0f10 Settings store: crates above the engine keep their own settings lines
horizon_hooks::settings (re-exported as horizon_platform::settings): a Store
names its file (horizon-extras.txt or its own), the keys it owns, and loads
and saves its lines. Settings::load and Settings::save route the lines; a
shared file gets the stores' lines after the engine's, an own file keeps the
lines no store owns. The hooks list and the registries share one Registry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:05:56 +01:00
Ludovic Marie c2414d0c5d Merge pull request #13982 from horizon-rust/land/11446
🤖 Agent: lands GabrielNichols' #11446 (fuel alternatives in the Recipes cooking card); its seven JEI sprites go into jei_sprites.rs and the fuel list's wheel shares the bundle's check, so hud.rs and game.rs don't grow.
2026-11-09 17:04:35 +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
Horizon agentandClaude Opus 5.5 18a2d812a7 game.rs doesn't grow: interact_location moves to hand.rs
Its only callers are hand.rs and the core tests (through game.rs's import).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:02:01 +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
Horizon agentandClaude Opus 5.5 510bb6f135 Hub::synced_name lives in lan/registry_names.rs
hub.rs is over 3000 lines and may not grow (tools/check_file_size.py); the
method moves unchanged to a file of its own beside it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:57:46 +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
Horizon agentandClaude Opus 5.5 daf537e0d4 Merge main into split-block-particles
game.rs: main moved the neighbouring item and hand helpers out (#13856,
#13877); kept main's file and removed only crack_particle and
particle_sprite, as this branch does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:54:33 +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
Horizon agentandClaude Opus 5.5 260051a190 Lockfiles after merging main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:53:29 +01:00
Horizon agent 66dafd2bb6 Merge remote-tracking branch 'origin/main' into split/launcher-server-platform
# Conflicts:
#	Cargo.lock
#	crates/horizon-platform/Cargo.toml
#	crates/horizon-platform/src/lib.rs
#	launcher/Cargo.lock
2026-11-09 16:51:12 +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
Horizon-agentandClaude Opus 5.5 ada4756065 Keep lan.rs and lan_actions.rs from growing under the current size check
main's check_file_size no longer counts test modules, so moving the two
inline test modules out no longer offset the new sea_level lines; it added
#[path] lines instead. The tests are back inline, and two self-contained
functions move to child modules unchanged (pure moves):
level_event_except to lan/level_event_targets.rs and horizon_channel to
lan_actions/horizon_channels.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:50:27 +00:00
Horizon-agentandClaude Opus 5.5 2fe3429a9a Insert a disc by its own jukebox_playable component too
JukeboxBlock.useItemOn asks the stack for JUKEBOX_PLAYABLE, so the insert
path and the client-side jukebox_takes now read the stack the same way the
comparator and the song tick do. An inline song definition is playable but
has no id to play.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:49:37 +00:00
Horizon agentandClaude Opus 5.5 3db7aa7cac Fix the fields' renames in the moved emote files
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:49:21 +01:00
Horizon agentandClaude Opus 5.5 5e6181b8f3 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:46:58 +01:00
pehe1andCodex 08f7f87f23 Use the item crate for the fixture save round trip
Co-Authored-By: Codex <[email protected]>
2026-11-09 12:46:16 -03:00
Horizon-agent 5b4984aa32 Merge remote-tracking branch 'origin/main' into m13723
# Conflicts:
#	crates/horizon-client/src/container_screen.rs
2026-11-09 15:45:28 +00: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
Horizon-agentandClaude Opus 5.5 8396106b2e Stats screen: look item registry ids up once per sort, not per row
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:45:01 +00: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
Horizon agent 7abf696d29 Merge split/cosmetics-prep
# Conflicts:
#	crates/horizon-client/src/game/cosmetics/emotes.rs
2026-11-09 16:44:34 +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 a94ef019be rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:43:25 +01:00
pehe1andCodex 1c9ebb2318 Merge current Horizon main into blocking durability
Co-Authored-By: Codex <[email protected]>
2026-11-09 12:43:20 -03:00
MSedgeMC e64dc6ad91 Keep one import of the goals module in the moved test file 2026-11-09 15:42:37 +00:00
Horizon agentandClaude Opus 5.5 50295a160c rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:40:17 +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
Horizon agentandClaude Opus 5.5 308bf3dae0 test: the overlay-hold check no longer races the clock
50 one-millisecond sleeps could pass the 80 ms hold on a loaded runner and
fail the test; hold for an hour, wait for the worker, then lift the hold.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:39:51 +01:00
Horizon agentandClaude Opus 5.5 470f9d01e0 fix: the window's inventory follows a mode change too
set_game_mode runs on the server thread; the window changes mode through
the server's game event (set_mode_id), so the switch to the creative
screen now lives in set_mode, which both reach.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:38:19 +01:00
Horizon agentandClaude Opus 5.5 8559f9ccd2 Merge origin/main: keep both the first-release and the double-click tests
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:36:25 +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 e1cd3420ce Take the pushed projectile's bubble surfaces instead of draining them
clippy's drain_collect: the drained Vec is the same type as the new one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:34:58 +01:00
Horizon agentandClaude Opus 5.5 21d6f5610b A partial pickup still takes the item and awards its stat
Inventory.add returns true once any of the stack went in (count < the
count before), so ItemEntity.playerTouch's take, ITEM_PICKED_UP stat and
onItemPickup run on a partial pickup too. The stat counts the stack's
count before the add, as playerTouch's local count does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:32:03 +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
Horizon agentandClaude Opus 5.5 507e48073a Merge main into #12272
main moved native_hooks.rs's tests to native_hooks_tests.rs: the new test moves there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:28:45 +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 0660817c30 Registry review: a version taken down by hash may leave its entry
A project whose signing key was lost (horizon-mods#276, baritone) can't keep
its old versions listed: the index checks every version against the entry's
one key. The review refused removing them even after revoked.toml took their
files down by sha256. Now such a removal is a note, not an error; a version
removed without a take-down by hash is still refused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:26:54 +01:00
Horizon agentandClaude Opus 5.5 16341f4d04 Land #11446 on the moved JEI sprite list
Merge move/jei-sprites (#13977): this PR's seven sprites go into jei_sprites.rs instead of hud.rs. The fuel list's wheel joins the bundle's in game.rs's container scroll (both take the wheel before the screen's own scrolling), so neither big file grows (docs/layers.md#file-size).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:26:13 +01:00
Appa 50901bae92 Reuse isolated game environment in DLL startup regression
Co-Authored-By: Codex <[email protected]>
(cherry picked from commit f047bd7949)
2026-11-09 16:25:06 +01:00
Appa c23c0056a1 Match the regression fixture to actual launcher DLL imports
Co-Authored-By: Codex <[email protected]>
(cherry picked from commit f36aff7aea)
2026-11-09 16:25:06 +01:00
Appa 76c3d4d262 Fix Windows launcher startup beside injector DLLs
Resolve static launcher imports from System32 before program entry and
exercise launcher/setup startup beside unrelated DLLs in release checks.

Co-Authored-By: Codex <[email protected]>
(cherry picked from commit 6d913502bf)
2026-11-09 16:25:06 +01:00
Horizon agentandClaude Opus 5.5 c024816c37 Merge main into blocking durability; the dispenser game keeps lab.game_env
Main's own tool cases joined the case list beside the blocking ones. The
branch had replaced lab.game_env with os.environ for the game it starts,
which test_game_home (every script that starts the game uses game_env)
refuses: the game then wrote into the real HORIZON_HOME. --game-dir and
the .exe name on Windows stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:50 +01:00
Horizon agentandClaude Opus 5.5 c9246a4fc5 Merge main into #12175
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:21 +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
DUzzL 282992c1ec Fix rider item pickup area and partial pickup side effects 2026-11-09 16:24:05 +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 agent eee2cd0db1 Merge main into native_registry_name 2026-11-09 16:23:08 +01:00
Horizon agentandClaude Opus 5.5 448ba30415 horizon-prepare: prefer a launcher folder that has every library
A launcher folder with 26.3's version JSON and jar but a missing library
(an official launcher that never finished downloading) was picked over a
complete copy in another launcher (the CurseForge app), and preparing
failed on the missing jar (#13872). game_files now tries folders with every
client library first, then the old order, so the error still names the
library when no folder has them all.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:22:59 +01:00
Horizon agentandClaude Opus 5.5 b82eeb4f95 Merge origin/main into #11446
Union of both sides: main's JEI notice (it already names the sprites), main's button/search sprites and settings button beside this PR's fuel sprites, tooltip and popup; en_us.json keeps main's keys and adds the three fuel strings.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:22:29 +01: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 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
Ludovic Marie 4f311197bd Merge split/cosmetics-prep 2026-11-09 16:18:58 +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
Horizon agent 49c2e8c55f Merge remote-tracking branch 'origin/main' into split/cosmetics-prep
# Conflicts:
#	Cargo.lock
#	tools/layers.json
2026-11-09 16:18:08 +01:00
MrCloudy2andClaude Opus 5.5 447514aa1d Harness: a piston's push gives a projectile its bubble column's push
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:17:55 +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
MSedgeMC c5897ab467 The first-release test carries a stack, the hand that would click
A release with an empty hand throws nothing whether skipNextRelease is set or
not; only a carried stack makes mouseReleased click, which is the hand that
opens a chest while holding one.
2026-11-09 15:13:54 +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
MrCloudy2andClaude Opus 5.5 d87da7928c A piston's push applies a projectile's bubble columns too
moveEntityByPiston's applyEffectsFromBlocks: a projectile a piston pushed
through a bubble column gets Projectile.onInsideBubbleColumn /
onAboveBubbleColumn, as after its own move.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:09:19 +01:00
Horizon agentandClaude Opus 5.5 481afc2e7b Cosmetics in one module tree: emotes and the emote wheel under game/cosmetics/
game/emotes.rs and game/emote_wheel.rs move to game/cosmetics/, their state into
Cosmetics (one Horizon field on Game), and the name mark comes from game/cosmetics.rs:
only game/cosmetics* names horizon_cosmetics now (layer plan M5, preparing the move
to a Horizon crate). No behaviour change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:06:53 +01:00
Horizon agentandClaude Opus 5.5 0deeb5cb29 discord_presence: rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:05:14 +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
Horizon agentandClaude Opus 5.5 b05af110f7 Cargo.lock: horizon-client-lib no longer depends on horizon-cosmetics
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:04:03 +01: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
MSedgeMC 6e45279301 A container screen's first release clicks nothing
AbstractContainerScreen's constructor sets `this.skipNextRelease = true`, so
a mouseReleased with no press before it on that screen returns before any
slotClicked. That is the release of the click that opened the screen: a
chest's right-click sends its UseItemOn, the server opens the menu, and the
button goes up with the screen already open, over whatever slot the pointer
was on. Horizon's Clicks started with the flag false (and cancel_drag cleared
it), so that release picked up the slot now under the pointer.

Clicks::default and cancel_drag now start a screen with skipNextRelease true,
as a fresh AbstractContainerScreen does.
2026-11-09 14:58:05 +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
MSedgeMC d3adab07d4 Re-run checks: the previous run failed in an unrelated fixture / harness 2026-11-09 14:52:13 +00: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 d0dcb33382 discord_presence: nothing of the game crate left in it
Layer plan M5 (preparation): the game mode comes from horizon_command, and
the party's size from game/discord.rs (Play::Hosting's max) instead of
crate::lan, so the presence client can move to a Horizon crate as a pure
file move once the seam has an activity hook. No change for players.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:44:17 +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
Horizon agentandClaude Opus 5.5 3bca3ed851 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:43:43 +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
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
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
Horizon agentandClaude Opus 5.5 24c8f66b32 Launcher backend.rs: import the platform's prepare, so the file doesn't grow
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:37:27 +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
MSedgeMC 2982b03bdf Move the Tempt goal out of goals.rs and scale its range by visibility
goals.rs is over the 3000-line limit, so the goal moves to mobs/goals/tempt.rs (a pure move) and the visibility
scaling lands there: TemptGoal reads TEMPT_RANGE with TargetingConditions' two-block floor.
2026-11-09 14:27:03 +00:00
Horizon agentandClaude Opus 5.5 c391c8971c rustfmt: the longer platform paths
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:25:17 +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
Skr1zzzzandClaude Opus 5.5 c73a1df2a5 Move a bucket's aim and target out of game.rs into game/buckets.rs
Part of #11857. bucket_hit and bucket_target (BucketItem.use's place position) move to a new game/buckets.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:11 +03:00
Skr1zzzzandClaude Opus 5.5 0f4cd026d3 Move saving a level in place and the world icon out of game.rs into game/saving.rs
Part of #11857. save_level and tick_world_icon (GameRenderer.tryTakeScreenshotIfNeeded) join game/saving.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:22:55 +03: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
Nikita Maximilian GrimmandClaude Opus 5.5 652accb89b docs/layers.md: test modules and test files don't count toward the size limit
The section #13011 landed predates #13847 and this branch; it now says
which test code the check skips.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:16:49 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 93e3025e31 File size check: tests.rs and *_tests.rs files don't count
They hold the tests moved out of a file (`#[cfg(test)] mod tests;`), and a
normal build doesn't compile them, the same reason inline test modules
don't count. The failure message sends new tests to a sibling
`<file>_tests.rs`, yet two such files were already over the limit
(mod_content_tests.rs 5,268 lines, level/tests.rs 4,175).

Fixes #13892

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:16:39 +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 4cada3276c Lockfiles: horizon-platform's new engine dependencies
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:15:57 +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
Horizon agentandClaude Opus 5.5 8d38384310 Emotes pose the player model through an engine hook, mobs::anim::BodyPose
Mob::emote holds a trait object the engine defines (anim::BodyPose) instead of
horizon_cosmetics::Playing; game/emotes.rs wraps Playing in it. horizon-client-lib
no longer depends on horizon-cosmetics (layer plan M5).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:15:22 +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 agentandClaude Opus 5.5 92886cd964 Launcher and horizon-server reach the engine through horizon-platform
Layer plan M6: horizon-platform gains feature-gated re-exports (version,
no_ai, prepared_files always; world, protocol, prepare, server-files behind
features) and the two Horizon-layer crates use them instead of depending on
horizon-core, horizon-prepare, horizon-protocol, horizon-world and
horizon-server-files. Seven layers.json exceptions go.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:14:02 +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
MSedgeMC b768990c0c Merge remote-tracking branch 'upstream/main' into HEAD 2026-11-09 13:59:53 +00: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
Mik00000 89fe5eb063 Add safe async task offloading API for native mods
Introduce Host-driven task offloading API for native mods (mod_async_worker).
Tasks run on a dedicated mod worker pool ('horizon-mod-worker') isolated from chunk
and terrain generation. Panics in mod background jobs and deferred main-thread callbacks
are caught and reported per mod via crash attribution and contain without crashing pool
workers or the main game loop.

Callback queues belong to individual Game instances (window and server thread in single
player), reached exclusively through Host without global ACTIVE_QUEUE state or test races.
2026-11-09 15:50:01 +02: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
Skr1zzzzandClaude Opus 5.5 630258d2a0 Move ticking chunk tracking and scheduled ticks out of game.rs into game/block_ticks.rs
Part of #11857. sync_tick_chunks, tick_moving_pistons and tick_scheduled join game/block_ticks.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:35:40 +03:00
Nikita Maximilian GrimmandClaude Opus 5.5 9f376f7061 The stations prepared-data test loads the full item table
Items::tags_only has no items, so the anvil part of
prepared_vanilla_data saw a diamond sword with max damage 0: not
damageable, no repair, cost 0 where vanilla gives 1. The test now loads
Items::load, and the whole test passes on freshly prepared data.

Fixes #13883

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:33:22 +01:00
SenjinandClaude Opus 5.5 4a70193562 Shader packs: a shadow program with no output left keeps its water, without the boats' patches
The shadow pass's translucent terrain takes one vertex output more for the boats' water patches
(the vertex's camera-relative place). A pack whose shadow program already writes as many as the
GPU takes had that program refused, and with it its translucent terrain's shadows. It now gets the
program without the patches instead (`Post::shadow_water_plain`), with a note: the water's light
shows in the boats there, as before the patches, and the rest of the shadow pass stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:29:19 +01: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
MSedgeMC e2482f0e1d port_map: the containerTick row names the fn where it now lives
The swap moved to game_mode.rs for the file-size limit.
2026-11-09 13:28:25 +00:00
MSedgeMC 0d6aaca77d fix: keep the inventory swap out of commands.rs
commands.rs is already over the 3000-line limit and may not grow, so the
screen swap moves to game/game_mode.rs beside set_mode, which is where the
mode's own effects live. The call site is one line, and the file ends a line
shorter than the base.
2026-11-09 13:25:43 +00: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
MSedgeMC f2be6f018c Keep goals.rs under the file-size limit
The targeting tests move to mobs/goals_targeting_tests.rs, so goals.rs (over 3000 lines) shrinks from 3740 to 3611
while the new tempt-range test joins them.
2026-11-09 13:22:55 +00: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
Nikita Maximilian GrimmandClaude Opus 5.5 5d5f14b7c5 CI: the size, layer and address checks compare with HEAD^1 on pull requests
verify checks out GitHub's merge commit, but these three checks compared
it with pull_request.base.sha: main at the pull request's last push. As
main moves on, main's own changes since then counted as the pull
request's, so pull requests failed for files they never touched (on
#13027's merge: 17 files flagged, 16 not in the PR), and a file main had
shrunk could grow unnoticed. HEAD^1 is the main the merge commit puts
the pull request on. Pushes to main run as before, without a base.

Fixes #13874

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:18:37 +01: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
MSedgeMC d2efe0374c port_map: InventoryScreen.containerTick names the screen swap
The row said the switch to the creative screen was missing; it is the same
tick rule the creative screen already had in reverse.
2026-11-09 13:08:57 +00:00
MSedgeMC 278db50cfd test: pin which screen a mode change leaves the survival inventory on
Creative gets the creative inventory, survival keeps the crafting one, a
spectator still closes it, and any other screen is untouched.
2026-11-09 13:08:53 +00:00
MSedgeMC 12dbbe2251 fix: a survival inventory becomes the creative one when the mode changes
InventoryScreen.containerTick replaces its screen with a
CreativeModeInventoryScreen once the player has infinite materials, so the
player stays in a menu. Horizon closed the inventory instead, which lost the
screen a creative player expects after /gamemode creative. The spectator path
still closes it (vanilla has no branch there and leaves it open).
2026-11-09 13:08:53 +00:00
Florian Ernst 134b443675 Start CI again: the last push started no checks run 2026-11-09 14:03:56 +01:00
MSedgeMC 7903537503 port_map: LoadingOverlay.isReadyToFadeOut names the hold
The row was filed as missing while the overlay was taken down as soon as the
worker finished.
2026-11-09 13:01:40 +00:00
MSedgeMC 0c989e88e1 test: pin the overlay's one-second hold
The hold is 1000 ms, a staged pack waits it out even when the worker is long
done, and a zero hold applies at once.
2026-11-09 13:01:37 +00:00
MSedgeMC 0e5d9f1d12 fix: a pack reload keeps its overlay up for a second before it applies
LoadingOverlay.isReadyToFadeOut holds a mid-game reload's overlay while
Util.getMillis() - fadeInStart < 1000L, so a pack that stages in a few frames
still shows the progress for a second instead of flashing for one or two.
drive_pack_reload applied the staged pack the moment its worker finished.
2026-11-09 13:01:37 +00:00
Skr1zzzzandClaude Opus 5.5 327a39bc56 Move block crack and transform particles out of game.rs into game/block_particles.rs
Part of #11857. particle_sprite, crack_particle (ClientLevel.addBreakingParticles) and transform_particle join game/block_particles.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:00:09 +03: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
Horizon agentandClaude Opus 5.5 0e687efd6e Cloud custom_particle: decode as the 26.3 option codecs do
ColorParticleOption keeps the colour's alpha (ARGB_COLOR_CODEC: an int's
top byte or a fourth float); colour lists have a fixed length and are
packed to 8-bit channels (ARGB.colorFromFloat); dust scale, sculk_charge
roll and shriek delay are required, and dust scale must be in [0.01, 4];
a spell's colour defaults to white. What fails to decode falls back to
the potion swirl, as vanilla's failed decode leaves no custom particle.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:46:58 +01: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
Horizon agentandClaude Opus 5.5 8a2a85a683 pot_hopper runs by name only; drop the harness contract test
pot_hopper needs --side and --work-dir, which an automatic parity run
doesn't pass. tests/test_pot_hopper_contract.py pinned the corpus row
counts and the harness's own compare, not game behaviour.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:43:51 +01:00
Vinícius MoreiraandClaude Opus 5.5 3319947bdf Polytone: creative tab modifiers
A pack's creative_tab_modifiers change a creative tab's icon, its name (text and colour),
and its items: removals by the wiki's item predicates (or, and, not, tag_match,
items_match, itemstack_match by item, id_match, true), then additions at the end or
before or after the first item a predicate matches. Measured on the real mod (icon and
red name on the building blocks tab, with the inventory opened through XTEST).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:42:51 -03:00
Horizon agentandClaude Opus 5.5 db07aba271 Drop a rebase leftover and a fixture nothing runs
respawn_world_point_tests.rs.orig is main's copy of the test file; the
spawn-info Python fixture and its Java decoder match no harness name, so
no runner ever calls them. The Rust test covers the sea level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:40:14 +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
Mik00000 a9a14c810e perf(mobs): optimize sensory raycasts, section caching, and despawn checks 2026-11-09 13:35:02 +02:00
Vinícius MoreiraandClaude Opus 5.5 9c1aca3474 Polytone: colormap tints cost a twelfth as much to mesh
The mesher's pack colormaps (the freepack's grass and leaves) sampled every block of a
section, each with its own biome zoom, through SipHash maps. Now:
- a section of one biome skips the per-block zoom (as vanilla tints already did);
- a colormap whose expressions don't read the height (no o.y) is sampled once per column
  over one biome (Program::reads_only says what an expression reads);
- the tint caches use the Fx hasher, and expression calls keep up to four arguments on
  the stack instead of a Vec.
Same colours (checksum of the timing test unchanged). One section, 16 layers of 256 tints
with a biome-id and noise colormap at Biome Blend 2 (release): 2.16 ms before, 178 µs now;
one layer 274 µs before, 138 µs now (mesher::tint_tests::colormap_tint_cost, ignored).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:49:33 -03:00
Vinícius MoreiraandClaude Opus 5.5 a62af71cef Polytone: environment.horizon_height
The sky's dark disc draws below a pack's horizon height instead of vanilla's 63 (flat worlds
keep their floor), measured on the real mod; sky_render_distance changes nothing there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:37:35 -03:00
Vinícius MoreiraandClaude Opus 5.5 0b38e07d1d Polytone: biome_colors replace vanilla's biome colour functions
biome_colors.grass/foliage/dry_foliage/water (a colormap or a flat colour) colour every
block tinted by that function, under the block modifiers' own colormaps; without it, a block
modifier's colormap on grass_block, oak_leaves or leaf_litter (or a fluid modifier's on
water) becomes the function, as the wiki says Polytone does. Measured on the real mod:
biome_colors.grass also wins over a dark forest's special grass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 07:37:35 -03:00
Nikita Maximilian Grimm 2d9576f84d Merge remote-tracking branch 'origin/main' into parity/piston-falling-block 2026-11-09 11:01:23 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 3e7da21a05 A falling block that lands in a moving piston's block stays and is pushed
FallingBlockEntity.tick neither places, drops nor discards a block that
comes down where a piston is extending, and checks that before cancelDrop:
it slows like any landing and stays a falling block, so the piston pushes
it on. Horizon took the block out of the list before the landing returned
Landing::Continue, so the block was lost.

Fixes #13793

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:59:33 +01:00
Florian ErnstandClaude Opus 5.5 92dd7886f0 Keep big LAN files from growing: tests and spawn_info in their own files
The file-size check (docs/layers.md#file-size) does not let a file over
3000 lines grow, and the sea level field adds a line in each. Hub's
spawn_info moves to lan/hub/spawn_info.rs; three inline test modules
move to their own files, unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:54:51 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 8ff04112a2 HDR output: Auto's grace ends on a fresh read after a settings change too
A settings change at the end of Auto's grace (a slider, F11) applies
the output before the frame's own display poll. It ended the grace on
the report read when the grace started, so a display that had come
back meanwhile still turned HDR off, and the frame's poll after could
no longer confirm it: the timer was gone.

The output now reads the display before the grace ends, whichever comes
first, the frame's poll or a settings change. A read taken since the
deadline counts, so each end of the grace reads the display at most
once. The two-second poll is unchanged otherwise, and Wayland's events
need no read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:44:26 +01:00
MSedgeMC d595ebdd5e Invisible players tempt animals only within two blocks 2026-11-09 09:40:59 +00: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 bf28b98a03 port_map: DirectionalSoundInstance.setPosition is directional_position
The row pointed at tick_end and said the flash sound has no position. The
10-block directional position is implemented and the flash sound uses it
from tick_end_flash.
2026-11-09 09:31:09 +00:00
MSedgeMC 779fb404f5 port_map: ClientLevel.syncBlockState does snap the player out
The note said the player is not moved when the restored block would hold
them. end_predictions checks block_meets_player and snaps through
snap_target/snap_player_to.
2026-11-09 09:30:48 +00:00
MSedgeMC 196f313399 port_map: the sprite animation rows name animate_with
SpriteAnimator::animate is gone; animate_with is the public pass that
advances every animated layer for the tick.
2026-11-09 09:30:23 +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
MSedgeMC 6fea5b936a Pufferfish puff goal ignores spectators 2026-11-09 09:21:05 +00:00
MSedgeMC fd04e8e867 Enderman stare goal ignores spectators 2026-11-09 09:10:55 +00:00
Nikita Maximilian Grimm 687f1bfd12 Merge remote-tracking branch 'origin/main' into fix/timeout-seconds-every-run 2026-11-09 10:10:06 +01:00
Mik00000 dde35f2b2e perf(worldgen): expand structure locate rings across candidate sets with vanilla stop order 2026-11-09 11:05:21 +02:00
MSedgeMC 704c3906e6 A brewing stand reads a numeric speed multiplier
BrewingStandBlockEntity.loadAdditional reads speed_multiplier with
ValueInput.getFloatOr, which takes any numeric tag. Horizon only accepted a
Float tag and defaulted every other numeric form to 1.0.
2026-11-09 09:04:14 +00:00
MSedgeMC 0413376752 A jukebox plays the song its disc names
JukeboxSong.fromStack reads the stack's own minecraft:jukebox_playable
component, and a removed component names no song. Horizon resolved records
through the item's default component only, so a disc that overrode or removed
the component played, or stayed silent, against the stack in the slot.
2026-11-09 09:04:07 +00:00
Nikita Maximilian GrimmandClaude Opus 5.5 c26fb23197 HDR output: Auto keeps HDR through a moment's loss of the display's report
With HDR on Auto, a display that wakes, a window moved to another
display, or a moment around Win+Alt+B can report SDR or nothing for one
read and HDR again at the next. Each such read turned HDR off at once
and on again: two swapchain reconfigurations, and on the layered float
frame a new renderer and a remesh, so the player saw black flashes and
a stall for nothing.

Now, while Auto presents HDR and the surface still offers that
encoding, a report that turns false or unknown keeps HDR on for 500 ms,
at the paper white, peak and black last applied. The applied output,
the shader Frame, the settings' active state and the frame the game
asks for stay as they were. A positive report before then clears the
timer with no swap; a loss that lasts turns HDR off once. Where the
system is polled, the display is read again at the deadline rather
than trusting a read up to two seconds old; where it sends events, the
deadline is served without one. The two-second poll stays otherwise.

Off and On act at once, entering HDR is never delayed, and an encoding
the surface no longer offers is never kept: the output switches to the
one offered, or SDR, at once. The timer is each renderer's own.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:03:50 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 50db6475fe --timeout-seconds ends every run, not only a frame-limited one (#13751)
A control-socket run started with --timeout-seconds never stopped when its
harness died. The flag now applies to every run; a frame-limited run keeps
its 120 s default and a run without either has no limit. --help lists it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:03:11 +01:00
RINNECODERandClaude Opus 5.5 a84771f764 Sulfur cube: MAX_FUSE is entity data 19 and FROM_BUCKET 20
SulfurCube defines MAX_FUSE (an int, -1 until primed) before FROM_BUCKET
(a boolean), right after AbstractCubeMob.ID_SIZE (18). Horizon sent and read
them the other way round, so a 26.3 client tracking a sulfur cube got a
boolean at 19 and disconnected (#13721). The server now sends the fuse at 19
with vanilla's -1 default and the bucket flag at 20, and the client reads
them there; a fuse of -1 is not primed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:02:25 -06:00
Florian ErnstandClaude Opus 5.5 58ac340c1c Let hoppers insert into the decorated pot saved item slot
Squashed and rebased onto main from #11599.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:01:28 +01:00
Florian ErnstandClaude Opus 5.5 d2612fd508 Use generator sea level in guest spawn info
Squashed and rebased onto main from #11458.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 10:01:28 +01:00
DUzzL a01f9d5883 Area effect clouds: honour and save custom_particle 2026-11-09 09:57:05 +01:00
RINNECODERandClaude Opus 5.5 562175df6c Streaming: meshes waiting for upload count against the meshing room
Uploads take a few ms of each frame. At a wide render distance and a low frame
rate they fall behind, and meshing kept going: a heavy modpack at 64 chunks held
4.3 GB of finished meshes in pending_uploads during a flight, enough to swap a
24 GB Mac. The room for new mesh jobs now subtracts that backlog.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 02:52:32 -06:00
RINNECODERandClaude Opus 5.5 13f692f8f0 Design-system text: § codes are formatting, glyphs Geist lacks come from the pack's font
A resource pack's lang can give strings `§` colour codes ("§6Options...")
and private-use characters its `font/default.json` maps to bitmap icons.
Geist printed the codes literally and drew tofu for the icons. `Ui::layout`,
shared by `measure` and `text_line`, now drops `§` codes like
`StringDecomposer.iterateFormatted` and draws a character Geist lacks with the
pack's default-font glyph: vanilla's 9px line scaled onto Geist's, or at GUI
scale when the whole label is such glyphs, as the pack draws it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 02:47:25 -06:00
k3yhandClaude Opus 5.5 3eb0cdc6f5 Content mods' fluids move the player and push items by their numbers
Step 5 (first part) of #6301: each mod fluid gets its own tag in
EntityFluidInteraction, as NeoForge tracks each FluidType, so it is not
water to the game. Its current pushes the player, items, orbs, armour
stands and boats by motion_scale when can_push_entity; the player moves in
it as in water, swims and rises when can_swim, loses air when can_drown, is
put out when can_extinguish, and has the fall distance scaled by
fall_distance_modifier. Unset numbers are the template's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 03:47:11 -05:00
Vinícius MoreiraandClaude Opus 5.5 fb354374e3 Polytone: item modifiers' damage bar colours
An item modifier's bar_color of the damage type (an <item>_bar.png alone, or a json with
bar_color "damage" and its png) colours the durability bar by the stack's damage: the first
pixel undamaged, the last at full damage, rounded, as measured on the real mod. The item
colormap (tint) changes nothing on the real mod's Fabric 26.3 build, so the freepack's
grass item modifier already looks the same.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:22:03 -03:00
RINNECODERandClaude Opus 5.5 644f4d80f7 Chat: apply a server's filter mask to player messages
The client decoded the player chat packet's `FilterMask` but only used it
to check the signature: on a server that filters chat, a fully filtered
message was still shown and narrated, and a partly filtered one showed
without its `#` masking.

As `ChatListener.showMessageToPlayer` does, a fully filtered message is
now hidden (and counted as processed, like a blocked sender's), and a
partly filtered one shows and narrates
`FilterMask.applyWithFormatting(signedContent)`: each masked UTF-16 unit
a dark grey `#` with the `chat.filtered` hover.

Fixes #13674

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 02:19:19 -06:00
Vinícius MoreiraandClaude Opus 5.5 8125ddb602 Polytone: a pack's leash texture
A pack's entity/lead.png textures the leash: each of its 24 segments, from the leashed
end, takes its row of the texture, unshaded, as measured on the real mod. Without one the
leash keeps vanilla's brown checker. The custom heart containers (polytone/heart/container_*)
change nothing on the real mod's 26.3 build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 05:14:27 -03:00
MSedgeMC 1a26268488 A screen pauses the single-player world when its isPauseScreen says
Minecraft.pause is `hasSingleplayerServer() && Gui.isPausing()`, and
Gui.isPausing is the open screen's isPauseScreen. Horizon kept that answer in
a matches! list beside pauses_game instead of on the Screen, and two screens
disagreed with 26.3. SocialInteractionsScreen overrides isPauseScreen false,
so the world keeps running behind it; Horizon paused. StructureBlockEditScreen,
JigsawBlockEditScreen and the two test block editors inherit Screen's true, so
opening one freezes a single-player world as a command block's screen already
did; Horizon left them out of the list.

The answer is now `Screen::is_pause_screen`, one match over every variant, and
pauses_game asks it. The book in hand, the win screen and a dialog still answer
from their own state where they live.
2026-11-09 08:03:34 +00:00
MSedgeMC 53c6cd4fa5 A container screen ignores the hotbar keys while a stack is carried
AbstractContainerScreen.checkHotbarKeyPressed answers nothing unless
`this.menu.getCarried().isEmpty() && this.hoveredSlot != null`: with a stack
on the cursor, keySwapOffhand and keyHotbarSlots send no slotClicked at all.
The mouse hotbar keys asked `carried.is_none()` at their call site, but the
keyboard path (Game::inventory_hotkey from Screen::keyPressed) did not, so
pressing 1 or F over a slot with a stack carried sent SWAP. The menu's SWAP
branch does not look at the carried stack, so it moved the hovered slot's
item into the hotbar slot while the stack stayed on the cursor.

The guard now lives in `container_screen::hotbar_swap`, the Rust counterpart
of checkHotbarKeyPressed, and `inventory_hotkey` asks it for the off hand and
the nine hotbar slots.
2026-11-09 08:00:48 +00:00
RINNECODERandClaude Opus 5.5 7bd4244e2f Stats screen: mined blocks count under the item that places them
The Items tab keyed its rows on the raw `minecraft:mined` stat key, a
block id, so wheat, redstone wire, tripwire, wall torches and other blocks
whose item has another id got a row for a non-item with "-" under Mined,
while the real item (wheat seeds, redstone, string, torch) showed no mined
count. Rows now take `Block.asItem` of a mined block (air dropped), and a
block item's Mined column reads its block's count, as vanilla's
`StatsScreen.ItemStatisticsList` and `ItemRow` do.

Sorting follows `ItemRowComparator`: "-" counts as -1, equal values are
ordered by item registry id, and the first click on a column sorts
biggest first (it sorted smallest first).

Fixes #13664

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:51:34 -06: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
pehe1andCodex 1788fb7b84 Keep the combat fixture documentation with its operation
Co-Authored-By: Codex <[email protected]>
2026-11-08 23:25:21 -03:00
pehe1andCodex c9592a87f6 Reset the blocking fixture player after each case
Co-Authored-By: Codex <[email protected]>
2026-11-08 23:20:43 -03:00
pehe1andCodex 3b34446f84 Honor custom durability on blocking items
Co-Authored-By: Codex <[email protected]>
2026-11-08 23:19:40 -03: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
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
Cheezer1656andClaude Code 85514bb934 Mod API: a mod's own screen over the options page, pause menu or container
native_mod_screen_open_over(mod_id, keep_drawing_previous) opens a mod screen
over Options, Paused or Inventory (a container) as well as the world. Escape or
native_mod_screen_close gives the replaced screen back (vanilla's lastScreen).
With keep_drawing_previous the pause title or container panel still draws under
it, pointer-less, and native_mod_screen_under_rect reports its panel. A server
ContainerClose closes the mod screen with the container; single-player stays
paused under a pausing screen. Ships as a hook fragment (mod_screen_over).

Co-Authored-By: Claude Code <[email protected]>
Claude-Session: https://claude.ai/code/session_011R7zRUK5udBTJmDgHhoXY2
2026-11-08 13:14:05 -07:00
Cheezer1656andClaude Code 7fe4eb3931 Native mods: the player's own inventory on a client
host.own_inventory() (native_own_inventory) gives the 36 slots of the
player's own inventory (0-8 hotbar, 9-35 main) with their components,
whichever menu is open. menu_stacks() is None for the window's own
inventory, and living.equipment only covers armor and the held item.

Co-Authored-By: Claude Code <[email protected]>
Claude-Session: https://claude.ai/code/session_011R7zRUK5udBTJmDgHhoXY2
2026-11-08 12:52:14 -07: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
Cheezer1656 44e8e374b9 Merge upstream/main into upstream-gap/u-01-registry-name 2026-11-07 22:27:59 -07:00
Cheezer1656andClaude Code 218b421e16 Native mod API: registry names for numeric ids (native_registry_name)
A mod hearing packets through native_client_packet gets registry references
as numbers (a Stack's item and components, AddEntity's type, Sound,
LevelParticles) and could not name them. native_registry_name(registry, id)
resolves one against the ids the connection uses: the built-in registries,
those the server synced and a mod's own data components. On a server it asks
the hub's SyncData the same way.

Helper-only fragment registry_name.

Co-Authored-By: Claude Code <[email protected]>
Claude-Session: https://claude.ai/code/session_011R7zRUK5udBTJmDgHhoXY2
2026-11-07 22:10:47 -07: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 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
GabrielNichols f4d97fff39 Include the Recipes fuel translations 2026-11-07 01:39:17 -03: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
GabrielNichols d1b447525a Show ordered alternatives for Recipes furnace fuel 2026-11-06 19:16:57 -03: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
470 changed files with 16521 additions and 4849 deletions

No files matched your search

+14 -11
View File
@@ -80,21 +80,24 @@ jobs:
rustup toolchain install stable --profile minimal --component rustfmt --component clippy
rustup default stable
echo "WGPU_BACKEND=dx12" >> "$GITHUB_ENV"
# On a pull request, the next three checks compare with HEAD^1: the main that GitHub's merge commit puts the pull
# request on. pull_request.base.sha is main at the pull request's last push and falls behind, so main's own
# changes since then would count as the pull request's (#13874).
# docs/layers.md: a crate depends only on its own layer or a lower one; today's exceptions only shrink.
- name: Crates follow the layers
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_layers.py ${BASE_SHA:+--base "$BASE_SHA"}
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: python3 tools/check_layers.py ${BASE:+--base "$BASE"}
# docs/layers.md#built-in-addresses: every built-in outside address in the Horizon layer's home; the rest only shrinks.
- name: Built-in addresses stay in their home
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_addresses.py ${BASE_SHA:+--base "$BASE_SHA"}
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: python3 tools/check_addresses.py ${BASE:+--base "$BASE"}
# docs/layers.md#file-size: a Rust file over 3000 lines may not grow (pull requests; main has no base to compare).
- name: Big files don't grow
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: python3 tools/check_file_size.py ${BASE_SHA:+--base "$BASE_SHA"}
BASE: ${{ github.event_name == 'pull_request' && 'HEAD^1' || '' }}
run: python3 tools/check_file_size.py ${BASE:+--base "$BASE"}
- name: Test offline research tooling
run: python -m unittest discover -s tests -v
- name: Install Rust toolchain and the ALSA and udev headers the client links against
@@ -217,17 +220,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
View File
@@ -201,6 +201,9 @@ jobs:
- name: Verify Windows executable architecture
if: runner.os == 'Windows'
run: python ../tools/check_pe_arch.py "${{ matrix.pe_arch }}" "$BIN/${{ matrix.binary }}"
- name: Unrelated DLLs cannot intercept launcher or setup startup
if: runner.os == 'Windows'
run: python ../tools/test_launcher_dll_search.py "$BIN/${{ matrix.binary }}"
- name: The version is the release's
run: |
out=$("$BIN/${{ matrix.binary }}" --version)
+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)" \
+3
View File
@@ -44,6 +44,8 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
- **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
@@ -79,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` |
Generated
+43 -7
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",
@@ -1528,10 +1528,9 @@ dependencies = [
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mcp",
"horizon-mods",
@@ -1587,8 +1586,8 @@ dependencies = [
"horizon-core",
"horizon-cosmetics",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
"horizon-mod-api-gen",
"horizon-narrator",
"horizon-optifine",
"horizon-polytone",
@@ -1677,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"
@@ -1688,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"
@@ -1768,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"
@@ -1782,7 +1814,13 @@ dependencies = [
name = "horizon-platform"
version = "0.1.0"
dependencies = [
"horizon-core",
"horizon-hooks",
"horizon-prepare",
"horizon-protocol",
"horizon-server-files",
"horizon-system",
"horizon-world",
]
[[package]]
@@ -1868,11 +1906,9 @@ dependencies = [
"horizon-anticheat",
"horizon-bench",
"horizon-chat-filter",
"horizon-core",
"horizon-mcp",
"horizon-platform",
"horizon-release-sign",
"horizon-server-files",
"horizon-world",
"libc",
"p12-keystore",
"png",
+4
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",
+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 = [
+2 -2
View File
@@ -80,8 +80,8 @@ pub fn main(args: &[String]) -> Result<(), String> {
};
let author = flag(rest, "--author").ok_or(USAGE)?;
let (base, _) = registry::load(Path::new(base)).map_err(lines)?;
let (head, _) = registry::load(Path::new(head)).map_err(lines)?;
let review = registry::review(&base, &head, author);
let (head, revoked) = registry::load(Path::new(head)).map_err(lines)?;
let review = registry::review_with_revoked(&base, &head, &revoked, author);
if rest.iter().any(|a| a == "--json") {
let json = serde_json::json!({"errors": review.errors, "notes": review.notes});
println!("{json}");
+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 -10
View File
@@ -18,10 +18,10 @@ horizon-client-core = { path = "../horizon-client-core" }
horizon-client-input = { path = "../horizon-client-input" }
horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
# Emotes pose the player model (`Mob::emote`, `mobs::anim::setup`).
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 +73,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 +111,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.
+2 -3
View File
@@ -7,6 +7,8 @@ rust-version.workspace = true
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
# The settings stores of the crates above the engine (docs/layers.md, "Platform registries").
horizon-hooks = { path = "../horizon-hooks" }
horizon-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
@@ -76,9 +78,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");
}
+39
View File
@@ -16,6 +16,45 @@ use crate::{
#[path = "chat_tests.rs"]
mod tests;
/// `FilterMask.applyWithFormatting` over a player message's signed content, as
/// the packet's mask `(type, bit set)`: as it is when passed through, none when
/// fully filtered, and when partly filtered each masked UTF-16 unit becomes a
/// `#` in `FILTERED_STYLE` (dark grey, hover "chat.filtered").
pub fn filtered_message(
content: &str,
(kind, bits): &(i32, Vec<i64>),
) -> Option<serde_json::Value> {
match kind {
1 => return None,
2 => {}
_ => return Some(serde_json::Value::from(content)),
}
let masked = |i: usize| {
bits.get(i / 64)
.is_some_and(|w| (*w as u64 >> (i % 64)) & 1 != 0)
};
let units: Vec<u16> = content.encode_utf16().collect();
let mut parts = Vec::new();
let mut start = 0;
while start < units.len() {
let filtered = masked(start);
let end = (start..units.len())
.find(|&i| masked(i) != filtered)
.unwrap_or(units.len());
parts.push(if filtered {
serde_json::json!({
"text": "#".repeat(end - start),
"color": "dark_gray",
"hover_event": {"action": "show_text", "value": {"translate": "chat.filtered"}},
})
} else {
serde_json::Value::from(String::from_utf16_lossy(&units[start..end]))
});
start = end;
}
Some(serde_json::json!({"text": "", "extra": parts}))
}
/// `CommandHistory.PERSISTED_COMMANDS_FILE_NAME` and its size cap.
const COMMAND_HISTORY_FILE: &str = "command_history.txt";
const MAX_PERSISTED_COMMAND_HISTORY: usize = 50;
@@ -1026,3 +1026,43 @@ fn a_compact_repeat_keeps_the_scrolled_view_still() {
says(&mut chat, "new");
assert_eq!(chat.scroll, 6);
}
/// `FilterMask.applyWithFormatting`: a pass-through mask keeps the message, a
/// fully filtered one hides it, and a partial one turns each masked UTF-16 unit
/// into a dark grey `#` whose hover reads `chat.filtered`.
#[test]
fn a_server_filter_mask_hides_or_masks_the_message() {
let text = |v: &serde_json::Value| crate::component::to_text(v, &mut |s| Text::literal(s));
let shown = |content: &str, mask: (i32, Vec<i64>)| {
filtered_message(content, &mask).map(|v| text(&v).plain())
};
assert_eq!(shown("hello", (0, vec![])), Some("hello".to_owned()));
assert_eq!(shown("hello", (1, vec![])), None);
assert_eq!(shown("hello", (2, vec![0b0110])), Some("h##lo".to_owned()));
assert_eq!(
shown("hello", (2, vec![0b1_0001])),
Some("#ell#".to_owned())
);
// An emoji is two UTF-16 units, so two bits and two `#`.
assert_eq!(
shown("a\u{1F600}b", (2, vec![0b0110])),
Some("a##b".to_owned())
);
// Bit 65 is in the second word.
let long = "x".repeat(70);
let mut expected = long.clone();
expected.replace_range(65..66, "#");
assert_eq!(shown(&long, (2, vec![0, 0b10])), Some(expected));
let masked = text(&filtered_message("hello", &(2, vec![0b0110])).unwrap());
let hash = masked.spans.iter().find(|s| s.text == "##").unwrap();
assert_eq!(hash.style.color, Some(text::DARK_GRAY));
let hover = hash.style.hover.as_ref().unwrap();
assert_eq!(hover.spans[0].text, "chat.filtered");
assert!(
masked
.spans
.iter()
.filter(|s| s.text != "##")
.all(|s| s.style.color.is_none())
);
}
+6 -1
View File
@@ -43,6 +43,11 @@ pub fn enter_native_mod(call: NativeModCall) -> NativeModGuard {
}
}
/// The native mod call this thread is currently inside, if any.
pub fn current_native_mod() -> Option<NativeModCall> {
IN_NATIVE_MOD.with(Cell::get)
}
/// Puts back the call that `enter_native_mod` found.
#[must_use]
pub struct NativeModGuard {
@@ -244,7 +249,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| {
@@ -305,6 +305,9 @@ pub(crate) struct Colormap {
/// (`temperature`, `downfall`, `biome_id`) blend: Polytone 6.8.6's water by `biome_id`
/// fades over a biome edge.
pub(crate) biome_blend: bool,
/// Its axes read nothing that changes up a column of one biome (no `o.y`): a section of
/// one biome can sample it once per column.
pub(crate) column: bool,
width: u32,
height: u32,
rgba: Vec<u8>,
@@ -323,6 +326,18 @@ enum Axis {
}
impl Axis {
/// Whether the axis reads only what stays the same up a column of one biome.
fn column(&self) -> bool {
use horizon_polytone::expr::Root;
let flat = |root: Root, name: &str| match root {
Root::Object => matches!(name, "x" | "z" | "biome" | "temperature" | "downfall"),
Root::Random => name == "noise",
_ => false,
};
match self {
Self::Expr(program) | Self::BiomeId(_, program) => program.reads_only(&flat),
}
}
fn value(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<f64> {
match self {
Self::Expr(program) => Some(program.eval(host).ok()?.num()),
@@ -373,6 +388,23 @@ fn biome_id_mapper(pack_root: &Path, json: &Json) -> HashMap<String, f64> {
}
impl Colormap {
/// One colour wherever it samples (a `biome_colors` flat colour, RGB).
pub(crate) fn flat(rgb: u32) -> Self {
let zero = || Axis::Expr(horizon_polytone::expr::Program::compile("0").expect("a number"));
let [_, r, g, b] = rgb.to_be_bytes();
Self {
x: zero(),
y: zero(),
triangular: false,
rounds: true,
biome_blend: false,
column: true,
width: 1,
height: 1,
rgba: vec![r, g, b, 255],
}
}
/// `assets/<namespace>/polytone/colormaps/<path>.png` and its optional `.json` (without
/// one, the axes are the biome's temperature and downfall).
pub(crate) fn load(pack_root: &Path, id: &str) -> Option<Self> {
@@ -424,9 +456,11 @@ impl Colormap {
axis("x_axis", "o.temperature")?,
axis("y_axis", "o.downfall")?,
);
let column = [&x, &y].iter().all(|axis| axis.column());
Some(Self {
x,
y,
column,
biome_blend: json["biome_blend"].as_bool().unwrap_or(by_biome),
triangular: json["triangular"].as_bool() == Some(true),
rounds: json["rounds"].as_bool() != Some(false),
+6 -3
View File
@@ -482,13 +482,16 @@ impl Extras {
true
}
/// Written only once something differs from the defaults, so a vanilla
/// setup gains no file.
/// setup gains no file. The settings stores of this file follow these lines
/// (`horizon_hooks::settings`).
pub fn save(&self, dir: &Path) {
let path = dir.join(FILE);
if *self == Self::default() && !path.exists() {
let mut text = self.serialize();
let stores_default = horizon_hooks::settings::engine::append(FILE, &mut text);
if *self == Self::default() && stores_default && !path.exists() {
return;
}
let _ = fs::write(path, self.serialize());
let _ = fs::write(path, text);
}
}
+33 -144
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,10 @@ 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>,
/// Since when Auto keeps HDR on through the display's loss of its report.
auto_off_since: Option<std::time::Instant>,
/// 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 +1544,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 +2993,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 +5356,13 @@ impl Renderer {
hdr_lighting_setting: true,
emission_stats,
output_caps,
output_rejected: None,
auto_off_since: 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 +5531,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 +5540,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 +5644,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.
@@ -7083,18 +7097,6 @@ impl Renderer {
horizon_hdr::set_metadata(&self.device, surface, hdr.declared_peak(), hdr.black);
}
}
/// 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
}
/// This renderer's output as applied, with what its destination offers and reports.
pub fn output(&self) -> &output::EffectiveOutput {
&self.output
@@ -7149,106 +7151,11 @@ impl Renderer {
output::resolve_frame(
settings,
request,
self.output_caps,
self.reading,
self.caps_for(settings),
output_apply::graced(self.auto_off_since, self.reading, &self.output),
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 +7165,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 +15288,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 +15332,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 +15341,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)
}
}
@@ -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);
}
}
}
@@ -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);
}
}
+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,941 @@
//! 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. Auto keeps HDR on through a
//! moment's loss of the display's report ([`auto_grace`]).
use super::{DisplayReading, DisplayWatch, Renderer, hdr, output};
use crate::graphics::HdrMode;
use output::{EffectiveOutput, OutputCapabilities};
use std::time::{Duration, Instant};
/// 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
}
/// How long Auto keeps HDR on once the display stops reporting it. A fixed window that
/// coalesces the report's churn as a display wakes or the window moves to another one; not a
/// measured sleep/wake time.
const AUTO_OFF_DELAY: Duration = Duration::from_millis(500);
/// The display's report `update_output` resolves Auto with at `now`. While Auto presents HDR
/// and the surface still offers that encoding, a report that turns false or unknown starts
/// the timer `since`, once, and HDR stays on at the values last applied until it has held
/// for [`AUTO_OFF_DELAY`]; a positive report before then clears it. Off and On, a changed
/// offer, and entering HDR take `raw` at once.
fn auto_grace(
now: Instant,
mode: HdrMode,
offered: Option<hdr::Output>,
raw: DisplayReading,
applied: &EffectiveOutput,
since: &mut Option<Instant>,
) -> DisplayReading {
let lost = mode == HdrMode::Auto
&& applied.encoding.is_some()
&& offered == applied.encoding
&& raw.hdr != Some(true);
if !lost {
if since.take().is_some() && raw.hdr == Some(true) {
log::info!("Horizon renderer: the display reports HDR again; HDR stays on");
}
} else if since.is_none() {
log::info!(
"Horizon renderer: the display stopped reporting HDR; Auto keeps it on for {} ms",
AUTO_OFF_DELAY.as_millis()
);
*since = Some(now);
} else if grace_over(*since, now) {
*since = None;
}
graced(*since, raw, applied)
}
fn grace_over(since: Option<Instant>, now: Instant) -> bool {
since.is_some_and(|since| now.saturating_duration_since(since) >= AUTO_OFF_DELAY)
}
/// What the display reports as the output takes it: during Auto's grace (`since`), HDR at the
/// luminance `applied` was resolved with; `raw` otherwise.
pub(super) fn graced(
since: Option<Instant>,
raw: DisplayReading,
applied: &EffectiveOutput,
) -> DisplayReading {
match since {
Some(_) => DisplayReading {
luminance: applied.reported,
hdr: Some(true),
},
None => raw,
}
}
/// Whether the output resolves again at `now`: a due poll (`read`, with the surface's
/// `offer`; on Wayland, an event) changed `reading` or `caps`, or Auto's grace `since` is
/// over. Its end reads the display once more first, however long until the next poll, unless
/// a read since the deadline did (the last read is a `DISPLAY_POLL` before the next).
fn poll_display(
watch: Option<&mut DisplayWatch>,
reading: &mut DisplayReading,
caps: &mut OutputCapabilities,
since: Option<Instant>,
now: Instant,
read: impl FnOnce() -> DisplayReading,
offer: impl FnOnce() -> Option<Option<hdr::Output>>,
) -> bool {
let over = grace_over(since, now);
let Some(watch) = watch else {
return over;
};
if let (Some(since), Some(next)) = (since.filter(|_| over), &mut watch.polled)
&& *next < since + AUTO_OFF_DELAY + super::DISPLAY_POLL
{
*next = now;
}
let Some(read) = watch.poll(*reading, now, read) else {
return over;
};
observe(reading, caps, read, offer()) || over
}
/// What `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 offer(surface: Option<&wgpu::Surface>, adapter: &wgpu::Adapter) -> Option<Option<hdr::Output>> {
Some(hdr::output(&surface?.get_capabilities(adapter)))
}
/// 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,
) {
let now = Instant::now();
self.watch_hdr(settings.hdr);
// Auto's grace ends on a fresh read, also where a settings change comes before the
// frame's `display_changed`.
if grace_over(self.auto_off_since, now) {
self.read_display(now);
}
self.hold_hdr_back(settings);
let reading = auto_grace(
now,
settings.hdr,
self.output_caps.hdr,
self.reading,
&self.output,
&mut self.auto_off_since,
);
let candidate = output::resolve_output(
settings,
self.output_caps,
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);
let offer = offer(self.surface.as_deref(), &self.adapter);
self.output_caps.hdr = offer.unwrap_or(self.output_caps.hdr);
self.reading = now;
self.display_fresh = true;
}
}
/// The swapchain's configuration: `config` (what every pipeline draws in), or
/// HDR10 or scRGB while HDR output is on.
pub(super) fn surface_config(&self) -> wgpu::SurfaceConfiguration {
surface_config(&self.config, self.hdr.as_ref().map(|hdr| hdr.output))
}
/// 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) {
let offer = offer(self.surface.as_deref(), &self.adapter);
self.output_caps.hdr = 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. So does the end of Auto's grace (`auto_grace`), with or without a
/// poll or an event then.
pub fn display_changed(&mut self) -> bool {
super::flush_display_log();
if std::mem::take(&mut self.display_fresh) {
return true;
}
self.read_display(Instant::now())
}
/// The display read where a poll is due or Auto's grace is over (`poll_display`).
fn read_display(&mut self, now: Instant) -> bool {
let (surface, adapter) = (self.surface.as_deref(), &self.adapter);
poll_display(
self.display.as_mut(),
&mut self.reading,
&mut self.output_caps,
self.auto_off_since,
now,
|| DisplayReading::read(surface, adapter),
|| offer(surface, adapter),
)
}
}
#[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);
}
/// A window in Auto presenting HDR10 at 400/1000 nits on the layered frame, followed on
/// fake time as the game follows a renderer each frame: `display_changed`, then
/// `update_output` where it says so.
struct Auto {
settings: Graphics,
start: Instant,
watch: DisplayWatch,
reading: DisplayReading,
caps: OutputCapabilities,
applied: EffectiveOutput,
since: Option<Instant>,
swapchain: Swapchain,
/// What the system reports when it is read, and when (ms) it was.
report: DisplayReading,
reads: Vec<u64>,
}
impl Auto {
/// `polled`: the system is read every `DISPLAY_POLL` from 0, else it sends events
/// (`event`), as on Wayland.
fn new(polled: bool) -> Self {
let settings = Graphics {
hdr: HdrMode::Auto,
..Graphics::default()
};
let (caps, reading) = window(Some(Pq), Some(true));
let applied = snapshot(&settings, NO_PACK, caps, reading);
assert!(applied.encoding == Some(Pq) && applied.frame.hud_layer);
let start = Instant::now();
Self {
settings,
start,
watch: DisplayWatch {
#[cfg(target_os = "linux")]
wayland: None,
polled: polled.then_some(start),
},
reading,
caps,
applied,
since: None,
swapchain: Swapchain::default(),
report: reading,
reads: Vec::new(),
}
}
/// A frame at `ms`.
fn frame(&mut self, ms: u64) {
if self.read_display(ms) {
self.update(ms);
}
}
/// `Renderer::read_display` at `ms`.
fn read_display(&mut self, ms: u64) -> bool {
let now = self.start + Duration::from_millis(ms);
let (report, offer, reads) = (self.report, Some(self.caps.hdr), &mut self.reads);
let read = || {
reads.push(ms);
report
};
let (reading, caps) = (&mut self.reading, &mut self.caps);
poll_display(
Some(&mut self.watch),
reading,
caps,
self.since,
now,
read,
|| offer,
)
}
/// Frames from `from` to `to` ms, every 10 ms.
fn frames(&mut self, from: u64, to: u64) {
for ms in (from..=to).step_by(10) {
self.frame(ms);
}
}
/// What the system reports from `ms` on: HDR `hdr`, with no luminance unless it is
/// HDR, at 300/800 nits then.
fn reports(&mut self, hdr: Option<bool>, offer: Option<hdr::Output>) {
self.report = DisplayReading {
luminance: (hdr == Some(true)).then_some(Reported {
paper_white: 300,
peak: 800,
}),
hdr,
};
self.caps.hdr = offer;
}
/// The system's event at `ms`, with what it now `reports`.
fn event(&mut self, ms: u64, hdr: Option<bool>, offer: Option<hdr::Output>) {
self.reports(hdr, offer);
self.reading = self.report;
self.update(ms);
}
/// `update_output` at `ms`, in its order.
fn update(&mut self, ms: u64) {
let now = self.start + Duration::from_millis(ms);
if grace_over(self.since, now) {
self.read_display(ms);
}
let applied = &self.applied;
let reading = auto_grace(
now,
self.settings.hdr,
self.caps.hdr,
self.reading,
applied,
&mut self.since,
);
let next = candidate(&self.settings, self.caps, reading, applied);
self.applied = commit(&mut self.swapchain, applied, next, self.caps, &mut None);
// While HDR stays on, the frame the game asks for (`Renderer::requested_frame`) is
// this one: no new renderer.
let reading = graced(self.since, self.reading, &self.applied);
let frame = output::resolve_frame(
&self.settings,
NO_PACK,
self.caps,
reading,
wgpu::TextureFormat::Bgra8UnormSrgb,
);
assert_eq!(
frame == self.applied.frame,
self.applied.hdr_on(),
"{ms} ms"
);
}
fn presented(&self) -> Vec<Option<hdr::Output>> {
self.swapchain.asked.iter().map(|a| a.0).collect()
}
}
/// The display's report flickers off and back, false or unknown: HDR stays on, nothing
/// is presented again, and the shader `Frame`, what the settings show as active and the
/// nits stay as they were applied, even while the report says nothing of them.
#[test]
fn a_report_that_flickers_off_and_back_swaps_nothing() {
for lost in [Some(false), None] {
let mut auto = Auto::new(false);
let before = auto.applied;
auto.event(0, Some(true), Some(Pq));
auto.frames(0, 90);
let fresh = auto.applied;
assert_eq!(fresh.nits, [300.0, 800.0, 0.0], "a positive report applies");
auto.event(100, lost, Some(Pq));
auto.frames(100, 390);
assert_eq!(auto.applied, fresh, "{lost:?}");
assert!(auto.applied.requests_hdr(HdrMode::Auto) && auto.applied.display().hdr);
auto.event(400, Some(true), Some(Pq));
auto.frames(400, 1500);
assert_eq!((auto.applied, auto.since), (fresh, None), "{lost:?}");
assert!(auto.presented().is_empty(), "{lost:?}");
assert_eq!(auto.applied.frame, before.frame);
}
}
/// A loss held through 600 ms turns HDR off once, at 500 ms, and on an event-driven
/// display with no event then. The same loss reported again doesn't restart the timer.
#[test]
fn a_loss_held_turns_hdr_off_once_at_the_deadline() {
for lost in [Some(false), None] {
let mut auto = Auto::new(false);
auto.event(0, lost, Some(Pq));
auto.frames(0, 190);
auto.event(200, lost, Some(Pq));
auto.frames(200, 490);
assert_eq!(auto.applied.encoding, Some(Pq), "{lost:?}");
auto.frame(500);
assert_eq!(auto.applied.encoding, None, "{lost:?}");
auto.frames(500, 3000);
assert_eq!(auto.presented(), [None], "{lost:?}");
assert_eq!(auto.since, None);
}
}
/// Where the system is polled, a false read by the poll that starts the timer is read
/// again at the deadline, not two seconds after, before it counts: the display came back
/// meanwhile, so HDR stays; where it didn't, HDR turns off then. The two-second cadence
/// stays otherwise.
#[test]
fn a_polled_false_is_read_again_at_the_deadline() {
for back in [true, false] {
let mut auto = Auto::new(true);
auto.frames(0, 1990);
auto.reports(Some(false), Some(Pq));
auto.frame(2000);
assert_eq!(
(auto.applied.encoding, auto.since.is_some()),
(Some(Pq), true)
);
if back {
auto.reports(Some(true), Some(Pq));
}
auto.frames(2010, 2490);
assert_eq!(auto.applied.encoding, Some(Pq), "never before the deadline");
auto.frames(2500, 4990);
assert_eq!(auto.reads, [0, 2000, 2500, 4500], "back {back}");
let after = if back { vec![] } else { vec![None] };
assert_eq!(auto.presented(), after, "back {back}");
}
}
/// A settings change at the deadline, before the frame's poll (a slider, F11), reads the
/// display before the grace ends, and the frame after reads it no more: the display came
/// back meanwhile, so HDR stays; where it didn't, HDR turns off then, once.
#[test]
fn a_settings_change_at_the_deadline_reads_the_display_first() {
for back in [true, false] {
let mut auto = Auto::new(true);
auto.frames(0, 1990);
auto.reports(Some(false), Some(Pq));
auto.frame(2000);
if back {
auto.reports(Some(true), Some(Pq));
}
auto.frames(2010, 2490);
auto.update(2500);
assert_eq!(auto.applied.hdr_on(), back, "back {back}");
auto.frames(2500, 4990);
assert_eq!(auto.reads, [0, 2000, 2500, 4500], "back {back}");
let after = if back { vec![] } else { vec![None] };
assert_eq!(auto.presented(), after, "back {back}");
}
}
/// A positive report clears the timer: a later loss has its own 500 ms.
#[test]
fn a_positive_report_clears_the_timer() {
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.event(300, Some(true), Some(Pq));
auto.event(700, Some(false), Some(Pq));
auto.frames(700, 1190);
assert_eq!(auto.applied.encoding, Some(Pq));
auto.frame(1200);
assert_eq!(auto.presented(), [None]);
}
/// Off acts at once, and On or Off clear the timer: Auto again starts it anew.
#[test]
fn off_and_on_take_no_grace() {
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.settings.hdr = HdrMode::Off;
auto.update(200);
assert_eq!((auto.presented(), auto.since), (vec![None], None));
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.settings.hdr = HdrMode::On;
auto.update(200);
assert_eq!((auto.applied.encoding, auto.since), (Some(Pq), None));
auto.settings.hdr = HdrMode::Auto;
auto.update(300);
auto.frames(300, 790);
assert_eq!(auto.applied.encoding, Some(Pq));
auto.frame(800);
assert_eq!(auto.presented(), [None]);
}
/// The surface no longer offering the encoding presented ends the grace at once: SDR
/// without a report, the other encoding with one.
#[test]
fn an_encoding_no_longer_offered_is_not_kept() {
for offer in [None, Some(ScRgb)] {
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.event(200, Some(false), offer);
assert_eq!((auto.presented(), auto.since), (vec![None], None));
let mut auto = Auto::new(false);
auto.event(100, None, offer);
assert_eq!(auto.presented(), [None], "{offer:?}");
}
let mut auto = Auto::new(false);
auto.event(100, Some(false), Some(Pq));
auto.event(200, Some(true), Some(ScRgb));
assert_eq!(auto.presented(), [Some(ScRgb)]);
}
/// Two windows' timers run apart.
#[test]
fn two_renderers_keep_their_own_timer() {
let (mut a, mut b) = (Auto::new(false), Auto::new(false));
b.start = a.start;
a.event(100, Some(false), Some(Pq));
b.event(300, Some(false), Some(Pq));
a.event(400, Some(true), Some(Pq));
for ms in (300..=900).step_by(10) {
a.frame(ms);
b.frame(ms);
assert_eq!(b.applied.hdr_on(), ms < 800, "{ms} ms");
}
assert_eq!((a.presented(), b.presented()), (vec![], vec![None]));
}
}
@@ -55,6 +55,10 @@ mod sky_disc_blend_tests;
#[cfg(test)]
mod undrawn_terrain_tests;
mod image_formats;
use image_formats::{image_format, storage_formats_of};
#[cfg(test)]
mod full_outputs_tests;
#[cfg(test)]
mod shadow_faces_tests;
@@ -1826,98 +1830,6 @@ fn bindings(
Some((entries, samplers, slots, reads, shadow_reads, images))
}
/// Makes each storage image of `module` in group 0 declare the format its layout entry binds:
/// a `layout(r11f_g11f_b10f)` image whose texture is the `RGBA16F` stand-in (`bindings`).
fn storage_formats_of(module: &mut naga::Module, entries: &[wgpu::BindGroupLayoutEntry]) {
let globals: Vec<_> = module.global_variables.iter().map(|(h, _)| h).collect();
for handle in globals {
let var = &module.global_variables[handle];
let Some(naga::ResourceBinding { group: 0, binding }) = var.binding else {
continue;
};
let naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage { format, access },
} = module.types[var.ty].inner
else {
continue;
};
let bound = entries.iter().find_map(|e| match e.ty {
wgpu::BindingType::StorageTexture { format, .. } if e.binding == binding => {
Some(format)
}
_ => None,
});
if format == naga::StorageFormat::Rg11b10Ufloat
&& bound == Some(wgpu::TextureFormat::Rgba16Float)
{
let ty = module.types.insert(
naga::Type {
name: module.types[var.ty].name.clone(),
inner: naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage {
format: naga::StorageFormat::Rgba16Float,
access,
},
},
},
naga::Span::UNDEFINED,
);
module.global_variables[handle].ty = ty;
}
}
}
/// The wgpu format of an image's format qualifier.
fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
use wgpu::TextureFormat as T;
Some(match qualifier {
"rgba32f" => T::Rgba32Float,
"rgba16f" => T::Rgba16Float,
"rg32f" => T::Rg32Float,
"rg16f" => T::Rg16Float,
"r11f_g11f_b10f" => T::Rg11b10Ufloat,
"r32f" => T::R32Float,
"r16f" => T::R16Float,
"rgba16" => T::Rgba16Unorm,
"rgb10_a2" => T::Rgb10a2Unorm,
"rgba8" => T::Rgba8Unorm,
"rg16" => T::Rg16Unorm,
"rg8" => T::Rg8Unorm,
"r16" => T::R16Unorm,
"r8" => T::R8Unorm,
"rgba16_snorm" => T::Rgba16Snorm,
"rgba8_snorm" => T::Rgba8Snorm,
"rg16_snorm" => T::Rg16Snorm,
"rg8_snorm" => T::Rg8Snorm,
"r16_snorm" => T::R16Snorm,
"r8_snorm" => T::R8Snorm,
"rgba32i" => T::Rgba32Sint,
"rgba16i" => T::Rgba16Sint,
"rgba8i" => T::Rgba8Sint,
"rg32i" => T::Rg32Sint,
"rg16i" => T::Rg16Sint,
"rg8i" => T::Rg8Sint,
"r32i" => T::R32Sint,
"r16i" => T::R16Sint,
"r8i" => T::R8Sint,
"rgba32ui" => T::Rgba32Uint,
"rgba16ui" => T::Rgba16Uint,
"rgb10_a2ui" => T::Rgb10a2Uint,
"rgba8ui" => T::Rgba8Uint,
"rg32ui" => T::Rg32Uint,
"rg16ui" => T::Rg16Uint,
"rg8ui" => T::Rg8Uint,
"r32ui" => T::R32Uint,
"r16ui" => T::R16Uint,
"r8ui" => T::R8Uint,
_ => return None,
})
}
/// The value slot of the pack's custom uniform `name` for a program declaring it as GLSL type
/// `ty`: after the frame's uniforms, in the order of `custom` (each with the type
/// `shaders.properties` gives it). A `float`, `int`, `vec2`, `vec3` or `vec4` custom uniform
@@ -2805,10 +2717,21 @@ impl PackPost {
.shadow
.zip(shadow_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
// A GPU that refuses translucent terrain's program with the boats' water patches gets it
// without them (`Post::shadow_water_plain`), rather than no translucent shadows at all.
let mut plain = post.shadow_water_plain;
let shadow_water = post
.shadow_water
.zip(shadow_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)));
let unpatched = shadow_water.is_none().then(|| plain.take()).flatten();
let unpatched_name = unpatched.as_ref().map(|p| p.name.clone());
let shadow_water = shadow_water.or_else(|| {
unpatched
.zip(shadow_geometry.as_ref())
.and_then(|(p, g)| build(p, Some(g)))
});
let water_unpatched = unpatched_name.filter(|_| shadow_water.is_some());
let (hides_sky, hides_celestial) = (post.sky.is_some(), post.sky_textured.is_some());
let sky = (post.sky.is_some() || post.sky_textured.is_some()).then(|| {
let geometry = PackSky::new(device);
@@ -3365,6 +3288,17 @@ impl PackPost {
let white = [255u8; 4];
let black = [0u8; 4];
let flat_normal = [128u8, 128, 255, 255];
// Said as what it is, not as a program skipped.
if let Some(name) = water_unpatched {
let skipped = format!("{name}: skipped: ");
if let Some(n) = notes.iter_mut().rev().find(|n| n.starts_with(&skipped)) {
*n = format!(
"{name}: translucent terrain without the boats' water patches, as the GPU \
refused it with them ({})",
&n[skipped.len()..]
);
}
}
let jobs = (pack.jobs.as_ref())
.map(std::cell::RefCell::take)
.unwrap_or_default();
@@ -0,0 +1,109 @@
//! A shadow program writing as many vertex outputs as the GPU takes leaves no room for the boats'
//! water patches (`GbuffersInputs::water_patches`, one output more): translucent terrain gets
//! the program without them, not no shadow program. The programs here are original.
use super::destination_tests::Fixture;
use super::*;
use std::io::{Cursor, Write};
fn pack(outputs: u32) -> Post {
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
let mut add = |name: &str, source: &str| {
zip.start_file(
format!("shaders/{name}"),
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.write_all(source.as_bytes()).unwrap();
};
let declared: String = (0..outputs)
.map(|i| format!("varying vec4 v{i};\n"))
.collect();
let written: String = (0..outputs).map(|i| format!("v{i}=vec4({i}.0);")).collect();
let read: String = (0..outputs).map(|i| format!("+v{i}")).collect();
add(
"shadow.vsh",
&format!(
"#version 120\n{declared}void main() {{ gl_Position=vec4(gl_Vertex.xy,0.5,1.0); {written} }}\n"
),
);
add(
"shadow.fsh",
&format!("#version 120\n{declared}void main() {{ gl_FragData[0]=vec4(0.0){read}; }}\n"),
);
add(
"final.vsh",
"#version 120\nvarying vec2 uv;\nvoid main() { uv=gl_MultiTexCoord0.xy; gl_Position=ftransform(); }\n",
);
add(
"final.fsh",
"#version 120\nconst int shadowMapResolution=16;\nuniform sampler2D shadowcolor0;\n\
varying vec2 uv;\nvoid main() { gl_FragColor=texture2D(shadowcolor0,uv); }\n",
);
let pack = horizon_shaders::Pack::from_zip_bytes(
"original-full-outputs",
zip.finish().unwrap().into_inner(),
)
.unwrap();
let mut macros = horizon_shaders::Macros::standard();
let (properties, _) = horizon_shaders::read_properties(&pack, &mut macros);
let inputs = horizon_shaders::GameInputs {
terrain: Some(horizon_shaders::GbuffersInputs {
item_id_declared: false,
far_depth: false,
paged_maps: false,
water_mask: false,
water_patches: false,
declarations: "layout(location=0)in vec3 fixture_position;\n".into(),
body: "hz_Vertex=vec4(fixture_position,1.0);hz_Color=vec4(1.0);\
hz_MultiTexCoord0=vec4(0.0);hz_MultiTexCoord1=vec4(240.0);\
hz_MultiTexCoord2=vec4(240.0);hz_Normal=vec3(0.0,0.0,1.0);\n"
.into(),
attributes: vec![],
after: String::new(),
alpha_test: None,
}),
..Default::default()
};
horizon_shaders::load_post(&pack, "", &properties, &macros, &inputs)
}
#[test]
fn a_shadow_program_with_every_output_drops_the_boats_patches_not_its_water() {
let Some(gpu) = Fixture::new() else { return };
const ATTRS: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![0=>Float32x3];
let layouts = [Some(wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &ATTRS,
})];
let geometry = || {
Some(Geometry {
material_maps: false,
buffers: &layouts,
table: None,
depth_write: true,
atlas: None,
ids: None,
cull: true,
})
};
let outputs = gpu.device.limits().max_inter_stage_shader_variables;
let post = gpu.build_with_terrain(pack(outputs), geometry());
assert!(post.has_shadow(), "{:?}", post.notes);
assert!(post.shadow_water.is_some(), "{:?}", post.notes);
assert!(
post.notes
.iter()
.any(|n| n.contains("without the boats' water patches")),
"{:?}",
post.notes
);
// With room for them, the patches stay and nothing is said.
let post = gpu.build_with_terrain(pack(outputs - 1), geometry());
assert!(post.shadow_water.is_some(), "{:?}", post.notes);
assert!(
!post.notes.iter().any(|n| n.contains("water patches")),
"{:?}",
post.notes
);
}
@@ -0,0 +1,97 @@
//! Image formats: a storage image's format qualifier as wgpu's, and the stand-in a module's
//! storage images declare.
/// Makes each storage image of `module` in group 0 declare the format its layout entry binds:
/// a `layout(r11f_g11f_b10f)` image whose texture is the `RGBA16F` stand-in (`bindings`).
pub(super) fn storage_formats_of(
module: &mut naga::Module,
entries: &[wgpu::BindGroupLayoutEntry],
) {
let globals: Vec<_> = module.global_variables.iter().map(|(h, _)| h).collect();
for handle in globals {
let var = &module.global_variables[handle];
let Some(naga::ResourceBinding { group: 0, binding }) = var.binding else {
continue;
};
let naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage { format, access },
} = module.types[var.ty].inner
else {
continue;
};
let bound = entries.iter().find_map(|e| match e.ty {
wgpu::BindingType::StorageTexture { format, .. } if e.binding == binding => {
Some(format)
}
_ => None,
});
if format == naga::StorageFormat::Rg11b10Ufloat
&& bound == Some(wgpu::TextureFormat::Rgba16Float)
{
let ty = module.types.insert(
naga::Type {
name: module.types[var.ty].name.clone(),
inner: naga::TypeInner::Image {
dim,
arrayed,
class: naga::ImageClass::Storage {
format: naga::StorageFormat::Rgba16Float,
access,
},
},
},
naga::Span::UNDEFINED,
);
module.global_variables[handle].ty = ty;
}
}
}
/// The wgpu format of an image's format qualifier.
pub(super) fn image_format(qualifier: &str) -> Option<wgpu::TextureFormat> {
use wgpu::TextureFormat as T;
Some(match qualifier {
"rgba32f" => T::Rgba32Float,
"rgba16f" => T::Rgba16Float,
"rg32f" => T::Rg32Float,
"rg16f" => T::Rg16Float,
"r11f_g11f_b10f" => T::Rg11b10Ufloat,
"r32f" => T::R32Float,
"r16f" => T::R16Float,
"rgba16" => T::Rgba16Unorm,
"rgb10_a2" => T::Rgb10a2Unorm,
"rgba8" => T::Rgba8Unorm,
"rg16" => T::Rg16Unorm,
"rg8" => T::Rg8Unorm,
"r16" => T::R16Unorm,
"r8" => T::R8Unorm,
"rgba16_snorm" => T::Rgba16Snorm,
"rgba8_snorm" => T::Rgba8Snorm,
"rg16_snorm" => T::Rg16Snorm,
"rg8_snorm" => T::Rg8Snorm,
"r16_snorm" => T::R16Snorm,
"r8_snorm" => T::R8Snorm,
"rgba32i" => T::Rgba32Sint,
"rgba16i" => T::Rgba16Sint,
"rgba8i" => T::Rgba8Sint,
"rg32i" => T::Rg32Sint,
"rg16i" => T::Rg16Sint,
"rg8i" => T::Rg8Sint,
"r32i" => T::R32Sint,
"r16i" => T::R16Sint,
"r8i" => T::R8Sint,
"rgba32ui" => T::Rgba32Uint,
"rgba16ui" => T::Rgba16Uint,
"rgb10_a2ui" => T::Rgb10a2Uint,
"rgba8ui" => T::Rgba8Uint,
"rg32ui" => T::Rg32Uint,
"rg16ui" => T::Rg16Uint,
"rg8ui" => T::Rg8Uint,
"r32ui" => T::R32Uint,
"r16ui" => T::R16Uint,
"r8ui" => T::R8Uint,
_ => return None,
})
}
@@ -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),
Binary file not shown.

After

Width:  |  Height:  |  Size: 90 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 B

@@ -0,0 +1,55 @@
//! 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"),
),
("slot", include_bytes!("jei_recipes/slot.png")),
(
"icons/list_badge",
include_bytes!("jei_recipes/icons/list_badge.png"),
),
(
"interactive_ingredient_tooltip_background",
include_bytes!("jei_recipes/interactive_ingredient_tooltip_background.png"),
),
(
"recipe_preview_background_v2",
include_bytes!("jei_recipes/recipe_preview_background_v2.png"),
),
(
"ingredient_list_slot_background_v2",
include_bytes!("jei_recipes/ingredient_list_slot_background_v2.png"),
),
(
"scrollbar_background_v2",
include_bytes!("jei_recipes/scrollbar_background_v2.png"),
),
(
"scrollbar_marker_v2",
include_bytes!("jei_recipes/scrollbar_marker_v2.png"),
),
];
+3
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,10 +63,12 @@ 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;
pub use horizon_polytone::colors as polytone_colors;
pub use horizon_polytone::creative_tabs as polytone_creative_tabs;
pub use horizon_world::json_folder;
pub mod level_loading;
pub mod light;
+80 -8
View File
@@ -761,6 +761,49 @@ pub struct MeshTables {
colormaps: Vec<Arc<crate::environment::Colormap>>,
water_colormap: Option<u16>,
}
/// Vanilla's four biome colour functions (`BiomeColors`) with what the packs replace them by:
/// `biome_colors` (a colormap or a flat colour), else the colormap a block modifier gives
/// `grass_block`, `oak_leaves` or `leaf_litter`, or a fluid modifier gives water, which Polytone
/// spreads to every block of that function. Each with the tints that read it.
fn biome_functions(
pack_root: &std::path::Path,
modifiers: &[(bool, horizon_polytone::Targets, String)],
) -> Vec<(&'static [Tint], crate::environment::Colormap)> {
use crate::environment::Colormap;
use horizon_polytone::colors::BiomeColor;
const FUNCTIONS: [(&str, &str, &[Tint]); 4] = [
(
"grass",
"minecraft:grass_block",
&[
Tint::Grass,
Tint::GrassBlock,
Tint::TallGrassUpper,
Tint::SugarCane,
],
),
("foliage", "minecraft:oak_leaves", &[Tint::Foliage]),
("dry_foliage", "minecraft:leaf_litter", &[Tint::DryFoliage]),
("water", "minecraft:water", &[Tint::Water]),
];
let colors = horizon_polytone::colors::PackColors::read(pack_root);
FUNCTIONS
.into_iter()
.filter_map(|(function, block, tints)| {
let colormap = match colors.biome_color(function) {
Some(BiomeColor::Flat(rgb)) => Some(Colormap::flat(rgb)),
Some(BiomeColor::Colormap(id)) => Colormap::load(pack_root, &id),
None => modifiers
.iter()
.find(|(fluid, targets, _)| {
*fluid == (function == "water") && targets.matches(block, |_| false)
})
.and_then(|(_, _, id)| Colormap::load(pack_root, id)),
}?;
Some((tints, colormap))
})
.collect()
}
fn stem_color(age: u8) -> u32 {
let a = u32::from(age);
(a * 32) << 16 | (255 - a * 8) << 8 | (a * 4)
@@ -945,7 +988,8 @@ impl MeshTables {
}
/// These tables with the colormaps of the Polytone block and fluid modifiers of the packs
/// merged at `pack_root` (wiki "Colormaps"): a tinted block they target takes its colour
/// from the colormap, and its own tint where the colormap's pixel is transparent.
/// from the colormap, and its own tint where the colormap's pixel is transparent. Vanilla's
/// four biome colour functions take the packs' `biome_colors` first (wiki "Custom Colors").
#[must_use]
pub fn with_polytone_colormaps(
mut self,
@@ -953,7 +997,8 @@ impl MeshTables {
registry: &horizon_world::registry::BlockRegistry,
) -> Self {
let modifiers = crate::environment::polytone_colormap_modifiers(pack_root);
if modifiers.is_empty() {
let biome_functions = biome_functions(pack_root, &modifiers);
if modifiers.is_empty() && biome_functions.is_empty() {
return self;
}
let mut loaded: std::collections::HashMap<String, Option<u16>> = Default::default();
@@ -964,7 +1009,24 @@ impl MeshTables {
Some((colormaps.len() - 1) as u16)
})
};
for (tints, colormap) in biome_functions {
self.colormaps.push(Arc::new(colormap));
let i = Some((self.colormaps.len() - 1) as u16);
if tints == [Tint::Water] {
self.water_colormap = i;
}
for info in &mut self.info {
let own = &info.tints[..usize::from(info.tint_count).min(2)];
if own.iter().any(|t| tints.contains(t)) {
info.colormap = i;
}
}
}
let water_function = self.water_colormap;
for (fluid, targets, id) in &modifiers {
if *fluid && water_function.is_some() {
continue;
}
if *fluid {
if targets.matches("minecraft:water", |_| false) {
self.water_colormap = index(id, &mut self.colormaps);
@@ -1495,9 +1557,9 @@ struct Tints<'a> {
/// by colormap and section-local block.
colormaps: &'a [Arc<crate::environment::Colormap>],
water_colormap: Option<u16>,
sampled: std::collections::HashMap<(u16, [i32; 3]), Option<u32>>,
sampled: crate::fast_hash::FxHashMap<(u16, [i32; 3]), Option<u32>>,
/// Each colormap's own colour by block, before the blend averages them.
cells: std::collections::HashMap<(u16, [i32; 3]), Option<u32>>,
cells: crate::fast_hash::FxHashMap<(u16, [i32; 3]), Option<u32>>,
}
impl<'a> Tints<'a> {
fn new(
@@ -1518,8 +1580,8 @@ impl<'a> Tints<'a> {
uniform: None,
colormaps: &[],
water_colormap: None,
sampled: std::collections::HashMap::new(),
cells: std::collections::HashMap::new(),
sampled: Default::default(),
cells: Default::default(),
}
}
/// With the packs' colormaps of `tables`.
@@ -1531,10 +1593,16 @@ impl<'a> Tints<'a> {
/// Colormap `index`'s colour at block `(x, y, z)` (section-local), `None` where its pixel
/// is transparent.
fn colormap(&mut self, index: u16, x: i32, y: i32, z: i32) -> Option<u32> {
let colormap = self.colormaps.get(usize::from(index))?.clone();
// A colormap that ignores the height, over one biome: one colour per column.
let y = if colormap.column && self.uniform_biome().is_some() {
0
} else {
y
};
if let Some(&known) = self.sampled.get(&(index, [x, y, z])) {
return known;
}
let colormap = self.colormaps.get(usize::from(index))?.clone();
// ClientLevel.calculateBlockTint: the mean of the (2r+1)^2 colours around the block, each
// channel summed and divided. A transparent pixel in the square leaves the block's own.
let r = if colormap.biome_blend { self.radius } else { 0 };
@@ -1573,7 +1641,11 @@ impl<'a> Tints<'a> {
if let Some(&known) = self.cells.get(&(index, at)) {
return known;
}
let id = Self::biome_id(&self.zoom, self.region, self.plains, self.origin, at);
// One biome over the section and its blend margin: no zoom per block.
let id = match self.uniform_biome() {
Some(biome) => biome,
None => Self::biome_id(&self.zoom, self.region, self.plains, self.origin, at),
};
let [cx, cz, base_y] = self.origin;
let block = [cx * 16 + at[0], base_y + at[1], cz * 16 + at[2]];
let mut host = crate::environment::BlockHost::new(block, self.biomes.get(id));
@@ -251,3 +251,117 @@ fn a_biome_colormap_blends_over_a_biome_edge_as_the_vanilla_water_tint_does() {
}
assert!(edge_differs, "the square must cross the edge somewhere");
}
#[test]
fn biome_colors_and_a_grass_block_colormap_replace_their_whole_biome_function() {
let biomes = Biomes::from_jsons(&[(
"minecraft:plains",
serde_json::json!({"effects": {"water_color": 0x3F76E4}}),
)]);
// biome_colors gives water a flat green; a block modifier colours oak leaves with a map,
// which Polytone spreads to the whole foliage function.
let root = pack(&[
(
"assets/mypack/polytone/colors.json",
br#"{"biome_colors": {"water": "0x00ff00"}}"#.to_vec(),
),
(
"assets/mypack/polytone/colormaps/leaves.png",
png_row(&[[0, 0, 255, 255]]),
),
(
"assets/mypack/polytone/block_modifiers/oak.json",
br#"{"targets": ["minecraft:oak_leaves"], "colormap": "mypack:leaves"}"#.to_vec(),
),
]);
let modifiers = crate::environment::polytone_colormap_modifiers(&root);
let functions = biome_functions(&root, &modifiers);
let tints: Vec<_> = functions.iter().map(|(t, _)| *t).collect();
assert_eq!(tints.len(), 2);
assert!(tints[0] == [Tint::Foliage] && tints[1] == [Tint::Water]);
let colormaps: Vec<_> = functions.into_iter().map(|(_, c)| Arc::new(c)).collect();
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, 0)).collect())
.collect();
let region: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz][rx])));
let mut tints = Tints::new(&region, &biomes, BiomeZoom::new(0), 0, [0, 0, 64]);
tints.colormaps = &colormaps;
tints.water_colormap = Some(1);
assert_eq!(tints.water(3, 4, 5), 0x00_FF00);
std::fs::remove_dir_all(root).unwrap();
}
#[test]
fn only_a_colormap_that_ignores_the_height_is_shared_up_a_column() {
let root = pack(&[
(
"assets/mypack/polytone/colormaps/flat.png",
png_row(&[[1, 2, 3, 255]]),
),
(
"assets/mypack/polytone/colormaps/flat.json",
br#"{"x_axis": "biome_id", "y_axis": "r.noise(o.x/60, o.z/60)"}"#.to_vec(),
),
(
"assets/mypack/polytone/colormaps/tall.png",
png_row(&[[1, 2, 3, 255]]),
),
(
"assets/mypack/polytone/colormaps/tall.json",
br#"{"y_axis": "y_level"}"#.to_vec(),
),
]);
let load = |id| crate::environment::Colormap::load(&root, id).unwrap();
assert!(load("mypack:flat").column);
assert!(!load("mypack:tall").column);
std::fs::remove_dir_all(root).unwrap();
}
/// Times a section's colormap tints with the freepack's kind of grass colormap (a biome id
/// column and a noise row) over the default Biome Blend: `cargo test --release ... -- --ignored
/// --nocapture colormap_tint_cost`.
#[test]
#[ignore = "timing, not a check"]
fn colormap_tint_cost() {
const RADIUS: i32 = 2;
const LAYERS: i32 = 16;
let biomes = Biomes::from_jsons(&[(
"minecraft:plains",
serde_json::json!({"effects": {"water_color": 0x3F76E4}}),
)]);
let pixels: Vec<[u8; 4]> = (0..16).map(|i| [i * 16, 200, 64, 255]).collect();
let root = pack(&[
("assets/mypack/polytone/colormaps/grass.png", png_row(&pixels)),
(
"assets/mypack/polytone/colormaps/grass.json",
br#"{"x_axis": "biome_id", "y_axis": "0.5+(r.noise(o.x/60,5+o.z/60)*0.55)", "biome_id_mapper": {"minecraft:plains": 1, "texture_size": 16}}"#
.to_vec(),
),
]);
let colormaps = vec![Arc::new(
crate::environment::Colormap::load(&root, "mypack:grass").unwrap(),
)];
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, 0)).collect())
.collect();
let region: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz][rx])));
let rounds = 50;
let start = std::time::Instant::now();
let mut check = 0u64;
for _ in 0..rounds {
let mut tints = Tints::new(&region, &biomes, BiomeZoom::new(0), RADIUS, [0, 0, 64]);
tints.colormaps = &colormaps;
for y in 0..LAYERS {
for z in 0..16 {
for x in 0..16 {
check += u64::from(tints.colormap(0, x, y, z).unwrap_or(0));
}
}
}
}
let per = start.elapsed() / rounds;
println!("{LAYERS} layers of 256 blended colormap tints: {per:?} (checksum {check})");
std::fs::remove_dir_all(root).unwrap();
}
+13 -63
View File
@@ -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(),
@@ -2582,43 +2583,6 @@ impl OptionsUi {
}
rows
}
/// A native mod's page: its name, its keys as Key Binds rows (the
/// player's choice, else the default), then its buttons.
fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
let Some(m) = s.mods.installed.get(i) else {
return Vec::new();
};
let mut rows = vec![Row::Note(m.title.clone())];
let Some(page) = &m.native else {
return rows;
};
let chosen = s.mods.keys.get(&m.id);
for (k, [name, label, default]) in page.keys.iter().enumerate() {
let key = chosen.and_then(|c| c.get(name)).unwrap_or(default);
let mut message = keybinds::display_name(key);
if self.selected_native_key == Some((i, k)) {
message = Text::literal("> ")
.append(message.with_color(text::WHITE))
.append(" <")
.with_color(text::YELLOW);
}
let caption = button(0.0, 0.0, 150.0, Text::literal(label.clone()), Act::None);
let change = button(0.0, 0.0, 150.0, message, Act::NativeKey(i, k));
rows.push(Row::Buttons(inactive(caption), Some(change)));
}
for (b, label) in page.buttons.iter().enumerate() {
let widget = button(
0.0,
0.0,
310.0,
Text::literal(label.clone()),
Act::NativeButton(i, b),
);
rows.push(Row::Buttons(widget, None));
}
rows
}
fn list_rows(&self, s: &Settings) -> Vec<Row> {
match self.page() {
Page::SplitSetup => Vec::new(),
@@ -4030,7 +3994,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() {
@@ -4272,10 +4236,7 @@ impl OptionsUi {
self.push(Page::NativeMod(i));
events.push(Event::NativeSettings(i));
}
Act::NativeKey(i, k) => {
self.selected_key = Some(NATIVE_KEY);
self.selected_native_key = Some((i, k));
}
Act::NativeKey(i, k) => self.wait_native_key(i, k),
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::ModProject(i) => {
@@ -4632,13 +4593,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();
@@ -4782,16 +4742,7 @@ impl OptionsUi {
return Vec::new();
};
if name == NATIVE_KEY {
let (i, k) = self.selected_native_key.take().unwrap_or_default();
let Some(m) = s.mods.installed.get(i) else {
return Vec::new();
};
let Some([key, ..]) = m.native.as_ref().and_then(|p| p.keys.get(k)) else {
return Vec::new();
};
let keys = s.mods.keys.entry(m.id.clone()).or_default();
keys.insert(key.clone(), value);
return vec![Event::Changed];
return self.bind_native_key(s, value);
}
if !s.extras.set_key(name, value.clone()) && !s.horizon.set_key(name, value.clone()) {
s.keys.set(name, &value);
@@ -5173,10 +5124,7 @@ impl OptionsUi {
self.push(Page::NativeMod(i));
events.push(Event::NativeSettings(i));
}
Act::NativeKey(i, k) => {
self.selected_key = Some(NATIVE_KEY);
self.selected_native_key = Some((i, k));
}
Act::NativeKey(i, k) => self.wait_native_key(i, k),
Act::NativeButton(i, b) => events.push(Event::NativeButton(i, b)),
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::ModProject(i) => {
@@ -8439,3 +8387,5 @@ mod tests {
#[cfg(test)]
#[path = "options_ui_graphics_tests.rs"]
mod graphics_tests;
#[path = "options_ui_pages.rs"]
mod pages;
@@ -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);
}
}
@@ -0,0 +1,62 @@
//! The pages crates add to the options screens: a native mod's settings page (its keys and
//! buttons). Kept out of options_ui.rs, which may not grow (tools/check_file_size.py).
use super::*;
impl OptionsUi {
/// A native mod's page: its name, its keys as Key Binds rows (the
/// player's choice, else the default), then its buttons.
pub(super) fn native_rows(&self, s: &Settings, i: usize) -> Vec<Row> {
let Some(m) = s.mods.installed.get(i) else {
return Vec::new();
};
let mut rows = vec![Row::Note(m.title.clone())];
let Some(page) = &m.native else {
return rows;
};
let chosen = s.mods.keys.get(&m.id);
for (k, [name, label, default]) in page.keys.iter().enumerate() {
let key = chosen.and_then(|c| c.get(name)).unwrap_or(default);
let mut message = keybinds::display_name(key);
if self.selected_native_key == Some((i, k)) {
message = Text::literal("> ")
.append(message.with_color(text::WHITE))
.append(" <")
.with_color(text::YELLOW);
}
let caption = button(0.0, 0.0, 150.0, Text::literal(label.clone()), Act::None);
let change = button(0.0, 0.0, 150.0, message, Act::NativeKey(i, k));
rows.push(Row::Buttons(inactive(caption), Some(change)));
}
for (b, label) in page.buttons.iter().enumerate() {
let widget = button(
0.0,
0.0,
310.0,
Text::literal(label.clone()),
Act::NativeButton(i, b),
);
rows.push(Row::Buttons(widget, None));
}
rows
}
/// A native mod's key waits for the next key or button pressed.
pub(super) fn wait_native_key(&mut self, i: usize, k: usize) {
self.selected_key = Some(NATIVE_KEY);
self.selected_native_key = Some((i, k));
}
/// The key pressed for the native mod's key that waited for one.
pub(super) fn bind_native_key(&mut self, s: &mut Settings, value: String) -> Vec<Event> {
let (i, k) = self.selected_native_key.take().unwrap_or_default();
let Some(m) = s.mods.installed.get(i) else {
return Vec::new();
};
let Some([key, ..]) = m.native.as_ref().and_then(|p| p.keys.get(k)) else {
return Vec::new();
};
let keys = s.mods.keys.entry(m.id.clone()).or_default();
keys.insert(key.clone(), value);
vec![Event::Changed]
}
}
@@ -2,6 +2,8 @@
//! `redstone.<power>`), for drawing only: the pack recolours collars, tropical fish, banners, wool,
//! sign text and redstone wire and dust, while gameplay (crafting, item data, the beacon) keeps
//! vanilla's `DyeColor`. Lock-free: the draw paths and the mesher's workers read it per face.
use std::path::Path;
use std::sync::RwLock;
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use crate::block_entity_render::DYES;
@@ -30,6 +32,96 @@ pub fn set(colors: &PackColors) {
}
}
/// An item modifier's `bar_color` of the `damage` type: a row of colours picked by the stack's
/// damage, the first pixel undamaged, the last at full damage (measured on the real mod).
struct DamageBar {
targets: horizon_polytone::Targets,
row: Vec<u32>,
}
static ITEM_BARS: RwLock<Vec<DamageBar>> = RwLock::new(Vec::new());
/// Reads the packs' item modifiers' durability bar colours under a pack overlay root: an
/// `<item>_bar.png` alone, or a json whose `bar_color` is `damage` with its `<name>_bar.png` (or
/// `<name>.png`) beside it. Colormap ids and inline colormaps aren't read.
pub fn set_item_bars(root: &Path) {
let mut bars = Vec::new();
for namespace in std::fs::read_dir(root.join("assets"))
.into_iter()
.flatten()
.flatten()
{
let base = namespace.path().join("polytone/item_modifiers");
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
} else if let Some(bar) = item_bar(root, &base, &path) {
bars.push(bar);
}
}
}
}
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = bars;
}
fn item_bar(root: &Path, base: &Path, path: &Path) -> Option<DamageBar> {
let name = path.file_name()?.to_str()?;
let row = |png: &Path| {
let (width, _, rgba) = crate::sprites::read_rgba(png).ok()?;
let row: Vec<u32> = rgba[..width as usize * 4]
.as_chunks::<4>()
.0
.iter()
.map(|p| u32::from(p[0]) << 16 | u32::from(p[1]) << 8 | u32::from(p[2]))
.collect();
(!row.is_empty()).then_some(row)
};
if let Some(stem) = name.strip_suffix("_bar.png") {
// A json of the same name owns its png.
if path.with_file_name(format!("{stem}.json")).exists() {
return None;
}
let relative = path.parent()?.strip_prefix(base).ok()?.join(stem);
let namespace = base.parent()?.parent()?.file_name()?.to_str()?;
let id = format!("{namespace}:{}", relative.to_str()?.replace('\\', "/"));
let targets = horizon_polytone::Targets::parse(None, Some(&id)).ok()?;
return Some(DamageBar {
targets,
row: row(path)?,
});
}
let stem = name.strip_suffix(".json")?;
let json: serde_json::Value = horizon_world::json_file::read(path).ok()?;
if json.get("bar_color")?.as_str()? != "damage" {
return None;
}
let png = [format!("{stem}_bar.png"), format!("{stem}.png")]
.into_iter()
.map(|png| path.with_file_name(png))
.find(|png| png.exists())?;
let relative = path.strip_prefix(root).unwrap_or(path).to_string_lossy();
let implicit = horizon_polytone::implicit_id(&relative, "item_modifiers");
let targets =
horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref()).ok()?;
Some(DamageBar {
targets,
row: row(&png)?,
})
}
/// A pack's durability bar colour (RGB) for an item at this damage (0 to 1).
pub fn item_bar_color(item: &str, damage: f32) -> Option<u32> {
let bars = ITEM_BARS.read().unwrap_or_else(|e| e.into_inner());
let bar = bars
.iter()
.rev()
.find(|b| b.targets.matches(item, |_| false))?;
let last = (bar.row.len() - 1) as f32;
Some(bar.row[(damage.clamp(0.0, 1.0) * last).round() as usize])
}
fn read(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
let v = table[i & 15].load(Relaxed);
(v != 0).then_some(v as u32)
@@ -96,4 +188,49 @@ mod tests {
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
std::fs::remove_dir_all(&dir).ok();
}
fn png_row(path: &Path, pixels: &[[u8; 3]]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
let file = std::fs::File::create(path).unwrap();
let mut encoder = png::Encoder::new(file, pixels.len() as u32, 1);
encoder.set_color(png::ColorType::Rgba);
let data: Vec<u8> = pixels
.iter()
.flat_map(|p| [p[0], p[1], p[2], 255])
.collect();
encoder
.write_header()
.unwrap()
.write_image_data(&data)
.unwrap();
}
#[test]
fn a_damage_bar_picks_its_pixel_by_the_stacks_damage() {
let dir = std::env::temp_dir().join(format!("pack-bars-{}", std::process::id()));
let modifiers = dir.join("assets/minecraft/polytone/item_modifiers");
let rgby = [[255, 0, 0], [0, 255, 0], [0, 0, 255], [255, 255, 0]];
png_row(&modifiers.join("diamond_pickaxe_bar.png"), &rgby);
std::fs::write(
modifiers.join("tools.json"),
r#"{"targets": ["minecraft:iron_axe"], "bar_color": "damage"}"#,
)
.unwrap();
png_row(&modifiers.join("tools_bar.png"), &[[1, 2, 3], [4, 5, 6]]);
set_item_bars(&dir);
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
assert_eq!(
item_bar_color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
Some(0xFF0000)
);
assert_eq!(
item_bar_color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
Some(0xFFFF00)
);
assert_eq!(item_bar_color("minecraft:iron_axe", 0.9), Some(0x040506));
assert_eq!(item_bar_color("minecraft:diamond_shovel", 0.5), None);
set_item_bars(&dir.join("none"));
assert_eq!(item_bar_color("minecraft:diamond_pickaxe", 0.5), None);
std::fs::remove_dir_all(&dir).ok();
}
}
@@ -362,6 +362,13 @@ impl Settings {
.and_then(|dir| fs::read_to_string(dir.join(crate::extras::FILE)).ok())
.unwrap_or_default();
let mut extras = crate::extras::Extras::parse(&extras_text);
// The settings stores of the crates above the engine: their lines of this file, or of
// their own (docs/layers.md, "Platform registries").
horizon_hooks::settings::engine::load(&mut |file| match path.parent() {
_ if file == crate::extras::FILE => Some(extras_text.clone()),
Some(dir) => fs::read_to_string(dir.join(file)).ok(),
None => None,
});
// The enabled mods are the game folder's, shared by its players (`--player-dir`).
let mods = path
.parent()
@@ -690,6 +697,7 @@ impl Settings {
let mut extras = self.extras.clone();
extras.emote_key = self.keys.get("key.emote").to_owned();
extras.save(dir);
horizon_hooks::settings::engine::save_own(dir, &[crate::extras::FILE]);
self.mods.save(&crate::logging::shared_dir(dir));
}
}
+10 -45
View File
@@ -549,6 +549,9 @@ pub struct Streaming {
pub mod_forced: HashSet<(i32, i32)>,
/// No work is scheduled while paused (title screen).
pub paused: bool,
/// Finished meshes the renderer has not taken yet (the game's pending
/// uploads): they count against the meshing room as the jobs do.
pub upload_backlog: usize,
/// Dimension flags: sky light, Nether face shading.
pub sky_light: bool,
pub nether_shading: bool,
@@ -1062,7 +1065,9 @@ pub struct WorkerContext {
pub biomes: Arc<crate::biome::Biomes>,
pub light: Arc<LightTables>,
}
mod bounded;
mod relight_now;
use bounded::nearest_first;
impl Streaming {
pub fn new<G: ChunkGenerator>(
generator: Arc<G>,
@@ -1147,6 +1152,7 @@ impl Streaming {
forced: HashSet::new(),
mod_forced: HashSet::new(),
paused: false,
upload_backlog: 0,
sky_light: true,
nether_shading: false,
shader_pack_lighting: false,
@@ -2083,7 +2089,10 @@ impl Streaming {
// jobs went stale long before a worker reached them (#3629). What
// stays dirty is asked for again, from the current centre, as
// capacity frees; `requeue` then has a small queue to keep close.
let room = (self.workers * 32).saturating_sub(self.meshing.len());
// Meshes waiting for upload count too: when uploads fall behind (a
// low frame rate at a wide distance), meshing on held gigabytes of
// them until the system swapped.
let room = (self.workers * 32).saturating_sub(self.meshing.len() + self.upload_backlog);
nearest_first(&mut candidates, room);
for (_, x, z, sections) in candidates.drain(..) {
let Some(region) = Self::region(world, x, z) else {
@@ -3021,19 +3030,6 @@ fn light_region_ready(
}
true
}
/// The nearest `room` of the `candidates`, ordered: the light and mesh
/// stages submit bounded work, chosen from everything that is ready each
/// `update`, so what runs is always the nearest of what is left — chosen
/// again, from the current centre, as capacity frees, the way vanilla's
/// chunk task queues follow the current ticket levels. The selection is
/// O(n); the sort orders only what is submitted.
fn nearest_first<T: Ord>(candidates: &mut Vec<T>, room: usize) {
if candidates.len() > room {
candidates.select_nth_unstable(room);
candidates.truncate(room);
}
candidates.sort_unstable();
}
/// What `keep_unchanged_sections` found changed.
#[derive(Debug, Default, PartialEq)]
struct Resent {
@@ -3586,37 +3582,6 @@ mod tests {
assert_eq!(pops(queue), [None]);
}
/// The light and mesh stages submit bounded work, the nearest first:
/// what exceeds the room stays dirty, to be chosen again — from the
/// current centre — as capacity frees. Submitted whole, the queue once
/// held every ready column's jobs and grew with the render distance
/// (#3629); ties break by position, so the choice is deterministic.
#[test]
fn bounded_submission_takes_the_nearest_first() {
let candidates: Vec<(i64, i32, i32, u32)> = (0..40i32)
.map(|i| {
let (x, z) = (i / 8 - 2, i % 8 - 3);
(
i64::from((x - 3).pow(2) + (z - 2).pow(2)),
x,
z,
1 << (i % 24),
)
})
.collect();
let mut expected = candidates.clone();
expected.sort_unstable();
let mut none = candidates.clone();
nearest_first(&mut none, 0);
assert!(none.is_empty());
let mut nearest = candidates;
nearest_first(&mut nearest, 5);
assert_eq!(nearest, expected[..5]);
let mut past = expected.clone();
nearest_first(&mut past, expected.len() + 10);
assert_eq!(past, expected);
}
/// What the scheduler counts as ready to light: every neighbour a
/// finished column, or a decorated proto chunk past the area — the
/// walk `light_region` makes without its snapshot.
@@ -0,0 +1,52 @@
//! Bounded submission: the light and mesh stages hand the workers only the
//! nearest of what is ready.
/// The nearest `room` of the `candidates`, ordered: the light and mesh
/// stages submit bounded work, chosen from everything that is ready each
/// `update`, so what runs is always the nearest of what is left — chosen
/// again, from the current centre, as capacity frees, the way vanilla's
/// chunk task queues follow the current ticket levels. The selection is
/// O(n); the sort orders only what is submitted.
pub(super) fn nearest_first<T: Ord>(candidates: &mut Vec<T>, room: usize) {
if candidates.len() > room {
candidates.select_nth_unstable(room);
candidates.truncate(room);
}
candidates.sort_unstable();
}
#[cfg(test)]
mod tests {
use super::*;
/// The light and mesh stages submit bounded work, the nearest first:
/// what exceeds the room stays dirty, to be chosen again — from the
/// current centre — as capacity frees. Submitted whole, the queue once
/// held every ready column's jobs and grew with the render distance
/// (#3629); ties break by position, so the choice is deterministic.
#[test]
fn bounded_submission_takes_the_nearest_first() {
let candidates: Vec<(i64, i32, i32, u32)> = (0..40i32)
.map(|i| {
let (x, z) = (i / 8 - 2, i % 8 - 3);
(
i64::from((x - 3).pow(2) + (z - 2).pow(2)),
x,
z,
1 << (i % 24),
)
})
.collect();
let mut expected = candidates.clone();
expected.sort_unstable();
let mut none = candidates.clone();
nearest_first(&mut none, 0);
assert!(none.is_empty());
let mut nearest = candidates;
nearest_first(&mut nearest, 5);
assert_eq!(nearest, expected[..5]);
let mut past = expected.clone();
nearest_first(&mut past, expected.len() + 10);
assert_eq!(past, expected);
}
}
+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
}
+105 -14
View File
@@ -138,6 +138,10 @@ fn scaling(sprite: &str, r: &Rect) -> NineSlice {
| "horizon/jei/button_disabled_v2"
| "horizon/jei/button_highlight_v2" => [6; 4],
"horizon/jei/search_background_v2" => [6; 4],
"horizon/jei/interactive_ingredient_tooltip_background" => [16; 4],
"horizon/jei/recipe_preview_background_v2" => [16; 4],
"horizon/jei/scrollbar_background_v2" => [6; 4],
"horizon/jei/scrollbar_marker_v2" => [2, 2, 2, 1],
"gui/sprites/widget/button"
| "gui/sprites/widget/button_highlighted"
| "gui/sprites/widget/button_disabled" => [3; 4],
@@ -1227,6 +1231,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 +1529,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 +1575,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 +1592,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 +1700,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 +1751,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 +1817,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 +2575,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.",
@@ -1057,5 +1058,8 @@
"horizon.studio.topbar.add_marker": "Add Marker",
"horizon.studio.topbar.clear_keyframes": "🗑 Clear Keyframes",
"horizon.studio.topbar.timeline_hud": "⏱ Timeline",
"horizon.studio.topbar.split_scene": "Split Scene"
"horizon.studio.topbar.split_scene": "Split Scene",
"horizon.recipes.fuel.options": "Hold SHIFT to pin this tooltip, then click an ingredient to view recipes.",
"horizon.recipes.fuel.any": "Any Fuel",
"horizon.recipes.fuel.minecraft": "Minecraft"
}
+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() {
@@ -0,0 +1,25 @@
//! A saved `speed_multiplier` is read by `ValueInput.getFloatOr`, which takes
//! any numeric tag, not only a float.
use horizon_client_lib::{containers::BlockEntity, save::decode_block_entity};
use horizon_world::nbt::Tag;
fn brewing(speed: Tag) -> f32 {
let tag = Tag::compound([
("id", Tag::String("minecraft:brewing_stand".into())),
("x", Tag::Int(0)),
("y", Tag::Int(64)),
("z", Tag::Int(0)),
("speed_multiplier", speed),
]);
let (_, model) = decode_block_entity(&tag).unwrap();
let BlockEntity::Brewing(stand) = model else {
panic!("a brewing stand");
};
stand.speed
}
#[test]
fn an_integer_speed_multiplier_loads() {
assert_eq!(brewing(Tag::Int(2)), 2.0);
assert_eq!(brewing(Tag::Double(1.5f64.to_bits())), 1.5);
}
+7 -9
View File
@@ -20,13 +20,13 @@ pub use horizon_client_render::{
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_palette, pack_screen,
packs, pad_cursor, pad_glyph, par, particles, poi_sections, polytone_colors, polytone_post,
post_chain, potent_sulfur, preedit, providers, pvp, pvp_hud, random_source, screen_draw,
screen_effects, sculk, section_mask, server_terrain, settings, shader_options,
shader_pack_list, signs, skins, social, sound_engine, sound_events, sound_preview, spans,
split_layout, split_map, split_profiles, sprites, streaming, structure_block, tab_list,
terrain_model, test_block, test_instance_block, testgen, title_edition, trims, ui, vault,
weather, widgets, world, worldborder, zip,
packs, pad_cursor, pad_glyph, par, particles, poi_sections, polytone_colors,
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain,
settings, shader_options, shader_pack_list, signs, skins, social, sound_engine, sound_events,
sound_preview, spans, split_layout, split_map, split_profiles, sprites, streaming,
structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
@@ -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;
+152 -4
View File
@@ -1,9 +1,11 @@
//! The mouse handling every survival and container screen shares
//! The input handling every survival and container screen shares
//! (`AbstractContainerScreen.mouseClicked` / `mouseReleased`): which slot id
//! a click targets (-999 outside the window, `hasClickedOutside` with the
//! recipe book's own rule) and which `ContainerInput` it sends. Shift is
//! the event's own modifiers (`InputWithModifiers.hasShiftDown`: the
//! physical Shift keys, never the remapped or toggled sneak key).
//! physical Shift keys, never the remapped or toggled sneak key). The
//! keyboard's hotbar and off-hand keys are the same screen's
//! (`checkHotbarKeyPressed`).
use crate::menu::Input;
@@ -93,14 +95,44 @@ 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)]
#[derive(Clone, Debug)]
pub struct Clicks {
/// `isQuickCrafting` with `quickCraftingButton`.
quick_crafting: Option<Button>,
skip_next_release: bool,
}
impl Default for Clicks {
fn default() -> Self {
Self {
quick_crafting: None,
// AbstractContainerScreen's constructor: `this.skipNextRelease =
// true`. The release of the click that opened the screen (a chest
// right-click opens it on the server's answer) is not a click on
// the slot now under the pointer.
skip_next_release: true,
}
}
}
/// What a release does.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Release {
@@ -133,7 +165,9 @@ impl Clicks {
/// dead on every screen after it.
pub fn cancel_drag(&mut self) {
self.quick_crafting = None;
self.skip_next_release = false;
// A fresh `AbstractContainerScreen` starts with `skipNextRelease`
// true, so the next release without a press is swallowed.
self.skip_next_release = true;
}
/// `mouseClicked` (no double click): the `slotClicked` input it sends on
@@ -206,6 +240,32 @@ impl Clicks {
}
}
/// A key `AbstractContainerScreen.checkHotbarKeyPressed` acts on: one of the
/// nine hotbar slots (`keyHotbarSlots`) or the off hand (`keySwapOffhand`).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum HotbarKey {
/// `key.hotbar.N`, in `0..9`.
Slot(usize),
/// `key.swapOffhand`, the menu's button 40.
Offhand,
}
/// What `AbstractContainerScreen.checkHotbarKeyPressed` answers: the menu
/// button of the SWAP click, or `None` when the screen refuses it. The whole
/// method is gated on `this.menu.getCarried().isEmpty() && this.hoveredSlot
/// != null`, so a carried stack sends nothing; without that guard the
/// server's SWAP would move the hotbar slot into the slot under the cursor
/// while the stack stays on the cursor.
pub fn hotbar_swap(hovered: Option<HotbarKey>, carried_empty: bool) -> Option<i32> {
if !carried_empty {
return None;
}
Some(match hovered? {
HotbarKey::Slot(slot) => i32::try_from(slot).ok()?,
HotbarKey::Offhand => 40,
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -381,4 +441,92 @@ mod tests {
Some(Input::Pickup)
);
}
/// `AbstractContainerScreen`'s constructor sets `skipNextRelease = true`:
/// a release with no press before it on this screen clicks nothing. This
/// is the release of the click that opened the screen (the server's
/// answer to a chest's right-click comes after the button went up), over
/// the slot now under the pointer. A carried stack is what makes the
/// release click, so that is the hand it is tested with.
#[test]
fn a_release_before_any_press_clicks_nothing() {
let slot = Target::<usize>::Slot(0);
let mut clicks = Clicks::default();
assert_eq!(
clicks.release(&slot, Button::Left, false, false, false, false),
Release::Nothing
);
// A press and release are a normal click again.
assert_eq!(clicks.press(&slot, Button::Left, false, false, false), None);
assert_eq!(
clicks.release(&slot, Button::Left, false, false, false, false),
Release::Click(Input::Pickup)
);
}
/// A screen that takes the pointer over starts as a fresh
/// `AbstractContainerScreen` does: `cancel_drag` drops the old drag and
/// leaves `skipNextRelease` true, so the opening click's release (a
/// carried stack is what would click) does nothing.
#[test]
fn a_screen_that_takes_the_pointer_over_swallows_its_first_release() {
let slot = Target::<usize>::Slot(3);
let mut clicks = Clicks::default();
// The old screen's press with a stack carried left a drag behind.
clicks.press(&slot, Button::Left, false, false, false);
clicks.cancel_drag();
assert_eq!(
clicks.release(&slot, Button::Left, false, false, false, false),
Release::Nothing
);
}
/// `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)));
}
/// `AbstractContainerScreen.checkHotbarKeyPressed`:
/// `this.menu.getCarried().isEmpty() && this.hoveredSlot != null` gates
/// the off hand and the nine hotbar slots alike. A stack on the cursor
/// refuses the key (the menu's SWAP does not look at the carried stack
/// and would move the hotbar slot into the hovered one); so does no
/// hovered slot.
#[test]
fn a_carried_stack_refuses_the_container_hotbar_keys() {
for carried_empty in [false, true] {
for slot in 0..9 {
let key = HotbarKey::Slot(slot);
assert_eq!(
hotbar_swap(Some(key), carried_empty),
carried_empty.then_some(slot as i32)
);
}
assert_eq!(
hotbar_swap(Some(HotbarKey::Offhand), carried_empty),
carried_empty.then_some(40)
);
}
assert_eq!(hotbar_swap(None, true), None);
assert_eq!(hotbar_swap(None, false), None);
}
}
+73 -2
View File
@@ -26,6 +26,8 @@ pub struct Tab {
/// The page of tabs it is on: vanilla's are 0, mods' after, as NeoForge
/// pages them.
pub page: u32,
/// A Polytone creative tab modifier's `name`: its text and colour, over the tab's own.
pub name: Option<(String, Option<u32>)>,
}
impl Tab {
/// The tab without its stacks.
@@ -41,6 +43,7 @@ impl Tab {
inventory: self.inventory,
hotbar: self.hotbar,
page: self.page,
name: self.name.clone(),
}
}
}
@@ -97,6 +100,29 @@ pub struct Creative {
/// of every search item, built on the first search and kept until the
/// translations, item list or enchantment registry change.
lines_cache: Mutex<Option<(u64, Arc<[Vec<String>]>)>>,
/// The packs' Polytone creative tab modifiers, applied by `rebuild`.
pack: Vec<crate::polytone_creative_tabs::TabModifier>,
}
/// A text component's plain text and its colour (a name, or `#rrggbb`).
fn component_text(json: &Value) -> (String, Option<u32>) {
match json {
Value::String(s) => (s.clone(), None),
Value::Object(o) => {
let text = o
.get("text")
.or_else(|| o.get("translate"))
.and_then(Value::as_str)
.unwrap_or("")
.to_owned();
let color = o.get("color").and_then(Value::as_str).and_then(|c| {
c.strip_prefix('#')
.and_then(|hex| u32::from_str_radix(hex, 16).ok())
.or_else(|| crate::text::named_color(c))
});
(text, color)
}
_ => (String::new(), None),
}
}
/// The grid's stacks for one tab, search text and language.
struct Shown {
@@ -300,6 +326,7 @@ impl Creative {
inventory: kind == "INVENTORY",
hotbar: kind == "HOTBAR",
page: t["page"].as_u64().unwrap_or(0) as u32,
name: None,
},
category: kind == "CATEGORY",
operator,
@@ -340,6 +367,7 @@ impl Creative {
inventory: kind == "INVENTORY",
hotbar: false,
page: 0,
name: None,
},
category: false,
});
@@ -380,6 +408,7 @@ impl Creative {
permissions: false,
shown: Mutex::new(None),
lines_cache: Mutex::new(None),
pack: Vec::new(),
};
creative.rebuild();
creative
@@ -399,10 +428,12 @@ impl Creative {
.filter(|(_, o)| keep(**o))
.map(|(s, _)| s.clone())
.collect();
(!e.category || !items.is_empty()).then(|| Tab {
let mut tab = Tab {
items,
..e.tab.header()
})
};
self.apply_pack(&mut tab);
(!e.category || !tab.items.is_empty()).then_some(tab)
})
.collect();
*self.lines_cache.lock().unwrap_or_else(|e| e.into_inner()) = None;
@@ -414,6 +445,44 @@ impl Creative {
.unzip();
self.forget_items();
}
/// The packs' Polytone creative tab modifiers (wiki "Creative Tab Modifiers"): the tabs are
/// built again with them.
pub fn set_pack_modifiers(&mut self, pack: Vec<crate::polytone_creative_tabs::TabModifier>) {
self.pack = pack;
self.rebuild();
self.selected = self.selected.min(self.tabs.len().saturating_sub(1));
}
/// A tab after the modifiers aimed at it: its items, icon and name.
fn apply_pack(&self, tab: &mut Tab) {
let aimed = self
.pack
.iter()
.filter(|m| m.targets.matches(&tab.id, |_| false));
for m in aimed {
let in_tag = |item: &str, tag: &str| {
let (namespace, path) = split_id(tag);
self.exported_search.iter().any(|(s, e, _)| {
*s.item == *item && e.tags.iter().any(|t| t.0 == namespace && t.1 == path)
})
};
let mut ids: Vec<String> = tab.items.iter().map(|s| s.item.to_string()).collect();
m.apply(&mut ids, &in_tag);
let mut old = std::mem::take(&mut tab.items);
tab.items = ids
.into_iter()
.map(|id| match old.iter().position(|s| *s.item == *id) {
Some(i) => old.remove(i),
None => ItemStack::new(&id, 1),
})
.collect();
if let Some(icon) = &m.icon {
tab.icon = icon.clone();
}
if let Some(name) = &m.name {
tab.name = Some(component_text(name));
}
}
}
/// `CreativeModeInventoryScreen.tryRefreshInvalidatedTabs`, which its
/// constructor and `containerTick` run with `hasPermissions`
/// (`Player.canUseGameMasterBlocks` and the Operator Items Tab option):
@@ -483,6 +552,7 @@ impl Creative {
inventory: false,
hotbar: false,
page: 0,
name: None,
});
}
&self.tabs[self.selected.min(self.tabs.len() - 1)]
@@ -1678,6 +1748,7 @@ mod tests {
inventory: false,
hotbar: false,
page: 0,
name: None,
};
// Top row, second column: the button spans x 27..53, y -28..4.
let t = tab(true, 1);
+11 -8
View File
@@ -4,7 +4,8 @@
//! length u32 LE + JSON). The game only calls [`set`] each frame: a change wakes the thread,
//! which connects when Discord runs, retries every 15 s when it doesn't, keeps under Discord's
//! update limit and clears the activity on quit. Nothing is sent with the setting off,
//! or with `HORIZON_NO_DISCORD=1`.
//! or with `HORIZON_NO_DISCORD=1`. It uses nothing else of the game (docs/layers.md: Horizon layer):
//! `game/discord.rs` says what the player does.
use std::collections::VecDeque;
use std::io::{self, Read, Write};
use std::path::PathBuf;
@@ -14,7 +15,7 @@ use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use serde_json::{Value, json};
use crate::command::args::GameMode;
use horizon_command::args::GameMode;
/// The "Horizon" application on Discord's developer portal (public: it names the game). Its
/// Rich Presence assets: `horizon`, the logo.
@@ -51,9 +52,11 @@ pub enum Play {
Singleplayer,
/// A server or someone else's LAN world. Its address and name are never shown.
Multiplayer,
/// This world is open to LAN or friends; `players` counts the host.
/// This world is open to LAN or friends; `players` counts the host, `max` is how many
/// may join.
Hosting {
players: usize,
max: usize,
},
}
@@ -253,7 +256,7 @@ fn activity(status: Status, since: Option<u64>) -> Option<Value> {
let (details, party) = match play {
Play::Singleplayer => ("Singleplayer", None),
Play::Multiplayer => ("Multiplayer", None),
Play::Hosting { players } => ("Hosting a world", Some(players)),
Play::Hosting { players, max } => ("Hosting a world", Some((players, max))),
};
(details, Some(place), party)
}
@@ -265,8 +268,7 @@ fn activity(status: Status, since: Option<u64>) -> Option<Value> {
if let Some(start) = since {
a["timestamps"] = json!({"start": start});
}
if let Some(players) = party {
let max = crate::lan::MAX_PLAYERS;
if let Some((players, max)) = party {
a["party"] = json!({
"id": format!("horizon-{}", std::process::id()),
"size": [players.clamp(1, max), max],
@@ -531,7 +533,7 @@ mod tests {
assert_eq!(multi["details"], "Multiplayer");
assert_eq!(multi["state"], "In the End");
let host = world(Play::Hosting { players: 3 }, Dimension::Overworld);
let host = world(Play::Hosting { players: 3, max: 8 }, Dimension::Overworld);
let host = activity(host, Some(5)).unwrap();
assert_eq!(host["details"], "Hosting a world");
assert_eq!(host["state"], "In the Overworld");
@@ -549,7 +551,8 @@ mod tests {
assert_eq!(w.since, Some(20));
// Another dimension, opening to LAN: the same visit.
assert!(w.update(world(Play::Singleplayer, Dimension::Nether), 30));
assert!(w.update(world(Play::Hosting { players: 1 }, Dimension::Nether), 40));
let hosting = world(Play::Hosting { players: 1, max: 8 }, Dimension::Nether);
assert!(w.update(hosting, 40));
assert_eq!(w.since, Some(20));
assert!(w.update(Status::Menus, 50));
assert_eq!(w.since, None);
@@ -115,6 +115,7 @@ pub fn update_entity_fluids(
0.0023333333333333335
},
);
let _ = interaction.apply_mod_currents(&mut scene);
(water_height, lava_height, eye_in_water)
}
impl ItemEntity {
+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);
}
}
}
@@ -117,4 +117,65 @@ impl Game<'_> {
self.particles.push(particle);
});
}
pub(super) fn particle_sprite(
&self,
state: BlockStateId,
pos: [i32; 3],
) -> Option<(String, u32)> {
let sprite = Self::block_particle_sprite(&self.pack, state)?;
let chunks = &self.world.chunks;
let (cx, cz) = (pos[0] >> 4, pos[2] >> 4);
let region: [[Option<&horizon_client::chunk::Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
chunks
.get(&(cx + dx as i32 - 1, cz + dz as i32 - 1))
.map(|c| &**c)
})
});
let tint = self
.pack
.context
.tables
.terrain_particle_tint(
state,
pos,
&region,
world_biomes!(self),
self.streaming.biome_zoom,
self.streaming.biome_blend,
)
.unwrap_or(0xFFFFFF);
Some((sprite, tint))
}
/// ClientLevel.addBreakingParticles: one crumb on the face being mined.
pub(super) fn crack_particle(&mut self, pos: [i32; 3], face: [i32; 3], state: BlockStateId) {
if self.settings.extras.hide_block_particles {
return;
}
let Some((sprite, tint)) = self.particle_sprite(state, pos) else {
return;
};
let boxes = self.particle_shape(pos, state);
// Keep client effects separate from the authoritative world RNG.
let r = self.particles.random();
let Some(p) = horizon_client::particles::breaking_position(r, pos, face, &boxes) else {
return;
};
let mut particle = Particle::terrain(r, p, [0.0; 3], sprite, tint);
particle.set_power(0.2);
particle.scale(0.6);
self.particles.push(particle);
}
/// `BlockTransformer.TransformParticle.send`: its level event.
pub(super) fn transform_particle(&mut self, particle: &str, pos: [i32; 3]) {
match particle {
"scrape" => self.level_event_at(3005, pos, 0),
"wax_on" => self.level_event_at(3003, pos, 0),
"wax_off" => self.level_event_at(3004, pos, 0),
_ => {}
}
}
}
@@ -286,4 +286,77 @@ impl Game<'_> {
laps.lap("tb_pistons");
Ok(sent)
}
/// Track which chunks are ticking (Ticks needs chunk load/unload).
pub(super) fn sync_tick_chunks(&mut self) {
if !self.tick_chunks.synchronize(&self.world.chunks) {
return;
}
// Move only the added keys out so registration can borrow all of Game.
let added = self.tick_chunks.take_added();
for &key in &added {
self.register_chunk_ticks(key);
}
for &key in self.tick_chunks.removed() {
self.random_tick_sections.remove(&key);
self.portals.sections.remove(&key);
self.block_ticks
.remove_chunk(horizon_core::position::ChunkPos::new(key.0, key.1));
self.block_ticks
.comparator_outputs
.retain(|p, _| (p.x >> 4, p.z >> 4) != key);
self.hoppers.retain(|p, _| (p.0 >> 4, p.2 >> 4) != key);
// Its moving pistons were saved with it and load back with it.
self.block_ticks
.moving_pistons
.retain(|(p, _)| (p.x >> 4, p.z >> 4) != key);
}
self.tick_chunks.recycle_added(added);
}
/// `ServerLevel.tick`: moving block entities push after entity travel.
pub(super) fn tick_moving_pistons(&mut self) -> Result<()> {
self.with_level(|b, level, ticks| b.tick_block_entities(level, ticks));
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
self.push_piston_entities(pushes);
self.process_level_events()?;
self.sync_moving_pistons();
Ok(())
}
/// `ServerLevel.tick`'s scheduled block then fluid ticks, one at a time:
/// the side effects of each (a shrieker summoning a warden, drops, game
/// events) happen before the next tick runs, as they do inline in vanilla.
pub(super) fn tick_scheduled(&mut self) -> Result<()> {
for fluids in [false, true] {
let mut laps = horizon_client::spans::laps();
let name = if fluids { "tb_fluids" } else { "tb_scheduled" };
if !self.with_level(|b, level, ticks| b.scheduled_begin(level, ticks, fluids)) {
laps.lap(name);
continue;
}
loop {
// Due ticks run against one level while each leaves nothing for
// the game to apply (level events, spilled items or orbs): the
// next one would see the same level built again.
let more = self.with_level(|b, level, ticks| {
loop {
let more = b.scheduled_step(level, ticks, fluids);
if !more || !level.events.is_empty() || !level.spill.is_empty() {
break more;
}
}
});
if !self.level_events.is_empty() {
self.process_level_events()?;
}
if !more {
break;
}
}
self.with_level(|b, _, ticks| b.scheduled_finish(ticks, fluids));
laps.lap(name);
}
Ok(())
}
}
+49
View File
@@ -0,0 +1,49 @@
//! A bucket's aim and target: `getPlayerPOVHitResult(..., SOURCE_ONLY)` for an empty bucket (the block outline or a source fluid, whichever is nearer) and `BucketItem.use`'s place position.
use super::*;
impl Game<'_> {
/// `getPlayerPOVHitResult(level, player, SOURCE_ONLY)` for an empty
/// bucket: the block outline or a source fluid, whichever is nearer. A
/// waterlogged block is a block hit (its outline is the block's, its
/// fluid a source); flowing fluid has no shape to pick.
pub(super) fn bucket_hit(&self) -> Option<Hit> {
let origin = self.aim().map_or([0.0; 3], |(origin, _)| origin);
let distance = |p: [f64; 3]| {
(p[0] - origin[0]).powi(2) + (p[1] - origin[1]).powi(2) + (p[2] - origin[2]).powi(2)
};
match (self.pick(), self.pick_fluid()) {
(Some(block), Some(fluid)) => Some(if distance(block.point) <= distance(fluid.point) {
block
} else {
fluid
}),
(block, fluid) => block.or(fluid),
}
}
/// `BucketItem.use`'s place position: a water bucket fills the clicked
/// block itself when that block can hold it (`canBlockContainFluid`: a
/// waterloggable stair, slab, fence, ..., but not a double slab, kelp or
/// seagrass); every other bucket empties into the block the clicked face
/// leads to. `SolidBucketItem` places like a block item, into a
/// replaceable clicked block.
pub(super) fn bucket_target(&self, content: BucketContent, hit: &Hit) -> [i32; 3] {
let clicked = self.world.block(hit.pos[0], hit.pos[1], hit.pos[2]);
let behaviours = self.res.behaviours();
if content == BucketContent::Powder {
if self.res.facts().get(clicked).replaceable {
return hit.pos;
}
} else if content.is_water()
&& behaviours.is_liquid_container(clicked)
&& behaviours.can_place_liquid(
clicked,
horizon_world::fluid::FluidKind::Water,
self.creative,
)
{
return hit.pos;
}
hit.beside()
}
}
@@ -1389,9 +1389,6 @@ impl Game<'_> {
}
}
self.set_mode(mode);
if self.screen == Screen::Inventory && (self.creative || self.spectator()) {
self.screen = Screen::Playing;
}
true
}
fn cmd_gamemode(&mut self, ctx: &Context) -> R<i32> {
@@ -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};
+20 -11
View File
@@ -7,6 +7,8 @@
//! service once a run, `OwnerCheck`): anyone else sees a vanilla game and the game asks nothing
//! about other players, until the player chooses otherwise in the settings.
pub(super) mod emote_wheel;
pub(super) mod emotes;
mod fx;
use super::Game;
@@ -23,6 +25,9 @@ use std::sync::Arc;
use std::sync::mpsc::{Receiver, channel};
use std::time::{Duration, Instant};
/// Horizon's mark before a Horizon player's name, over their name tag and in the player list.
pub(super) const MARK: horizon_client::tab_list::NameMark = &horizon_cosmetics::mark::MARK;
/// An item as the renderer takes it.
struct Baked {
item: Arc<Item>,
@@ -72,6 +77,10 @@ pub(super) struct Cosmetics {
emote_out: EmoteOut,
/// Companions, trails and auras.
fx: RefCell<Fx>,
/// Emotes: those the wheel offers and the one playing (`cosmetics/emotes.rs`).
emotes: emotes::Emotes,
/// The emote wheel (`cosmetics/emote_wheel.rs`).
wheel: emote_wheel::Wheel,
}
/// The emote the player plays, told to the service: the last wish sent (`Some(None)`: stopped),
@@ -518,7 +527,7 @@ impl Game<'_> {
}
}
// My emote: only while I show myself as a Horizon player (playing one says so).
let playing = self.emotes.sending();
let playing = c.emotes.sending();
let looping = playing.as_ref().is_some_and(|(_, looping)| *looping);
let wish = playing.map(|(id, _)| id).filter(|_| extras.horizon_listed);
let o = &mut c.emote_out;
@@ -577,14 +586,15 @@ impl Game<'_> {
for m in self.mobs.iter_mut() {
let age = m.age as f32;
if &*m.kind.id == "minecraft:mannequin"
&& m.emote.as_ref().is_none_or(|p| p.at(age).is_none())
&& emotes::playing(m).is_none_or(|p| p.at(age).is_none())
{
m.emote = Some(horizon_cosmetics::Playing {
let playing = horizon_cosmetics::Playing {
item: first.clone(),
// Out of step, so a crowd doesn't move as one.
start: age - (m.id % 40) as f32,
stop: None,
});
};
emotes::set_playing(m, Some(playing));
}
}
return;
@@ -595,7 +605,7 @@ impl Game<'_> {
if !shown {
feed.ask([]);
for m in self.mobs.iter_mut() {
m.emote = None;
emotes::set_playing(m, None);
}
return;
}
@@ -622,7 +632,8 @@ impl Game<'_> {
let (dx, dz) = (m.position.x - m.previous.x, m.position.z - m.previous.z);
dx * dx + dz * dz > 1e-4
};
match (seen, &mut m.emote) {
let mut next = emotes::playing(m).cloned();
match (seen, &mut next) {
(Some(seen), Some(p)) if Arc::ptr_eq(&p.item, &seen.item) => {
if moved && p.stop.is_none() {
p.stop = Some(age);
@@ -639,9 +650,7 @@ impl Game<'_> {
(_, Some(p)) if p.stop.is_none() => p.stop = Some(age),
_ => {}
}
if m.emote.as_ref().is_some_and(|p| p.at(age).is_none()) {
m.emote = None;
}
emotes::set_playing(m, next.filter(|p| p.at(age).is_some()));
}
}
@@ -649,7 +658,7 @@ impl Game<'_> {
/// every 10 minutes (a folder's emotes need no asking; an offline account has none).
pub(super) fn refresh_owned_emotes(&mut self) {
const AGAIN: Duration = Duration::from_secs(600);
let e = &mut self.emotes;
let e = &mut self.cosmetics.emotes;
if self.cosmetics.local
|| !enabled()
|| horizon_client::auth::current().is_none()
@@ -790,7 +799,7 @@ impl Game<'_> {
let worn = self.worn(m, own);
// An emote's props: its time and weight while it plays.
let ticks = m.age as f32 + alpha;
let emote = m.emote.as_ref().and_then(|p| {
let emote = emotes::playing(m).and_then(|p| {
let (t, w) = p.at(ticks)?;
(p.item.vertices > 0).then_some((&p.item, t, w))
});
@@ -3,14 +3,14 @@
//! number; Escape or the key again closes it. The game doesn't pause and nothing is sent to the
//! game server.
use super::{Game, Screen};
use crate::game::{Game, Screen};
use horizon_client::{hud::HudBatch, text::Text};
use std::time::{Duration, Instant};
use winit::event::{ElementState, MouseButton, WindowEvent};
use winit::keyboard::{KeyCode, PhysicalKey};
/// Emotes on the wheel.
pub(super) const SLOTS: usize = 8;
pub(in crate::game) const SLOTS: usize = 8;
/// The ring, in GUI pixels from the middle; inside `INNER` nothing is hovered.
const INNER: f32 = 30.0;
const OUTER: f32 = 92.0;
@@ -19,14 +19,14 @@ const OUTER: f32 = 92.0;
const HOLD: Duration = Duration::from_millis(250);
#[derive(Default)]
pub(super) struct Wheel {
pub(in crate::game) struct Wheel {
opened: Option<Instant>,
hovered: Option<usize>,
}
/// The slice under a point `(dx, dy)` from the middle (GUI pixels, y down) of a wheel of `n`:
/// slice 0 is straight up, then clockwise. None in the dead middle or past the ring.
pub(super) fn slice_at(dx: f32, dy: f32, n: usize) -> Option<usize> {
pub(in crate::game) fn slice_at(dx: f32, dy: f32, n: usize) -> Option<usize> {
let r = dx.hypot(dy);
if n == 0 || r < INNER || r > OUTER * 1.6 {
return None;
@@ -44,9 +44,9 @@ fn at(cx: f32, cy: f32, radius: f32, angle: f32) -> [f32; 2] {
impl Game<'_> {
/// Opens the wheel from play.
pub(super) fn open_emote_wheel(&mut self) {
pub(in crate::game) fn open_emote_wheel(&mut self) {
self.refresh_owned_emotes();
self.emote_wheel = Wheel {
self.cosmetics.wheel = Wheel {
opened: Some(Instant::now()),
hovered: None,
};
@@ -59,8 +59,8 @@ impl Game<'_> {
}
/// Closes the wheel; `play`: the slice to play first.
pub(super) fn close_emote_wheel(&mut self, play: Option<usize>) {
self.emote_wheel = Wheel::default();
pub(in crate::game) fn close_emote_wheel(&mut self, play: Option<usize>) {
self.cosmetics.wheel = Wheel::default();
self.screen = Screen::Playing;
self.capture(true);
let id = play.and_then(|i| self.owned_emotes().get(i).map(|e| e.id.clone()));
@@ -70,19 +70,20 @@ impl Game<'_> {
}
/// The emote key let go: a held press plays what is hovered, a tap keeps the wheel open.
pub(super) fn emote_wheel_released(&mut self) {
pub(in crate::game) fn emote_wheel_released(&mut self) {
let held = self
.emote_wheel
.cosmetics
.wheel
.opened
.is_some_and(|at| at.elapsed() >= HOLD);
if held || self.emote_wheel.hovered.is_some() {
self.close_emote_wheel(self.emote_wheel.hovered);
if held || self.cosmetics.wheel.hovered.is_some() {
self.close_emote_wheel(self.cosmetics.wheel.hovered);
}
}
/// A window event while the wheel is open: the emote key let go, or a left click. True when
/// the wheel took it.
pub(super) fn emote_wheel_event(&mut self, event: &WindowEvent) -> bool {
pub(in crate::game) fn emote_wheel_event(&mut self, event: &WindowEvent) -> bool {
match event {
WindowEvent::KeyboardInput { event, .. }
if event.state == ElementState::Released
@@ -105,11 +106,11 @@ impl Game<'_> {
}
/// A click on the wheel: plays the hovered emote, or closes.
pub(super) fn emote_wheel_click(&mut self) {
self.close_emote_wheel(self.emote_wheel.hovered);
pub(in crate::game) fn emote_wheel_click(&mut self) {
self.close_emote_wheel(self.cosmetics.wheel.hovered);
}
pub(super) fn draw_emote_wheel(&mut self, batch: &mut HudBatch) {
pub(in crate::game) fn draw_emote_wheel(&mut self, batch: &mut HudBatch) {
horizon_client::widgets::begin_screen(|| {
horizon_client::widgets::ScreenInfo::new("emote_wheel", "horizon.emote_wheel.title")
});
@@ -125,8 +126,8 @@ impl Game<'_> {
let n = names.len();
// The cursor is in GUI pixels, as the batch.
let (mx, my) = self.cursor;
self.emote_wheel.hovered = slice_at(mx - cx, my - cy, n);
let hovered = self.emote_wheel.hovered;
self.cosmetics.wheel.hovered = slice_at(mx - cx, my - cy, n);
let hovered = self.cosmetics.wheel.hovered;
let atlas = &self.gui.atlas;
// The ring: each slice a fan of quads, the hovered one brighter.
let slices = n.max(1);
@@ -4,8 +4,10 @@
//! sleeping, gliding or sneaking cancels one; the camera pulls back to the front view while it
//! plays (from first person only) and comes back when it ends.
use super::Game;
use super::perspective::CameraType;
use crate::game::Game;
use crate::game::perspective::CameraType;
use horizon_client::mobs::Mob;
use horizon_client::mobs::anim::BodyPose;
use horizon_cosmetics::account::OwnedEmote;
use horizon_cosmetics::{Item, Playing, Slot};
use std::sync::Arc;
@@ -14,14 +16,15 @@ use std::sync::Arc;
const DRIFT: f64 = 0.01;
#[derive(Default)]
pub(super) struct Emotes {
pub(in crate::game) struct Emotes {
/// The emotes the wheel offers, in order.
pub(super) owned: Vec<Arc<Item>>,
pub(in crate::game) owned: Vec<Arc<Item>>,
/// Their names by locale, by id (the service's), when known.
pub(super) names: std::collections::HashMap<String, std::collections::BTreeMap<String, String>>,
pub(in crate::game) names:
std::collections::HashMap<String, std::collections::BTreeMap<String, String>>,
/// The account's emotes on their way from the service, and when they last came.
pub(super) loading: Option<std::sync::mpsc::Receiver<Result<Vec<OwnedEmote>, String>>>,
pub(super) loaded: Option<std::time::Instant>,
pub(in crate::game) loading: Option<std::sync::mpsc::Receiver<Result<Vec<OwnedEmote>, String>>>,
pub(in crate::game) loaded: Option<std::time::Instant>,
/// `owned` was read from `HORIZON_COSMETICS_LOCAL`'s folder.
local_read: bool,
playing: Option<Playing>,
@@ -34,13 +37,48 @@ pub(super) struct Emotes {
impl Emotes {
/// The emote to tell the service about, while it plays: its id and whether it loops.
pub(super) fn sending(&self) -> Option<(String, bool)> {
pub(in crate::game) fn sending(&self) -> Option<(String, bool)> {
let p = self.playing.as_ref().filter(|p| p.stop.is_none())?;
let looping = p.item.emote.as_ref().is_some_and(|e| e.looping);
Some((p.item.id.clone(), looping))
}
}
/// An emote on a player's model: the engine's pose hook (`Mob::emote`), which names no cosmetics
/// type.
#[derive(Debug)]
struct EmotePose(Playing);
impl BodyPose for EmotePose {
fn pose(&self, age: f32, at_rest: &[[f32; 9]; 6], poses: &mut [[f32; 9]; 6]) -> bool {
let (Some(emote), Some((t, weight))) = (&self.0.item.emote, self.0.at(age)) else {
return false;
};
emote.pose_body(t, weight, at_rest, poses);
true
}
}
/// The emote `m` plays, if any.
pub(in crate::game) fn playing(m: &Mob) -> Option<&Playing> {
let any: &dyn std::any::Any = &**m.emote.as_ref()?;
any.downcast_ref::<EmotePose>().map(|e| &e.0)
}
/// Makes `m` play `next`, unless it plays that already (a new one allocates).
pub(in crate::game) fn set_playing(m: &mut Mob, next: Option<Playing>) {
let same = match (playing(m), &next) {
(None, None) => true,
(Some(a), Some(b)) => {
Arc::ptr_eq(&a.item, &b.item) && a.start == b.start && a.stop == b.stop
}
_ => false,
};
if !same {
m.emote = next.map(|p| Arc::new(EmotePose(p)) as Arc<dyn BodyPose>);
}
}
/// The emotes among a folder's item files, by id.
fn read_folder(dir: &std::path::Path) -> Vec<Arc<Item>> {
let mut emotes: Vec<Arc<Item>> = std::fs::read_dir(dir)
@@ -58,19 +96,19 @@ fn read_folder(dir: &std::path::Path) -> Vec<Arc<Item>> {
impl Game<'_> {
/// The emotes the wheel offers: a local folder's (`HORIZON_COSMETICS_LOCAL`), else those the
/// account owns.
pub(super) fn owned_emotes(&mut self) -> &[Arc<Item>] {
if !self.emotes.local_read {
self.emotes.local_read = true;
pub(in crate::game) fn owned_emotes(&mut self) -> &[Arc<Item>] {
if !self.cosmetics.emotes.local_read {
self.cosmetics.emotes.local_read = true;
if let Some(dir) = std::env::var_os("HORIZON_COSMETICS_LOCAL") {
self.emotes.owned = read_folder(std::path::Path::new(&dir));
self.cosmetics.emotes.owned = read_folder(std::path::Path::new(&dir));
}
}
&self.emotes.owned
&self.cosmetics.emotes.owned
}
/// An emote's name: the catalogue's, else its id in words (`wave_hello`: "Wave hello").
pub(super) fn emote_name(&self, id: &str) -> String {
let names = self.emotes.names.get(id);
pub(in crate::game) fn emote_name(&self, id: &str) -> String {
let names = self.cosmetics.emotes.names.get(id);
let lang = horizon_client::lang::code();
if let Some(name) = names.and_then(|n| n.get(&lang).or_else(|| n.get("en_us"))) {
return name.clone();
@@ -87,7 +125,7 @@ impl Game<'_> {
let Some(p) = self.player.as_ref() else {
return false;
};
super::cosmetics::enabled()
super::enabled()
&& self.player_model.is_some()
&& !p.health.dead_or_dying()
&& !p.fall_flying
@@ -99,7 +137,7 @@ impl Game<'_> {
/// Plays the owned emote `id` (again from its start if it plays already). False if it isn't
/// owned or the player can't emote now.
pub(super) fn play_emote(&mut self, id: &str) -> bool {
pub(in crate::game) fn play_emote(&mut self, id: &str) -> bool {
let Some(item) = self.owned_emotes().iter().find(|i| i.id == id).cloned() else {
return false;
};
@@ -110,14 +148,14 @@ impl Game<'_> {
return false;
};
let start = m.age as f32;
self.emotes.from = Some(p.position);
self.emotes.playing = Some(Playing {
self.cosmetics.emotes.from = Some(p.position);
self.cosmetics.emotes.playing = Some(Playing {
item,
start,
stop: None,
});
if self.camera_type.first_person() {
self.emotes.camera_from = Some(self.camera_type);
self.cosmetics.emotes.camera_from = Some(self.camera_type);
self.camera_type = CameraType::ThirdPersonFront;
self.check_entity_post_effect(false);
}
@@ -125,9 +163,9 @@ impl Game<'_> {
}
/// Stops the emote playing, fading it out.
pub(super) fn stop_emote(&mut self) {
pub(in crate::game) fn stop_emote(&mut self) {
let now = self.player_model.as_ref().map_or(0.0, |m| m.age as f32);
if let Some(p) = self.emotes.playing.as_mut()
if let Some(p) = self.cosmetics.emotes.playing.as_mut()
&& p.stop.is_none()
{
p.stop = Some(now);
@@ -135,21 +173,21 @@ impl Game<'_> {
}
/// The emote playing, if any (its id).
pub(super) fn emote_playing(&self) -> Option<&str> {
let p = self.emotes.playing.as_ref()?;
pub(in crate::game) fn emote_playing(&self) -> Option<&str> {
let p = self.cosmetics.emotes.playing.as_ref()?;
p.stop.is_none().then_some(p.item.id.as_str())
}
/// Once a tick after the player's model: cancels the emote on movement or action, ends it,
/// puts the camera back, and hands it to the model.
fn tick_emote(&mut self) {
if self.emotes.playing.is_none() {
if self.cosmetics.emotes.playing.is_none() {
if let Some(m) = self.player_model.as_mut() {
m.emote = None;
set_playing(m, None);
}
return;
}
let moved = match (self.player.as_ref(), self.emotes.from) {
let moved = match (self.player.as_ref(), self.cosmetics.emotes.from) {
(Some(p), Some(from)) => {
let (dx, dy, dz) = (
p.position.x - from.x,
@@ -169,16 +207,17 @@ impl Game<'_> {
}
let now = self.player_model.as_ref().map_or(0.0, |m| m.age as f32);
if self
.cosmetics
.emotes
.playing
.as_ref()
.and_then(|p| p.at(now))
.is_none()
{
self.emotes.playing = None;
self.emotes.from = None;
self.cosmetics.emotes.playing = None;
self.cosmetics.emotes.from = None;
// The pulled-back view goes back to first person, unless the player turned it.
if let Some(from) = self.emotes.camera_from.take()
if let Some(from) = self.cosmetics.emotes.camera_from.take()
&& self.camera_type == CameraType::ThirdPersonFront
{
self.camera_type = from;
@@ -186,26 +225,27 @@ impl Game<'_> {
}
}
if let Some(m) = self.player_model.as_mut() {
m.emote = self.emotes.playing.clone();
set_playing(m, self.cosmetics.emotes.playing.clone());
}
}
/// Once a tick: my emote, then the other players'.
pub(super) fn tick_emotes(&mut self) {
pub(in crate::game) fn tick_emotes(&mut self) {
if let Some(done) = self
.cosmetics
.emotes
.loading
.as_ref()
.and_then(|rx| rx.try_recv().ok())
{
self.emotes.loading = None;
self.cosmetics.emotes.loading = None;
if let Ok(owned) = done {
self.emotes.loaded = Some(std::time::Instant::now());
self.emotes.names.clear();
self.emotes.owned.clear();
self.cosmetics.emotes.loaded = Some(std::time::Instant::now());
self.cosmetics.emotes.names.clear();
self.cosmetics.emotes.owned.clear();
for (item, names) in owned {
self.emotes.names.insert(item.id.clone(), names);
self.emotes.owned.push(Arc::new(item));
self.cosmetics.emotes.names.insert(item.id.clone(), names);
self.cosmetics.emotes.owned.push(Arc::new(item));
}
}
}
@@ -215,14 +255,55 @@ impl Game<'_> {
/// How far the camera stands back while an emote pulls it (1: the usual distance): it eases
/// out over the emote's first quarter second.
pub(super) fn emote_camera_pull(&self, partial: f32) -> f32 {
let (Some(p), Some(m)) = (&self.emotes.playing, &self.player_model) else {
pub(in crate::game) fn emote_camera_pull(&self, partial: f32) -> f32 {
let (Some(p), Some(m)) = (&self.cosmetics.emotes.playing, &self.player_model) else {
return 1.0;
};
if self.emotes.camera_from.is_none() {
if self.cosmetics.emotes.camera_from.is_none() {
return 1.0;
}
let t = ((m.age as f32 + partial - p.start) / 5.0).clamp(0.0, 1.0);
0.35 + 0.65 * t * t * (3.0 - 2.0 * t)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_emote_poses_the_body_over_the_animation_until_it_ends() {
// The right arm up to 90° about z at tick 10, the body hopping 4 px.
let item = Item::parse(
br#"{"format": 1, "id": "up", "slot": "emote", "parts": [],
"emote": {"length": 20, "blend": 0, "tracks": [
{"part": "right_arm", "keys": [{"t": 10, "rotation": [0, 0, 90]}]},
{"part": "root", "keys": [{"t": 0, "offset": [0, -4, 0]}]}]}}"#,
)
.unwrap();
let rest = |x: f32, y: f32| [x, y, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0];
let mut at_rest = [rest(0.0, 0.0); 6];
at_rest[2] = rest(-5.0, 2.0);
// What the walk animation did: the head looks aside, the arm swings.
let mut poses = at_rest;
poses[0][4] = 0.4;
poses[2][3] = 0.7;
let pose = EmotePose(Playing {
item: Arc::new(item),
start: 100.0,
stop: None,
});
assert!(pose.pose(110.0, &at_rest, &mut poses));
let arm = poses[2];
assert!(
(arm[5] - std::f32::consts::FRAC_PI_2).abs() < 1e-5 && arm[3].abs() < 1e-5,
"{arm:?}"
);
assert!((arm[0] + 5.0).abs() < 1e-5 && (arm[1] - (2.0 - 4.0)).abs() < 1e-5);
// The head keeps looking aside, lifted with the body.
let head = poses[0];
assert!((head[4] - 0.4).abs() < 1e-5 && (head[1] + 4.0).abs() < 1e-5);
// Past its end the emote poses nothing.
assert!(!pose.pose(125.0, &at_rest, &mut poses));
}
}
@@ -28,6 +28,9 @@ impl Game<'_> {
/// `itemGroup.<mod>.<name>`, else its `title` as a lang key (a mod's own
/// `itemGroup.<mod>`), else `title` as English text.
pub(super) fn creative_tab_title(&self, tab: &horizon_client::creative::Tab) -> String {
if let Some((name, _)) = &tab.name {
return name.clone();
}
match horizon_client::creative::item_group_key(&tab.id) {
Some(key) => self.translate(key, &tab.title),
None => {
@@ -144,7 +147,12 @@ impl Game<'_> {
&title,
ox + 8.0,
oy + 6.0,
rgba(64, 64, 64, 255),
tab.name
.as_ref()
.and_then(|n| n.1)
.map_or(rgba(64, 64, 64, 255), |c| {
rgba((c >> 16) as u8, (c >> 8) as u8, c as u8, 255)
}),
false,
);
}
+18 -104
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.
@@ -1351,7 +1266,10 @@ impl Game<'_> {
let path_changes = horizon_client::mobs::collision_changes(&base, &path_writes);
// Each mob keeps its snapshot slot all tick; the first slot of an
// id answers, as a scan would.
let mut index = horizon_client::fast_hash::FxHashMap::<u32, u32>::default();
let mut index = horizon_client::fast_hash::FxHashMap::with_capacity_and_hasher(
snapshot.len(),
Default::default(),
);
let mut villager_slots = Vec::new();
let mut solid_slots = Vec::new();
for (i, s) in snapshot.iter().enumerate() {
@@ -1364,6 +1282,10 @@ impl Game<'_> {
}
}
laps.lap("creature_prepare");
let single_player = horizon_client::mobs::single_player_pos(
players.iter().map(|p| (p.position, p.spectator)),
);
let mut despawn_w = base.despawn_view();
let mut cached_difficulty: Option<((i32, i32), spawning::LocalDifficulty)> = None;
for i in 0..mobs.len() {
let m = &mut mobs[i];
@@ -1387,26 +1309,18 @@ impl Game<'_> {
keep.push(!m.hostile() || m.persistent);
continue;
}
let nearest_player = nearest
.nearest(m.position.x, m.position.y, m.position.z)
.map(|p| p.position);
let nearest_player = single_player.unwrap_or_else(|| {
nearest
.nearest(m.position.x, m.position.y, m.position.z)
.map(|p| p.position)
});
// ServerLevel.tick: every loaded entity checks despawning, but
// only those in entity-ticking chunks (within the simulation
// distance) tick. The despawn check reads the nearest player
// and the difficulty, not the player the mob sees.
if !ticking(bx, bz) {
let w = MobWorld {
others: &snapshot,
others_grid: Some(&grid),
others_index: Some(&index),
others_villagers: Some(&villager_slots),
others_solid: Some(&solid_slots),
nearest_player,
player: None,
teams: std::sync::Arc::clone(&base.teams),
..base
};
let kept = horizon_client::mobs::ai::despawn_check(m, &w);
despawn_w.nearest_player = nearest_player;
let kept = horizon_client::mobs::ai::despawn_check(m, &despawn_w);
if !kept {
died.remove(&m.id);
snapshot[i].alive = false;
@@ -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,
)
}
}
@@ -21,11 +21,13 @@ impl Game<'_> {
let play = if let Some(host) = &self.lan {
Play::Hosting {
players: 1 + host.hub.guests.len(),
max: horizon_client::lan::MAX_PLAYERS,
}
} else if let Some(server) = &self.integrated {
match (server.published(), &self.remote) {
(Some(_), Some(remote)) => Play::Hosting {
players: remote.player_count(),
max: horizon_client::lan::MAX_PLAYERS,
},
_ => Play::Singleplayer,
}
@@ -190,6 +190,9 @@ impl Game<'_> {
fn dispense_case(&mut self, case: &Value) -> Result<Vec<String>> {
let seed = case["seed"].as_i64().unwrap_or(0);
self.fixture_reset(seed);
if let Some(op) = case.get("blocking_wear") {
return self.fixture_blocking_wear(op);
}
// The oracle level answers one sky and one block light everywhere.
let light = case["light"].as_array();
let level = |i: usize, default: i64| {
@@ -666,6 +669,46 @@ impl Game<'_> {
Ok(out)
}
/// Compare a raised item's durability across two frontal hits.
fn fixture_blocking_wear(&mut self, op: &Value) -> Result<Vec<String>> {
let stack = parse_stack(op["item"].as_str().ok_or("missing blocking stack")?)?
.ok_or("empty blocking stack")?;
// Reload the stack just as a player inventory does between sessions.
let saved = horizon_items::item_nbt::item_to_nbt(&stack);
self.inventory[self.selected] = horizon_items::item_nbt::item_from_nbt(&saved);
let (creative, mode) = (self.creative, self.mode);
self.creative = op["creative"].as_bool().unwrap_or(false);
self.mode = if self.creative {
GameMode::Creative
} else {
GameMode::Survival
};
self.combat = Default::default();
self.using = None;
self.shield_cooldown = 0;
self.yaw = 0.0;
self.player = Some(PlayerMotion::new(
self.motion,
horizon_core::geometry::Vec3::new(0.0, 64.0, 0.0),
)?);
if !self.raise_shield(self.selected) || self.using.is_none() {
return Err("could not raise blocking stack".into());
}
let mut lines = Vec::new();
for _ in 0..2 {
self.hurt_player_from(4.0, Some([0.0, 64.0, 2.0]), "minecraft:arrow", Some(None));
let stack = self.inventory[self.selected].as_ref();
let (count, damage) = stack.map_or((0, 0), |stack| (stack.count, stack.damage));
lines.push(format!("blocking wear {count} {damage}"));
}
self.player = None;
self.using = None;
self.inventory[self.selected] = None;
self.creative = creative;
self.mode = mode;
Ok(lines)
}
/// The player's hit on the first target with the case's item, from the
/// attack strength's scaled `base` damage on: the item's damage bonus and
/// source, the hurt with the weapon's armor effects, then `weapon_hit`
+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
Loaded 100 of 470 files, more files were not shown because too many files have changed in this diff. Show more