Compare commits

...
Author SHA1 Message Date
Horizon agent 40f82d0e99 Merge remote-tracking branch 'origin/main' into split/telemetry-hooks
# Conflicts:
#	crates/horizon-telemetry-server/Cargo.lock
2026-11-09 20:38:46 +01:00
Ludovic Marie bc08885276 Merge pull request #14142 from MSedgeMC/fix/shulker-face-bytes
🤖 Agent: AttachFace wraps through from3DDataValue and Peek/Color stay raw bytes; texture() reads the colour signed, so -1 draws white as DyeColor.byId's ZERO strategy does.
2026-11-09 20:37:15 +01:00
Ludovic Marie bc7b8889de Merge pull request #14126 from Joansitoh/fix/9965-warden-save
🤖 Agent: the LAN owner's warden tracker now goes into g.saved before playerdata::store rewrites player_extra from it, so warning level and cooldown survive a save; with_warden keeps lan.rs (over 3000 lines) from growing.
2026-11-09 20:37:11 +01:00
Ludovic Marie 7180a52bf2 Merge pull request #14014 from oloion/fix/elytra-wall-sound-repeat
🤖 Agent: handleFallFlyingCollisions now compares lastSpeed (before updateFallFlyingMovement) with the delta Entity.move leaves, collided axes zeroed, so a glider pressed on a wall stops replaying the crash sound.
2026-11-09 20:37:05 +01:00
Ludovic Marie 8e37c4a4b7 Merge pull request #14215 from DUzzL/fix/14176-lod-mesh-samples
🤖 Agent: bring_back now calls sync_sample for a returning kept tile, so check_mesh_samples sees it.
2026-11-09 20:32:42 +01:00
Ludovic Marie 94ba80f90d Merge pull request #14074 from DUzzL/fix/14034-glass-data-move
🤖 Agent: the glass About card now builds location_row (Move…, confirm, retry/default), and the row keeps the Open button beside Move (agent fix eb3157d; needs the launcher job green: no local launcher build).
2026-11-09 20:32:38 +01:00
Ludovic Marie 238fc99add Merge pull request #14129 from NikitaMGrimm/hdr-windows-calibrate
🤖 Agent: only os_reported (Windows) sets peak_guessed, so Wayland and macOS keep their labels; main merged, the docs paragraph re-applied on main's text, and main's ui_nits test given the field.
2026-11-09 20:32:33 +01:00
Ludovic Marie ac96d3fa35 Merge pull request #13959 from horizon-rust/layers/packs-colors
🤖 Agent: pack colours (OptiFine color.properties, Polytone colors) are now read through pack_hooks::PackFormats once per pack load; the engine keeps a plain PackColors table, so per-block tint lookups stay table reads. layer plan M6 step 2. Merging main moved its new Polytone damage-bar colours behind PackFormats::item_bars, test moved with them.
2026-11-09 20:26:44 +01:00
Ludovic Marie ceff25fca1 Merge pull request #14188 from vnsmoreira/packs/polytone-post-depth
🤖 Agent: the world depth is kept whenever post effects run (use_depth_buffer reads it), only in that case; move_off_page moved out unchanged so gfx.rs doesn't grow.
2026-11-09 20:23:18 +01:00
Ludovic Marie 36d0695742 Merge pull request #14169 from MSedgeMC/port_map/trapdoor-and-dismount
🤖 Agent: both rows match main: Mob::on_climbable has the trapdoor-over-ladder rule and Game::mount_events posts entity_dismount.
2026-11-09 20:23:14 +01:00
Ludovic Marie 94484675a8 Merge pull request #14044 from Ragbie/refactor/minecarts-split
🤖 Agent: checked line by line: every removed line reappears in minecarts/nbt.rs or minecarts/index.rs (pure move, exempt from the 500-line count); the new head adds only main merges (same tree as the reviewed head merged with main).
2026-11-09 20:23:10 +01:00
Ludovic Marie 9097d2973d Merge pull request #13921 from MSedgeMC/port_map/bow-release-file
🤖 Agent: confirmed on main: the player's bow is Game::release_bow at game/use_hand.rs:599; game.rs no longer has it.
2026-11-09 20:23:06 +01:00
Ludovic Marie 00e573cf87 Merge pull request #13911 from Skr1zzzz/split-menu-clicks
🤖 Agent: pure move; reset_inventory_clicks and player_menu_sound have no caller outside game/, so pub(super) from menu_clicks.rs reaches them all.
2026-11-09 20:23:02 +01:00
Ludovic Marie a3bf6531e0 Merge pull request #14019 from MSedgeMC/fix/enchant-update-presence
🤖 Agent: EnchantmentHelper.updateEnchantments returns EMPTY when the component is absent, so enchant is now a no-op on a stack whose enchantments (or a book's stored ones) were removed.
2026-11-09 20:14:49 +01:00
Ludovic Marie 0ddd7ec41d Merge pull request #14012 from TheMrZZ/fix/play-ping-response
🤖 Agent: a play PingRequest is answered with PongResponse on its own connection (handlePingRequest) and kept through the held/handover filter; it doesn't touch last_action_tick. 5/5 ping tests pass with resources.
2026-11-09 20:14:44 +01:00
Ludovic Marie cea1a54e31 Merge pull request #14184 from horizon-rust/agent/telemetry-sqlite-3.54
🤖 Agent: rusqlite 0.41 with fallible_uint keeps the u64/usize conversions the server stores; fmt, clippy -D warnings and its 59 tests pass inside the telemetry workspace (pr_ready skips it).
2026-11-09 20:14:40 +01:00
Ludovic Marie 23dd21e928 Merge pull request #13999 from Mik00000/perf/spatial-entity-collision
🤖 Agent: SeenGrid cells stay sorted (binary insert on update), so candidates merges 1-4 cells without sort_unstable and still returns snapshot order (the 2000-round randomized test holds); main merged (world.rs), bench seed's hex grouping fixed for clippy.
2026-11-09 20:09:00 +01:00
Ludovic Marie 6cf95bb4d7 Merge pull request #14198 from horizon-rust/docs/mod-prs-to-ari-fork
🤖 Agent: docs only, one CONTRIBUTING paragraph pointing mod-related pull requests to arismoko/horizon (the project decision of 11-09); mods.md and native-mods.md never said where to send them, so they stay.
2026-11-09 20:08:56 +01:00
Ludovic Marie cf09a6610a Merge pull request #14159 from horizon-rust/layers/extras-sections
🤖 Agent: adds horizon_hooks::pages::Place::Extras, a page with no entry of its own whose rows join the Extras page group named by their title (or get a group of their own after the page's groups); stacked on #14153 (layers/pages-pause).
2026-11-09 20:08:51 +01:00
Ludovic Marie 094bb83dff Merge pull request #14153 from horizon-rust/layers/pages-pause
🤖 Agent: with no page registered for the pause menu, pause_rows is the split row alone (or empty), so the grid, icon row and focus ids are exactly as before; registered pages take ids 40+index and open the options screen at Page::Added like the split row does.
2026-11-09 20:05:51 +01:00
Horizon agent 7433b49bef Merge remote-tracking branch 'origin/main' into split/telemetry-hooks
# Conflicts:
#	crates/horizon-client/src/entry.rs
#	crates/horizon-hooks/src/lib.rs
#	crates/horizon-platform/src/lib.rs
#	docs/layers.md
2026-11-09 20:05:34 +01:00
Ludovic Marie dfc91826bd Merge pull request #14164 from horizon-rust/split/telemetry-display
🤖 Agent: horizon-ctl status's display object is built by window::display now, the same width/height/refresh/scale fields as before, so tools reading it see no change.
2026-11-09 20:03:00 +01:00
Ludovic Marie 2782c5e810 Merge pull request #14110 from horizon-rust/layers/pages-slider
🤖 Agent: a registered slider is a real Opt (Opt::Page(page,row)), so drag, arrow keys, narration, focus and search come from the existing slider code; its arms read the page's rows only for that variant, and options_ui.rs still shrinks (horizon_page_tooltip moves out).
2026-11-09 20:00:45 +01:00
Ludovic Marie 86f1749664 Merge pull request #14081 from horizon-rust/layers/hud-layers
🤖 Agent: per-frame cost with no layer registered is one sealed-slice load and is_empty in draw_extras (no canvas, no corner pass); a layer's place() uses native_corner_shown, so F3 hides the same corners for it as for mods' boxes.
2026-11-09 20:00:41 +01:00
Ludovic Marie d149cc1dca Merge pull request #14139 from Joansitoh/fix/14125-nautilus-feed
🤖 Agent: matches TamableAnimal.feed(…, 2.0F, 1.0F): 2 x nutrition, 1 without a food component; reads the item's food, not a patched stack's.
2026-11-09 20:00:32 +01:00
Ludovic Marie ae963b0a98 Merge pull request #14057 from MSedgeMC/fix/book-sign-title
🤖 Agent: pushed one fix: the control socket's sign on a read-only book again answers 'not a book and quill' (the catch-all arm had turned it into a silent success).
2026-11-09 19:55:43 +01:00
Ludovic Marie c30911cddf Merge pull request #14023 from MSedgeMC/fix/attribute-modifiers-component
🤖 Agent: pushed one fix: the stack's modifier amount is read as a double (it went through float_value, so 0.1 became 0.10000000149), and the test now pins 0.1.
2026-11-09 19:55:37 +01:00
Ludovic Marie 38eac394be Merge pull request #14016 from horizon-rust/split/revert-render-pipeline-config
🤖 Agent: plain git revert -m 1 of #13968, which put the shaders/ pack-format config into an engine crate (horizon-render-pipeline); the layer rule puts that format in platform, and no layers.json allowed entry changes.
2026-11-09 19:55:32 +01:00
Ludovic Marie 5546650bd7 Merge pull request #14070 from horizon-rust/layers/pages-registry
🤖 Agent: with no page registered the options screen is unchanged (Horizon Settings gets no extra row, nothing is asked per frame); a registered page's rows are rebuilt only while it is open, and every act sends Event::Changed so the crate's settings store is saved.
2026-11-09 19:55:29 +01:00
Ludovic Marie c277636360 Merge pull request #14003 from iseaprojects/fix/12633-recipe-book-dye-imbue
🤖 Agent: crafting_dye and crafting_imbue entries follow DyeRecipe.display and ImbueRecipe.display exactly (OnlyWithComponent dye, DyedSlotDemo; WithAnyPotion source and result), checked against the 26.3 bytecode.
2026-11-09 19:49:57 +01:00
Ludovic Marie 68a01ee7ca Merge pull request #14033 from horizon-rust/layers/moderation-durations
🤖 Agent: pure move of 82 lines that puts the moderation durations into horizon-server-files, the first step of the chat filter's engine hook (#13942 is stacked on it).
2026-11-09 19:49:53 +01:00
Ludovic Marie 15e83578ee Merge pull request #14119 from bvironn/relay/nameplate-unknown-budget
🤖 Agent: only unknown codes spend the nameplate bucket, so once a code exists an attacker's joins stop draining it and a friend waits at most one refill (12 s at 5/min).
2026-11-09 19:49:48 +01:00
Ludovic Marie 66e8c0d04d Merge pull request #14151 from horizon-rust/agent/mob-geometry-move
🤖 Agent: pure move; mob_geometry and mob_geometry_inner go from creatures.rs to creatures/mob_geometry.rs, only super:: paths one level deeper and two rustfmt reflows.
2026-11-09 19:49:44 +01:00
Ludovic Marie 6bab737cae Merge pull request #14075 from Ragbie/refactor/spawn-egg-stack-config-module
🤖 Agent: verified pure move: every removed line reappears in creatures/spawn_eggs.rs once visibility and super::super:: are normalised; creatures.rs 8896 -> 8732 counted lines.
2026-11-09 19:43:40 +01:00
Ludovic Marie 7093c79eeb Merge pull request #14166 from horizon-rust/split/friends-join-official
🤖 Agent: joining a friend now works only in horizon-official (the shipped game); the friends harness was switched to that binary in the same PR, so CI's friends_integration run is the proof that release builds still join.
2026-11-09 19:43:36 +01:00
Horizon agent 9f5ed29593 Merge remote-tracking branch 'origin/main' into split/telemetry-hooks
# Conflicts:
#	crates/horizon-client/src/entry.rs
2026-11-09 19:42:41 +01:00
Ludovic Marie a4da356144 Merge pull request #14115 from horizon-rust/agent/particles-spawn-move
🤖 Agent: pure move; ParticleEngine::send_particles and create go unchanged from particles.rs to particles/spawn.rs, so particles.rs shrinks by 67 lines.
2026-11-09 19:39:40 +01:00
Ludovic Marie 6c2c706959 Merge pull request #13997 from horizon-rust/split/friends-screen-glue
🤖 Agent: pure move of the Friends screen out of the engine library; the build without Friends keeps naming the pause icon "Play with Friends" from friends_off.rs, so the widget tree and controller focus are unchanged.
2026-11-09 19:33:42 +01:00
Ludovic Marie 6769422c88 Merge pull request #14101 from horizon-rust/docs/fork-microsoft-client-id
🤖 Agent: docs only: docs/forking.md now says forks register their own Azure app and may not ship or fetch Horizon's client ID; nothing found to change.
2026-11-09 19:30:29 +01:00
Ludovic Marie ebe7b4a4fd Merge pull request #14112 from voduff/baby-speed-attribute
🤖 Agent: speed_bonus now reads finalize::transient, the same minecraft:baby modifier /attribute reports, so modifier remove slows the baby and a baby piglin gets 1 + 0.2f, not 1.5.
2026-11-09 19:30:24 +01:00
Ludovic Marie 1cce1cbf31 Merge pull request #14006 from Ragbie/fix/mob-fall-clip
🤖 Agent: Mob::do_move now casts Entity.move's FALLDAMAGE_RESETTING/WATER ray only when fall_distance != 0 and the move is a block or more, so it costs nothing on an ordinary tick; boats share the same helper and now query the water on a tagged block too.
2026-11-09 19:30:20 +01:00
Ludovic Marie ed5da43953 Merge pull request #13993 from MrCloudy2/fix/9325-piston-arrow-release
🤖 Agent: release_after_block_move runs AbstractArrow.move's shouldFall check after every non-SELF move, including a push limitPistonMovement or the collisions cut to nothing; #10243's part is already on main, so the net change is piston_push.rs plus the new harness.
2026-11-09 19:30:16 +01:00
Ludovic Marie 3295c54097 Merge pull request #13986 from MSedgeMC/fix/loot-seed-numeric
🤖 Agent: TagValueInput.getLongOr takes any NumericTag (longValue), so an Int LootTableSeed now seeds the roll as in vanilla instead of falling back to the position seed.
2026-11-09 19:30:11 +01:00
Ludovic Marie d0da7ec157 Merge pull request #14113 from MrCloudy2/fix/piston-push-entity-colliders
🤖 Agent: the extra cost is one O(mobs + boats) scan per body a moving piston or shulker lid pushes, the same set item_entity_collisions gives items; the new harness pins the snowball stopping at the boat's side.
2026-11-09 19:27:05 +01:00
Ludovic Marie 042d2ce46a Merge pull request #13950 from horizon-rust/split/friends-tunnel-hook
🤖 Agent: the engine keeps only the friend: address form; a build without a resolver refuses such an address with the same invalid_address key the old cfg(not(feature = "friends")) path used, so reconnect still never retries it.
2026-11-09 19:27:02 +01:00
Ludovic Marie 8f756e8487 Merge pull request #14090 from horizon-rust/fix/git-slim-test-deterministic
🤖 Agent: git 2.5x runs a detached geometric auto-maintenance after each commit that gc.auto=0 does not stop; the test now runs git with no user/system config and maintenance.auto=false, so the 12 packs it counts stay 12 (desktop: 10/10 failures before, 10/10 OK after).
2026-11-09 19:26:58 +01:00
Horizon agentandClaude Opus 5.5 b7bf458117 horizon-telemetry-server's Cargo.lock: horizon-platform uses horizon-core (stale on main)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:26:15 +01:00
Horizon agent 3dcfd7276b Merge remote-tracking branch 'rv3n/14142' into rv3-fix-14142 2026-11-09 19:21:20 +01:00
Horizon agentandClaude Opus 5.5 bfbf06a439 Fall flying wall hit: compare the speed before updateFallFlyingMovement
LivingEntity.travelFallFlying takes lastSpeed before the glide update, as
move_h does; the post-update speed left move_h unused (verify failed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:19:28 +01:00
Horizon agentandClaude Opus 5.5 b7b137a829 docs/layers.md: the exception table names Hooks::tunnel
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:18:39 +01:00
Horizon agent 725208d8de Merge branch 'split/friends-screen-glue' into split/friends-join-official
# Conflicts:
#	crates/horizon-hooks/Cargo.toml
#	crates/horizon-hooks/src/lib.rs
2026-11-09 19:18:32 +01:00
Ludovic Marie 3516aa9641 Merge branch 'split/friends-tunnel-hook' into split/friends-screen-glue 2026-11-09 19:18:13 +01:00
DUzzL 971f341977 Keep lod.rs from growing 2026-11-09 19:17:51 +01:00
Horizon agent cd362fb354 Merge remote-tracking branch 'origin/main' into split/friends-tunnel-hook
# Conflicts:
#	crates/horizon-client-lib/Cargo.toml
2026-11-09 19:15:23 +01:00
Ludovic Marie 216eca2f51 Merge pull request #14030 from horizon-rust/layers/registries-spec
🤖 Agent: docs only (docs/layers.md): the Platform registries spec as built in #14036/#14070/#14081/#14096/#14110 (HUD place() returns None under F3, seat() for splitscreen), and the launcher/horizon-server exception rows removed after #13905.
2026-11-09 19:15:10 +01:00
Ludovic Marie 7dccd1fa39 Merge pull request #13629 from MSedgeMC/fix/bee-anger-target
🤖 Agent: the earlier verify failure was main's options_ui.rs growth measured against this branch's old base; with main merged in it passes, and mob_ai passed with this change on union19b, so a stung player stays the bee's angry_at through the chunk save as Bee.addAdditionalSaveData writes it.
2026-11-09 19:13:23 +01:00
Ludovic Marie e84e5b2dd3 Merge pull request #8230 from voidsipp/fix/8001-preparer-lock
🤖 Agent: the prepare lock now unlocks explicitly on drop, so a child that inherited the descriptor between fork and exec no longer holds the flock past the preparation (#8001); the new test holds a dup'd descriptor to show it.
2026-11-09 19:13:19 +01:00
Ludovic Marie 7b9f3f8e83 Merge pull request #13910 from Skr1zzzz/split-visual-clocks
🤖 Agent: pure move; pub(super) from render_tick.rs is narrower than it was in game.rs, but the only mention outside game/ is a doc comment in clock.rs, so nothing loses access.
2026-11-09 19:13:15 +01:00
Ludovic Marie c9d73d5bea Merge pull request #13954 from horizon-rust/split/official-servers-module
🤖 Agent: the official and partner server entries now live in official_servers.rs behind a lib horizon-extras feature, and servers.dat's lock moved to horizon_protocol::servers::lock(); a plain build adds no server and carries no afri-network address.
2026-11-09 19:13:11 +01:00
Horizon agent 5062adfb0c Merge remote-tracking branch 'origin/main' into split/telemetry-hooks
# Conflicts:
#	crates/horizon-hooks/src/lib.rs
2026-11-09 19:11:07 +01:00
Horizon agentandClaude Opus 5.5 56b703899c hub.rs: keep the chat module's doc comment on it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:09:30 +01:00
Ludovic Marie 7e6d2121b7 Merge pull request #14095 from vivescene/perf/client-mob-skips
🤖 Agent: both skips only move side-effect-free checks (box intersection, empty effect lists) ahead of the lookups; the set and order of mobs that push or swirl is unchanged.
2026-11-09 19:08:49 +01:00
Ludovic Marie a89865b1c5 Merge pull request #14094 from MSedgeMC/fix/book-title-centered
🤖 Agent: text_start_x is EditBox.updateTextPosition's centred textX ((width - text width) / 2 from getX), and position_at measures the click from the same start, as findClickedPositionInText does.
2026-11-09 19:08:44 +01:00
MSedgeMC c51a5eec3f Merge remote-tracking branch 'upstream/main' into fix/enchant-update-presence 2026-11-09 18:01:53 +00:00
DUzzL 21aabee4c0 Sync far-terrain mesh samples when kept tiles return 2026-11-09 19:00:58 +01:00
MSedgeMC 7be13f1e9a Merge upstream/main 2026-11-09 18:00:20 +00:00
Horizon agentandClaude Opus 5.5 ff83eccf6a AttachFace: unsigned_abs on the remainder, as clippy asks, within the line
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:59:17 +01:00
Ludovic Marie c8e48f9cea Merge pull request #14089 from Mik00000/perf/particle-collision-cache
🤖 Agent: ParticleLevel::block is World::block with a one-chunk Cell in front (same air fallback), and the other edits (lava smoke, bubble removal, lifetime_alpha) keep their random draws and results. Queue BEFORE 14097 (its Ctx impl sits beside this PR's Ctx<'a, 'l>).
2026-11-09 18:59:04 +01:00
Ludovic Marie bdb6452da9 Merge pull request #14073 from alexcatdad/nix-packages
🤖 Agent: packaging only; Cargo.lock has no git sources, so buildRustPackage needs no outputHashes, and nothing the source filter drops (launcher/, nix/, docs pages) is read by the build.
2026-11-09 18:58:58 +01:00
Ludovic Marie 2ddd9705d9 Merge pull request #14059 from MSedgeMC/fix/port-tooltip
🤖 Agent: PORT_INFO_TEXT is drawn after every widget so it sits on top, and a remote world (no port box) gets none.
2026-11-09 18:58:53 +01:00
Ludovic Marie 868d3b76a5 Merge pull request #14058 from MSedgeMC/fix/social-tooltip-delay
🤖 Agent: the delay is per button identity (row id + kind) with vanilla's strict elapsed > 500 ms, and costs one Instant::now() per frame on the social screen only.
2026-11-09 18:58:47 +01:00
MSedgeMC f2718e086d Merge remote-tracking branch 'upstream/main' into fix/enchant-update-presence 2026-11-09 17:58:35 +00:00
Horizon agentandClaude Opus 5.5 dc382b898a CONTRIBUTING: mod-related pull requests go to arismoko/horizon
The project decided that mod-API work (native hooks, native, content and
wasm mods, mod builds and the registry) is reviewed and merged on Ari's
fork. Say so where contributors look first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:55:43 +01:00
Horizon agentandClaude Opus 5.5 b5f396461e Merge origin/main into layers/packs-colors
Main's Polytone item bar colours (#fb354374e3) move behind the new
PackFormats::item_bars hook (glue pack_formats/item_bars.rs, test moved with
them), and environment.horizon_height joins the engine's Environment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:55:39 +01:00
Horizon agentandClaude Opus 5.5 e963d24058 Give main's new UI-nits test its peak_guessed field
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:54:18 +01:00
Horizon agentandClaude Opus 5.5 3eec96a8b1 Format the shulker load and its test; abs() keeps the line within entity_nbt.rs's size
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:54:15 +01:00
Horizon agentandClaude Opus 5.5 249e8db13c Keep lan.rs from growing: with_warden takes the tracker and gives the guest back
lan.rs is over 3000 lines and may not grow (tools/check_file_size.py): the
tracker now goes in on the line that picks the owner's guest, and the
comment moves to playerdata::with_warden.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:53:53 +01:00
Ludovic Marie b4f24319ac Merge pull request #14083 from horizon-rust/agent/remote-poll-move
🤖 Agent: pure move; poll_stationary_client, poll_remote and poll_remote_events go byte-identical from remote.rs to remote_poll.rs (a #[path] child of remote), only visibility and super::super::server_links change.
2026-11-09 18:53:19 +01:00
MSedgeMC 1d2dd56a06 Merge remote-tracking branch 'upstream/main' into fix/enchant-update-presence 2026-11-09 17:52:56 +00:00
Horizon agentandClaude Opus 5.5 22f3da10c0 Telemetry server: bundle SQLite 3.54 through rusqlite 0.41
Its own workspace still resolved rusqlite 0.37 -> libsqlite3-sys 0.35,
which bundles a SQLite older than 3.54 (RUSTSEC-2026-0459). rusqlite 0.41
resolves libsqlite3-sys 0.39.1, as the root workspace already does.
rusqlite 0.38 moved the u64/usize conversions behind fallible_uint: turned
on, so the stored values convert as before (an error past i64::MAX).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:51:42 +01:00
Evrim Cicek c992f24496 Merge remote-tracking branch 'origin/refactor/minecarts-split' into refactor/minecarts-split 2026-11-09 20:49:53 +03:00
Evrim Cicek 005249b984 Merge remote-tracking branch 'upstream/main' into refactor/minecarts-split
# Conflicts:
#	crates/horizon-client/src/game/minecarts.rs
2026-11-09 20:48:40 +03:00
Horizon agentandClaude Opus 5.5 63dae1216c SeenGrid: group the bench seed's hex digits (clippy), say when candidates scans all
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:48:39 +01:00
Ludovic Marie 962f5949ac Merge pull request #13613 from vnsmoreira/fix/command-block-last-output
🤖 Agent: LastOutput is now saved as a component, keeping the red, click-to-suggest feedback of a failed command (BaseCommandBlock / ComponentSerialization.CODEC). The case keeps to failed commands, so no new score holder changes the later /scoreboard players list order. command_exec: 19063/19065; the 2 left are the zombie PersistenceRequired pair, which fails the same way on main 8321f11360.
2026-11-09 18:46:17 +01:00
Ludovic Marie 5e9024e0a1 Merge pull request #14063 from TheDanikReal/main
🤖 Agent: all 1266 entries keep their {placeholders}; translation file only, approved by a Team member who reads it.
2026-11-09 18:46:13 +01:00
Ludovic Marie 559c2d4e81 Merge pull request #14062 from MSedgeMC/fix/sign-messages-codec
🤖 Agent: a non-list messages now empties the side as SignText.CODEC does; still open: 26.3 also fails a side whose messages are missing or not exactly 4 (listOf(4, 4)), a follow-up.
2026-11-09 18:46:09 +01:00
Ludovic Marie ee449e0ef2 Merge pull request #13734 from RINNECODER/up10/campfire-smoke-limit
🤖 Agent: campfire smoke now goes through the particle limiter like vanilla's; marker_particles 2187/2187 pass on a union with main.
2026-11-09 18:46:05 +01:00
Ludovic Marie dc3d97ce25 Merge pull request #13728 from DUzzL/fix/13673-cured-villager-gear
🤖 Agent: cure drops and cursed gear go to the inventory as in vanilla; zombie_cure_sound 804/804 pass on a union with main.
2026-11-09 18:46:00 +01:00
Ludovic Marie cab5f23234 Merge pull request #13724 from DUzzL/fix/13672-cushion-lava
🤖 Agent: the cushion now takes lava damage at the check interval, as vanilla does; cushion_placement (2852 cases), fall_damage and living_fall pass on a union with main.
2026-11-09 18:45:56 +01:00
Ludovic Marie 2c5d1990ce Merge pull request #13957 from horizon-rust/layers/packs-sky
🤖 Agent: OptiFine custom sky now reaches the engine only through pack_hooks::PackFormats::custom_sky, resolved once per pack load (the per-frame draw keeps the same layer list and shader). layer plan M6 step 1. verify green on this head; no vanilla behaviour changed (parity.py selects 633 harnesses only because game.rs is touched; none observes pack skies).
2026-11-09 18:45:52 +01:00
Horizon agentandClaude Opus 5.5 eb3157d251 Launcher: the Data folder row keeps its Open button beside Move
The glass About card's row had Open; building location_row there dropped
it. location_row's action is now Open and Move side by side (only the
glass page builds it: the old settings page is unreachable).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:44:54 +01:00
MSedgeMC d577d49a78 port_map: the trapdoor ladder and the dismount event rows are ports
`Mob::on_climbable` takes an open trapdoor over a matching ladder, as
`climbing::on_climbable` does for the player, and `Game::mount_events`
posts `entity_dismount` for players and mobs alike. Both rows still
described the old gaps.
2026-11-09 17:44:34 +00:00
Horizon agent 8a9bd15a3d Merge remote-tracking branch 'origin/main' into rv3-fix-14129
# Conflicts:
#	docs/graphics.md
2026-11-09 18:43:16 +01:00
Horizon agentandClaude Opus 5.5 a40f3d82c1 A shulker with a negative colour byte draws white, as DyeColor.byId does
Shulker.getColor treats the byte as signed: 16 and above are undyed, and a
negative id reaches DyeColor.byId, whose OutOfBoundsStrategy.ZERO gives
white. Keeping the raw byte (255 for -1) sent it to the undyed texture.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:42:45 +01:00
Horizon agent 7383ff1908 Merge branch 'layers/pages-slider' into layers/pages-pause 2026-11-09 18:40:40 +01:00
Horizon agent 4f4c87df64 Merge branch 'layers/pages-registry' into layers/pages-slider 2026-11-09 18:40:39 +01:00
Vinícius MoreiraandClaude Opus 5.5 af3c22b9f1 Post effects: a pack's post chain reads the world's depth
The world pass discarded its depth unless a shader pack, TAA, OIT or a
native mod read it, so a resource pack's post chain that samples
minecraft:main with use_depth_buffer read cleared depth (0, the sky)
everywhere. The freepack's sun glare tests the sky around the sun in
that depth, so it shone through hills and walls. The depth is now kept
whenever post effects run, as GameRenderer hands them the main target.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:39:31 -03:00
Vinícius MoreiraandClaude Opus 5.5 182eec2cb7 gfx: move_off_page moves to gfx/off_page.rs
A pure move that makes room for the next commit: `gfx.rs` is over 3000 lines and
may not grow (docs/layers.md "File size"). `move_off_page` leaves it unchanged, imported back
under the same path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 14:39:30 -03:00
Horizon agentandClaude Opus 5.5 37bdbf9def spans::startup::since_start in the startup module; rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:39:04 +01:00
Horizon agentandClaude Opus 5.5 53d1afc5b1 Merge origin/main into #13999
world.rs: main's raycast cache and despawn view kept beside this PR's NearIter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:35:35 +01:00
Horizon agentandClaude Opus 5.5 a94b5a1b2e Extras sections: group_named's lifetime, rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:34:21 +01:00
Horizon agentandClaude Opus 5.5 b4c7f409d2 attribute_modifiers: read the amount as a double
AttributeModifier.amount is a double; going through float_value turned
0.1 into 0.10000000149. The test now uses 0.1 to pin it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:31:54 +01:00
Horizon agentandClaude Opus 5.5 3365fd6bc4 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:30:38 +01:00
Horizon agentandClaude Opus 5.5 c0da1ac2e1 Control socket sign: a read-only book still answers "not a book and quill"
begin_signing does nothing for BookScreen::View, so the catch-all arm
turned that error into a silent success.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:30:28 +01:00
Horizon agentandClaude Opus 5.5 45cefe3278 Pages: Place::Extras, a section of the Extras page (layer plan M5)
A crate above the engine adds rows to Horizon Settings' Extras page: they
join the page's group the section's title names, else a group of its own.
Auto-reconnect moves out on it next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:29:17 +01:00
Horizon agentandClaude Opus 5.5 032ae31f34 creatures.rs: mob_geometry moves to creatures/mob_geometry.rs (pure move)
mob_geometry and mob_geometry_inner, the per-frame drawing of mobs, players and
projectiles, move unchanged into a child module of creatures.rs, so a change
to them (#12276's client_entity_render) no longer grows creatures.rs, which is
over the 3000-line limit. Only the visibility (pub(in crate::game)), the
super:: paths one level deeper, and rustfmt's reflow of two lines follow the
new module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:24:34 +01:00
Horizon agentandClaude Opus 5.5 47d55b5690 pack_post.rs: import BUFFERS instead of a local const
The revert re-added `const BUFFERS` and a blank line, so the file grew from
5859 to 5860 lines over the 3000-line limit (verify run 42650015942). The
import does the same in no extra line.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:24:21 +01:00
rdbtCVSandClaude Sonnet 5.5 db4a145e6b cargo fmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 17:22:31 +00:00
MSedgeMC 59d958c09e port_map: the shulker's save reads wrap and narrow like vanilla
`tools/port_map/game2-002.tsv` said `Shulker.readAdditionalSaveData` reads
`AttachFace`, `Peek` and `Color`; the row now names the codec's wrapping and
the two raw bytes the port keeps.
2026-11-09 17:22:12 +00:00
MSedgeMC 1a2015e305 test: a shulker's saved face and lid bytes
`mob_from_nbt` is what `Game::load_mob` goes through. A shulker with
`AttachFace: 7b`, `Peek: -1b` and `Color: -1b` loads with `variant3 == 1`
(`abs(7 % 6)` is up), `counter == -1` and `color == 255`.

Without the coercion they load clamped: `left: 5`, `right: 1` for the face,
`left: 0`, `right: -1` for the peek and `left: 16`, `right: 255` for the
colour.
2026-11-09 17:22:12 +00:00
MSedgeMC 197172ef3c fix: a shulker reads its saved face and lid bytes as vanilla does
`Shulker.readAdditionalSaveData` reads three fields with different rules:

- `AttachFace` is `Direction.LEGACY_ID_CODEC`, whose `from3DDataValue` wraps
  the byte through `abs(id % 6)`;
- `Peek` is `getByteOr("Peek", 0)`, a raw byte put straight on the synched
  data;
- `Color` is `getByteOr("Color", 16)`, a raw byte too.

Horizon clamped all three to the setters' ranges (`0..=5`, `0..=100`,
`0..=16`): an `AttachFace` of 7 became east (5) instead of up (1), a negative
`Peek` became 0 instead of the byte vanilla stores, and a negative `Color`
became 0 instead of 255.

The load now wraps the face and narrows `Peek` and `Color` to bytes, exactly
as the codec and `getByteOr` do.
2026-11-09 17:22:12 +00:00
Horizon agentandClaude Opus 5.5 c3c0e2d868 Pages pause: test imports, rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:20:47 +01:00
JoansitohandClaude Sonnet 5.5 528d98bf36 Nautilus: feeding heals 2 x the food's nutrition
TamableAnimal.feed heals 2.0 * nutrition (1.0 without a food component),
not a flat 2 HP.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 18:20:34 +01:00
JoansitohandClaude Sonnet 5.5 9d6d292348 Keep the owner's warden spawn tracker through a save
store_owner wrote the tracker into level.player_extra, then playerdata::store
replaced that with the guest's loaded snapshot, losing the warning level and
cooldown. Put the tracker into the owner guest's saved compound first.

Part of #9965

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 18:18:08 +01:00
Horizon agentandClaude Opus 5.5 7973f9935b Pages: Place::Pause, an entry in the pause menu
A page registered with Place::Pause gets a full-width button in the
pause menu, above Options and under Splitscreen's settings, labelled
with its title; it opens the options screen at the page. The split row
and the pages' rows share one list (pause_rows), so the grid, the icons
and controller focus place them alike. With nothing registered the
pause menu is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:11:43 +01:00
Horizon agentandClaude Opus 5.5 d119d6da2d horizon-friends game: drop an unused import
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:10:50 +01:00
Horizon agentandClaude Opus 5.5 4bc93d9a5f particles.rs: send_particles and create move to particles/spawn.rs (pure move)
The two entry points that bring particles into the engine move unchanged into
a child module, so a change to them (#12277's client_particle hook) no longer
grows particles.rs, which is over the 3000-line limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:10:42 +01:00
Horizon agentandClaude Opus 5.5 671af567f4 docs/layers.md registries: status with the PRs, Page::tooltip
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:09:15 +01:00
iseaprojectsandopencode 1f19e94d74 Send dye and imbue recipes to the recipe book
`ServerRecipeBook.addRecipes` sends one entry per display of a recipe.
`book_entries` had no writer for `crafting_dye` or `crafting_imbue`, so it
fell through to "no display": a learnt leather-dyeing or tipped-arrow
recipe never reached the player's book, though removing it sent the right
Remove packet.

- `crafting_dye` writes `DyeRecipe.display`: the target and the dyes kept
  to the DYE component, the `minecraft:dyed` demo of the two, then a
  crafting table; the placement is the target and the dye.
- `crafting_imbue` writes `ImbueRecipe.display`: the 3x3 shaped display of
  four materials, the `with_any_potion` source, four materials again, and
  the result wrapped in `with_any_potion`; the placement puts the source
  in the middle cell.

Both are single-display recipes, so they also get their ghost display now.
Covered by unit tests of the decoded entries and by the all-real-recipes
round trip, and the variants parity harness now grants and takes back
leather helmet dyeing and tipped arrows beside map cloning.

Co-Authored-By: opencode (DeepSeek V4.1 Flash) <[email protected]>
2026-11-09 13:08:47 -04:00
voduffandClaude Opus 5.5 fcbeaefd37 Baby mobs move by their minecraft:baby modifier, not a fixed 1.5x (#11994)
ai::speed_bonus hard-coded 1.5 for baby zombies and piglins, so a baby
whose minecraft:baby modifier was removed with /attribute kept running
fast, and a baby piglin ran at 1.5x where Piglin.SPEED_MODIFIER_BABY is
+0.2. It now applies finalize::transient's MovementSpeed modifier
(ADD_MULTIPLIED_BASE), the one /attribute reads.

Part of #11994

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:08:16 +00:00
Horizon agentandClaude Opus 5.5 8bf0614d9d Pages: slider test checks the position shown; rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:06:31 +01:00
Horizon agentandClaude Opus 5.5 83b3c9306f Docs: forks may not use Horizon's Microsoft client ID
Horizon's Azure app registration and Mojang's approval of it cover Horizon's
own builds only. Forks and derived launchers register their own app, seek
their own approval, and must not ship or fetch Horizon's client ID.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:03:45 +01:00
Horizon agent e6184afbe2 Merge remote-tracking branch 'origin/main' into split/official-servers-module
# Conflicts:
#	docs/forking.md
2026-11-09 18:01:41 +01:00
Horizon agentandClaude Opus 5.5 96c5616505 Pages: sliders and per-row tooltips
A registered page's Slider row is one of the options screen's own
sliders (Opt::Page(page, row)): dragging, the arrow keys, narration,
focus and search work as for the engine's options, and the page hears
Act::Slide. Page::tooltip (default none) gives a row's hover text, which
may follow its value.

The registered pages are read from horizon_hooks::pages directly (a
test sets its own per thread), and horizon_page_tooltip moves to
options_ui_pages.rs: options_ui.rs shrinks again.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:00:36 +01:00
Horizon agentandClaude Opus 5.5 55b78015fe docs/layers.md: the hooks paragraph rewrapped
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:59:57 +01:00
Horizon agentandClaude Opus 5.5 cb1198a38d Telemetry's hooks register only with horizon-extras
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:59:46 +01:00
Horizon agentandClaude Opus 5.5 17b50d7959 Telemetry's play session hears the game through the hooks (layer plan M5)
horizon-hooks gains the play session's hooks: startup (core-ready, rest-ready,
the title's first frame, once each), wants_frame_steps, world_shown, frame and
world_left, with plain data (Frame, WorldLeft, Milestone). The engine's
play_session::Session marks the startup trace and dispatches them; Game and
the startup menu hold it instead of telemetry's Session.

telemetry.rs implements them (HOOKS, registered by entry.rs like the ping):
the consent check for frame steps, the settings' encoding and the start's
timings move there, so gameplay no longer names horizon_telemetry for the
perf session.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:59:39 +01:00
Bairon M. 7766619ecb Relay: cap unknown codes per nameplate
An invite code is eight Crockford symbols: four nameplate symbols the
relay routes by (20 bits) and four secret symbols the host checks (20
bits). The relay sees only the nameplate, so it can count joins whose
nameplate does not exist, not wrong secrets for one that does; the host
already retires a code after five of those.

This adds a budget for unknown codes for one nameplate (5 a minute,
burst 10), spent whichever address asked and checked before the lookup:
without it, one address set can take the whole global unknown budget
for a single short code. The check records nothing for a nameplate it
has not seen; only spending an unknown code creates the entry, so a
join refused by another budget leaves no state behind.

Verified: cargo test -p horizon-relay (31 tests) and clippy
--all-targets with -D warnings.
2026-11-09 13:58:12 -03:00
MSedgeMC ec3e4df6e3 port_map: EditBox.setCentered 2026-11-09 16:55:26 +00:00
MSedgeMC a0f02b61ea test: a centred edit box starts its text in the middle
The title box is set centred; the test pins the offset for an empty, a short
and an odd-width value, and that the sign screen's title box asks for it.
2026-11-09 16:55:26 +00:00
MSedgeMC 7f2e64b603 fix: the book signing title box centres its text
BookSignScreen sets its 15 character title box centred, so the title and the
caret sit in the middle of the 114 pixel box. EditBox had no centred mode: the
text (and a click on it) started at the box's left edge.
2026-11-09 16:55:26 +00:00
Horizon agentandClaude Opus 5.5 6e21ce9476 Joining a friend leaves the engine: horizon-friends offers the tunnel through Hooks::tunnel
The tunnel extension point moves to horizon-hooks (re-exported by horizon_platform::hooks),
offered by a hook instead of a startup registration. horizon-friends' new game feature holds the
resolver, and horizon-official registers it with the other Horizon parts. The friends harness
runs the official executable. Layer plan M5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:55:14 +01:00
valuttaandClaude Opus 5.5 861b9e7b16 Client tick: skip far mobs before the pushes' lookups and effectless ones before their size
remote_pushes looked up every mob's player entry and scoreboard team
before testing whether its box meets the player's; effect_swirls worked
out every mob's width and height before seeing it had no effects. Both
skips come first now, for the same pushes and particles. At 10,000 pigs
the client tick takes about a quarter less of the main thread.

Fixes #14053

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:53:00 +01:00
MrCloudy2andClaude Opus 5.5 365a44ff91 Harness: a body a piston pushes stops at a boat in its way
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:52:33 +01:00
rdbtCVSandClaude Sonnet 5.5 358456bb02 Merge main into refactor/minecarts-split
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-09 16:52:32 +00:00
Horizon agent 07068153c6 Merge branch 'split/friends-tunnel-hook' into split/friends-screen-glue
# Conflicts:
#	docs/forking.md
2026-11-09 17:52:02 +01:00
MSedgeMC b75193985b port_map: WorldOptionsScreen.init's port box tooltip 2026-11-09 16:51:28 +00:00
MSedgeMC 1a07a02990 port_map: the social row buttons' tooltip delay and the PlayerEntry row 2026-11-09 16:51:22 +00:00
MSedgeMC f5c6b7dfd1 port_map: BookSignScreen.init fills the title box with the kept title 2026-11-09 16:51:15 +00:00
Horizon agent 8d5dd89421 Merge remote-tracking branch 'origin/main' into split/friends-tunnel-hook
# Conflicts:
#	Cargo.lock
#	crates/horizon-client-lib/Cargo.toml
#	crates/horizon-client/Cargo.toml
#	crates/horizon-client/src/entry.rs
#	crates/horizon-client/src/game.rs
#	tools/layers.json
2026-11-09 17:51:01 +01:00
MrCloudy2andClaude Opus 5.5 6280d2630c A body a piston or a shulker lid pushes stops at a boat or a shulker
Entity.collide's getEntityCollisions: a pushed entity collides with the
entities it can collide with (canCollideWith: a boat, a living shulker, a
happy ghast held still), not only with blocks and the world border.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:50:23 +01:00
Mik00000 c221aa9ec9 perf(render): cache chunk lookups inside ParticleLevel
Maintainer feedback from PR #13906 requested keeping signatures identical and
placing the one-chunk lookup cache directly inside ParticleLevel via
Cell<Option<((i32, i32), Option<&'a Chunk>)>>, avoiding threading _cached twins
and preventing particles.rs from growing beyond base size.

- Store cached_chunk inside ParticleLevel and read through ParticleLevel::block.
- Decouple Ctx lifetime parameter in polytone.rs (Ctx<'a, 'l>) so ParticleLevel's
  invariant 'a does not constrain stack evaluation borrows.
- Keep particles.rs strictly under line size limits (5,195 non-test lines vs 5,197 base).
- Passes check_file_size.py, cargo fmt, clippy -D warnings, and particle unit tests.
2026-11-09 18:47:29 +02:00
Horizon agentandClaude Opus 5.5 0568b2beff tests: keep git's background maintenance out of test_git_slim
Since git 2.5x, `git commit` starts `git maintenance run --auto` detached
after every commit, and its default strategy repacks geometrically even
with gc.auto=0. The packs the tests build were folded while they were
counted: 12 built, 3-6 found on the desktop (git 2.54, every run), and
intermittently in CI verify (run 42647063573 on #13970).

The tests now run git with no user or system config, no inherited GIT_*
variables, and gc.auto=0, maintenance.auto=false, no detached gc. The
assertions are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:47:23 +01:00
Nikita Maximilian GrimmandClaude Opus 5.5 aa741b3570 HDR settings: on Windows, the HDR page asks to calibrate
On Windows, Auto takes the display's peak from DXGI's MaxLuminance, which is
mostly the display's EDID figure and often runs high. The HDR page still said
the display was already set up ("Calibrate Anyway..."), though calibration's
Peak step works there, since PQ passes through unchanged.

Now the Windows reading marks its peak as a guess (`Reported::peak_guessed`),
and the page's Calibrate row reads "Calibrate..." and says the reported peak is
often too high, while Peak Brightness and Paper White stay on Auto. Auto still
uses the reported values, which beat the 1000-nit default. Once the player has
calibrated or set either value, the row offers "Use Automatic Setup" as before.
There the Peak Brightness row no longer says it is set up for the display:
its Auto is the guess. Paper White, the player's own slider, still does.
Compositor reports (Wayland) and macOS, which reports no nits, are unchanged.

The setup decision moves next to `Reported` (`Graphics::hdr_setup`), out of
graphics_ui.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:47:16 +01:00
Horizon agentandClaude Opus 5.5 3e77ec425f remote.rs: the packet poll loop moves to remote_poll.rs (pure move)
poll_stationary_client, poll_remote and poll_remote_events move unchanged
into game/remote_poll.rs, a module of remote.rs, so a change to the loop
(#12271's cancellable packets) no longer grows remote.rs, which is over the
3000-line limit. Only the visibility (pub(in crate::game)) and one path
(super::super::server_links) follow the new module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:45:28 +01:00
Horizon agentandClaude Opus 5.5 0ccd2a4397 docs/layers.md: the launcher and horizon-server reach the engine through platform (#13905)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:43:12 +01:00
Horizon agent dd8214d3a3 Merge remote-tracking branch 'origin/main' into layers/registries-spec 2026-11-09 17:43:11 +01:00
Horizon agent 34b5df1397 Merge branch 'layers/packs-sky' into layers/packs-colors
# Conflicts:
#	crates/horizon-client-render/src/lib.rs
#	crates/horizon-client/src/client_lib.rs
2026-11-09 17:34:35 +01:00
Horizon agent 2a018699ac Merge remote-tracking branch 'origin/main' into layers/packs-sky
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-09 17:34:13 +01:00
Horizon agentandClaude Opus 5.5 74eaac0c21 docs/layers.md registries: status, seats, place gives None under the debug screen
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:31:50 +01:00
Vinícius MoreiraandClaude Opus 5.5 5757de7e74 Merge main into #13613
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:28:51 -03:00
MSedgeMC 916ab6b8b1 A reopened social screen starts its tooltips' wait again
The screen's widgets are new on every open in vanilla, so the half-second
wait starts over rather than resuming a hover from the last time.
2026-11-09 16:26:28 +00:00
MSedgeMC b56e58fde3 Document the sign side codec fallback 2026-11-09 16:24:35 +00:00
alexcatdadandClaude Opus 5.5 0df07ea3cf Nix: build horizon-server, the engine, horizon-prepare and the client from the flake
The flake built only the launcher, for Linux. It now also builds the game's programs from this
workspace, with nixpkgs' rustPlatform and Cargo.lock, on x86_64-linux, aarch64-linux and
aarch64-darwin (nix/):

- horizon-engine: horizon-client --no-default-features, the headless game (no ALSA or udev);
- horizon-server, with horizon-engine linked beside it, where the server looks for the game;
- horizon-prepare, with a JDK 25 as its default JAVA_HOME and its root (research-lock.json, the
  exporters' sources) in share/: a wrapper copies that root somewhere writable unless --root is given;
- horizon-client with horizon-ctl, the libraries wgpu and winit open at run time on its rpath.

The launcher and the dev shell stay as they were, on Linux. docs/nix.md says how to use them. Nothing
of Mojang's is fetched or packaged. Part of #13841.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:23:29 +02:00
TheDanikReal e527784bc3 Fix more Belarusian translations 2026-11-09 16:23:03 +00:00
MSedgeMC 5dd06d27cb The port box explains itself on hover
WorldOptionsScreen gives portEdit the PORT_INFO_TEXT tooltip, so hovering the
LAN port box says what it is; Horizon drew no tooltip for it. The box stays
visible with LAN off (only its editing stops), so the tooltip shows there too.
2026-11-09 16:21:49 +00:00
DUzzL 72c6784172 Launcher: give the glass Settings Data folder row the Move button 2026-11-09 17:21:46 +01:00
MSedgeMC 3c5a95239d A social screen row button's tooltip waits half a second
PlayerEntry sets TOOLTIP_DELAY (500 ms) on its report, mute and show
buttons, so their tooltips appear only after the cursor has rested on the
same button for half a second (WidgetTooltipHolder). Horizon's social screen
drew the row button's tooltip on the first hovered frame.

social_screen::TooltipDelay now tracks the hovered Tip and its start time and
answers whether the tooltip shows; the screen's draw passes the current tip.
2026-11-09 16:21:43 +00:00
MSedgeMC 678d6da047 The book signing screen reopens with the title it kept
BookSignScreen's constructor sets its title box to titleValue, the text saved
when Cancel left the screen. Horizon reopened the sign screen with an empty
box while its title field still held the old title, so the next keystroke
started from nothing and Enter signed a title the box did not show.

BookScreen::begin_signing now fills the title box from the kept title, as the
constructor does, and both ways into the sign screen use it.
2026-11-09 16:21:35 +00:00
Evrim CicekandClaude Opus 5.5 24c9ad202b Move the spawn egg functions out of creatures.rs
A pure move, for the file-size rule: spawn_egg_offspring,
spawn_egg_fluid_hit, spawn_egg_allowed, use_spawn_egg and
apply_spawn_egg_entity_data go to game/creatures/spawn_eggs.rs. Only
visibility and the `super::` paths change. creatures.rs goes from 8896
counted lines to 8732.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:21:11 +03:00
TheDanikReal af593b6559 Fix Belarusian translations 2026-11-09 16:08:47 +00:00
Horizon agent c587403015 Merge remote-tracking branch 'origin/main' into split/official-servers-module
# Conflicts:
#	crates/horizon-client-lib/Cargo.toml
#	crates/horizon-client/Cargo.toml
#	crates/horizon-client/src/game.rs
2026-11-09 17:03:01 +01:00
Horizon agentandClaude Opus 5.5 3ebcf19eed docs/layers.md: spec of the platform registries (settings, pages, HUD)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:03:00 +01:00
Horizon agentandClaude Opus 5.5 f2f02e57bd Moderation durations move from horizon-chat-filter to horizon-server-files
Layer plan M6, a pure move before the chat filter's engine hook: /tempban (engine) and /tempmute
share parse_duration, duration_parts, duration_text and now_millis, which now live in the engine's
horizon-server-files (duration.rs). horizon-chat-filter re-exports them. Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:02:55 +01:00
MSedgeMC c10aea9eb8 Equipment attribute modifiers come from the stack
ItemAttributeModifiers is a component, so a patched stack replaces the item's
prototype modifiers. Horizon's mob attack and armour comparisons read
ItemInfo.modifiers, ignoring the stack's own.

Test: a sword with a patched attack_damage +5 reports that modifier.
2026-11-09 16:00:18 +00:00
MSedgeMC bf22beb825 An absent enchantments component makes enchant a no-op
ItemStack.enchant uses EnchantmentHelper.updateEnchantments, which returns
EMPTY when the component is absent. Horizon added the enchantment to the
modelled list anyway, so enchant_randomly and enchant_with_levels enchanted a
stack whose !minecraft:enchantments patch removed the component.

Test: a sword with !minecraft:enchantments stays unenchanted after enchant;
an enchanted book with !minecraft:stored_enchantments does too.
2026-11-09 15:59:43 +00:00
Horizon agentandClaude Opus 5.5 10fb981317 Revert #13968: pack-format types back out of the engine
horizon-render-pipeline is an engine crate, and the config it held is the
shaders/ format's buffer and shadow configuration (colortex buffers, Iris
half-lives, shaders.properties settings). docs/layers.md: the engine never
holds a shader or pack format vanilla doesn't read; pack formats and the
shader API are platform. The M6 shader-pack move restarts from a spec.

This reverts merge 75b85d258f.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:57:43 +01:00
Evrim CicekandClaude Opus 5.5 443a42f97a Move the minecart's save/load and cart index out of minecarts.rs
A pure move, for the file-size rule: `impl Minecart`'s to_nbt/load go to
game/minecarts/nbt.rs, `CartIndex` and `cart_at` to game/minecarts/index.rs.
Only visibility changes so minecarts.rs still reaches them. minecarts.rs
goes from 3230 counted lines to 2967.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:56:04 +03:00
TheMrZZandCodex gpt-6.1-sol 7f67dc8333 fix(lan): answer play ping requests on the origin connection
Keep PingRequest through held/handover filtering, handle it in Hub transport,
and exclude it from action-time updates. Preserve the existing reverse Pong path.

Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-09 16:53:54 +01:00
Horizon agentandClaude Opus 5.5 230b167744 Keep PackColors::entity_shadows a method, as the shadow ownership check reads it
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:46:36 +01:00
Mik00000 3c710450fb mobs: optimize spatial SeenGrid candidate retrieval and others_near allocations 2026-11-09 17:40:49 +02:00
MrCloudy2andClaude Opus 5.5 6a6aa75cb9 A stuck arrow falls after every non-self move, not only a push that moved it
AbstractArrow.move checks shouldFall after every Entity.move with a mover
other than the arrow, including a push limitPistonMovement or the collisions
cut to nothing. #10243's release ran only in apply_push; it is now
release_after_block_move, called after every push.
Harness: tools/test_piston_arrow_fall_parity.py; unit test
a_stuck_arrow_whose_push_a_wall_stops_falls_when_nothing_holds_it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:39:01 +01:00
Evrim CicekandClaude Opus 5.5 9b72080c32 Share the fall-reset ray between mobs and vehicles, water first
Vanilla's clip queries the block shape and the fluid shape of every
block it visits. The vehicle ray skipped the water on a tagged block, so
a waterlogged ladder or vine never fixed the shared water shape there as
vanilla's first query does; the mob, vehicle and item rays now share one
helper that queries the water first, as the player's does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:36:15 +03:00
Vinícius MoreiraandClaude Opus 5.5 ed3fabb7b4 command_exec: the command block case keeps to failed commands
A success's feedback names the player with an entity hover, a new score
holder (which changes a later scoreboard players list) or a bracketed
value, whose components the two engines build differently. The case
keeps the two failures, whose styled feedback is what the save must keep.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 12:35:40 -03:00
Horizon agentandClaude Opus 5.5 c93db2be5f Merge origin/main into the branch
The verify job compared big files against this branch's old base, so main's own growth of options_ui.rs since then failed the size check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:35:02 +01:00
MSedgeMC 83c50cf720 A sign whose messages are not a list loads an empty side
SignText.CODEC reads a side's messages as a list; any other tag fails the side
and leaves SignText.EMPTY. Horizon ignored the bad field but still applied the
side's colour and glow.
2026-11-09 15:32:32 +00:00
MSedgeMC df6c3384fb A container's loot seed is read as any number
RandomizableContainer.tryLoadLootTable reads LootTableSeed with getLongOr,
which takes any numeric tag. Horizon's loot_seed matched only a Long tag and
rolled an unseeded container from its position instead.
2026-11-09 15:31:30 +00:00
Horizon agentandClaude Opus 5.5 c71978372d rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:27:24 +01:00
Horizon agentandClaude Opus 5.5 987dc976d3 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:24:30 +01:00
Evrim CicekandClaude Opus 5.5 6187796e59 A mob resets its fall on a move of a block or more through a vine or water
Entity.move casts a ray along any move of a block or more
(ClipContext.Block.FALLDAMAGE_RESETTING, a tagged block as a full block;
ClipContext.Fluid.WATER) and a hit resets the fall before the move's
drop counts. Mob::do_move never cast it, so a mob falling fast past a
vine kept its whole fall and landed hurt (or dead).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:18:16 +03:00
Horizon agentandClaude Opus 5.5 70db97e662 Merge origin/main into #8230
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:09:45 +01:00
Horizon agentandClaude Opus 5.5 cf28dc7745 The Play with Friends screen leaves the engine library for the friends glue
friends_screen.rs moves from horizon-client-lib to game/friends/screen.rs, beside the host and
join glue; the pause menu asks friends_shown() and names the icon from game/friends (or
friends_off). Pure move, layer plan M5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:04:42 +01:00
Horizon agent e8196d58c4 Merge remote-tracking branch 'origin/main' into split/friends-tunnel-hook
# Conflicts:
#	Cargo.lock
#	tools/layers.json
2026-11-09 16:02:07 +01:00
Horizon agentandClaude Opus 5.5 0ef952921d rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:00:09 +01:00
MSedgeMC 9698eed74f Re-run checks: the previous run failed in an unrelated fixture / harness 2026-11-09 14:52:18 +00:00
Horizon agentandClaude Opus 5.5 f9dfc692e5 window::display: the screen a window is on leaves telemetry (layer plan M5)
monitor() moves from the game's telemetry module to horizon-client-input's
window module (engine), with display() giving its size, refresh rate and
scale as plain data. horizon-ctl status reads it there; telemetry's
note_display keeps the same values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:48:52 +01:00
Horizon agent 233e09221d Merge remote-tracking branch 'origin/main' into split/friends-tunnel-hook 2026-11-09 15:44:07 +01:00
Horizon agentandClaude Opus 5.5 af9edec8b9 Cargo.lock: horizon-client-lib no longer depends on horizon-friends
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:44:07 +01:00
Horizon agentandClaude Opus 5.5 2a44a86383 Pack colours through the engine's pack-format hook
layer plan M6 (pack formats), step 2: the colours a pack replaces (dyes,
sheep, redstone, effects, the experience number, entity shadows, the
effect fogs) are an engine table (pack_palette::PackColors) that
Polytone's colors.json fills through pack_hooks; the polytone_colors
re-export goes away.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:37:31 +01:00
MSedgeMC de9ab4646d port_map: the bow shooter is release_bow in game/use_hand.rs
The row's note still said the bow was fired from game.rs. release_bow is in
game/use_hand.rs after the hand split.
2026-11-09 14:35:58 +00:00
Horizon agentandClaude Opus 5.5 15da067a21 fix: deref the sky layers; rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:25:26 +01:00
Skr1zzzzandClaude Opus 5.5 2d2a2621f2 Move resetting inventory clicks and menu click sounds out of game.rs into game/menu_clicks.rs
Part of #11857. reset_inventory_clicks and player_menu_sound join game/menu_clicks.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:34 +03:00
Skr1zzzzandClaude Opus 5.5 067ca7dfb3 Move the frame's visual clocks out of game.rs into game/render_tick.rs
Part of #11857. visual_ticks, texture_clock and visual_millis join game/render_tick.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:26 +03:00
Horizon agentandClaude Opus 5.5 382776e2ee Custom sky layers through the engine's pack-format hook
layer plan M6 (pack formats), step 1: the renderer's custom sky layers are
engine types (crate::custom_sky: Blend, Draw, Conditions, the SkyLayers
trait); OptiFine's sky format fills them through crate::pack_hooks, a hook
the platform installs at startup. The OptiFine glue moves to pack_formats/,
which only it names horizon_optifine and moves to a platform crate later.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:17:38 +01:00
Horizon agentandClaude Opus 5.5 b3dd537570 Play with Friends joins through an engine tunnel hook; horizon-client-lib drops horizon-friends
The engine keeps the friend: address form and the join; a resolver registered at startup
(net::tunnel) reads invite codes and opens the tunnel. Play with Friends registers its own
(game/friends/tunnel.rs) under the friends feature. Layer plan M5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:16:26 +01:00
Horizon agentandClaude Opus 5.5 d2dbe9227c official_servers: the official and partner server entries leave multiplayer.rs, behind horizon-extras
Layer plan M5 (preparation): Horizon's entries in the multiplayer list get
their own module in horizon-client-lib, gated by a new lib feature
horizon-extras (horizon-client's horizon-extras turns it on). It touches
servers.dat only through horizon_protocol::servers, whose new lock() replaces
multiplayer.rs's private SAVING mutex, so the module can later move to a
Horizon crate as a pure file move. A plain build now neither adds the
official server nor carries the withdrawn partner's address.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:14:30 +01:00
Oloion e0b91d71bf Compare pre- and post-collision delta movement in fall flying
LivingEntity.handleFallFlyingCollisions in vanilla 26.3 compares the
horizontal movement vector passed to move() with the delta movement left
after move() (where restitution zeroes out colliding axes), not the
position delta travelled vs initial velocity.

Using post-move velocity prevents the crash sound from repeating on
subsequent ticks while pressed against a wall, while still playing the
single impact sound in both survival and creative mode.

Fixes #12304
2026-11-09 16:41:16 +03:00
Horizon agentandClaude Opus 5.5 9163335028 command_exec: the command block case scores the player, not a new holder
A fake holder (#cb) created here stays tracked after its objective is
removed, and the later /scoreboard players list then lists the holders
in another order than vanilla's hash map. Horizon's open-hash order for
that insertion is a separate bug; this case is about LastOutput.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:49:14 +01:00
Vinícius MoreiraandClaude Opus 5.5 d3392cb35b A command block saves its last output as a component
BaseCommandBlock.save writes LastOutput with ComponentSerialization.CODEC:
the time-stamped feedback component with its colours and click events.
Horizon wrote its plain characters as a string.

Fixes #13077

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:40:24 -03:00
RINNECODERandClaude Opus 5.5 26d55e5d39 Campfire smoke goes through the particle limiter
`make_campfire_smoke` made both of its particles with the engine's
`create`, which skips `ClientLevel.doAddParticle`'s limiter, so with
Particles on Minimal or Decreased, or a campfire more than 32 blocks
away, a smoking (doused) campfire still drew every plain `smoke` puff.

As `CampfireBlock.makeParticles` does, the cosy/signal smoke now goes
through `addAlwaysVisibleParticle(.., true, ..)` (`add_with(.., true,
true)`: always made, but it still rolls the Particles option) and the
plain smoke through `addParticle` (`add`).

Fixes #13663

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:59:31 -06:00
DUzzL f82a1bd4fb A cured zombie villager drops its picked-up gear instead of wearing it 2026-11-09 08:30:18 +01:00
DUzzL 60b7307b3f Break cushions in lava at the check interval 2026-11-09 08:19:30 +01:00
MSedgeMC 5ed7535acd port_map: a bee's pair of save methods
`tools/port_map/animal-1.tsv` had no answer for `Bee.addAdditionalSaveData`
or `Bee.readAdditionalSaveData`. Both now name `mobs/bee.rs::to_nbt` and
`from_nbt`: the hive and flower positions, the nectar and sting state, and
`NeutralMob`'s `anger_end_time` and `angry_at`.
2026-11-09 05:18:34 +00:00
MSedgeMC 1bb2149e88 test: an angry bee's target through the chunk save
`mob_to_nbt`/`mob_from_nbt` are the pair `Game::mob_tree_nbt` and
`Game::load_mob` go through. A bee angry at the player (`Bee.angry_at =
Some(Who::Player)`, its timer 500) saves `angry_at` as the offline player's
UUID; the reloaded bee has the same player target and the same timer. A calm
bee writes no `angry_at`, even over a compound that still names one.

Without the write the save has no `angry_at` (left `None`, right `Some(IntArray([...player...]))`),
and the reloaded bee has no target (left `None`, right `Some(Player)`).
2026-11-09 05:18:34 +00:00
MSedgeMC d59333ab3a fix: an angry bee keeps the player it stung in its chunk save
`Bee` implements `NeutralMob` and its `addAdditionalSaveData` ends with
`addPersistentAngerSaveData`, which writes `anger_end_time` and `angry_at`
(the UUID of the entity the bee is angry at). `readAdditionalSaveData` reads
both back with `readPersistentAngerSaveData`.

Horizon modelled the bee's target in `Bee.angry_at` and saved only
`anger_end_time`, so a bee that had stung a player came back after a chunk
reload with its anger timer still running but no `angry_at`:
`BeeBecomeAngryTargetGoal` asks for `Some(Who::Player)` there, saw none, and
the bee stopped attacking, while an anger at another mob was forgotten the
same way.

The save now writes `angry_at` as the target's UUID (the player's own, or the
level's answer for a mob id, nothing when there is no target), and the load
reads it back as the player or as a mob the level may not have loaded yet.
2026-11-09 05:18:34 +00:00
voidsipandCodex 4b502a3c48 fix(prepare): release locks before closing descriptors
Explicitly unlock when a preparation finishes so a descriptor retained by
a forked child cannot extend the lock lifetime. Cover retained duplicates
while preserving the existing contention test.

Co-Authored-By: Codex <[email protected]>
2026-11-03 07:36:34 -06:00
188 changed files with 7807 additions and 3775 deletions

No files matched your search

+8
View File
@@ -13,6 +13,14 @@ else (videos, Discord and other community material) lives elsewhere.
suggest something. A pull request for a roadmap item carries its `roadmap:<id>` label; ideas go to the suggestion
queue (the hub, or `/suggest` on Discord) rather than to issues, which are for bugs.
**Mod-related pull requests go to [arismoko/horizon](https://github.com/arismoko/horizon)**, the fork that carries the
mod API and reviews and merges that work. That covers the mod API, native hooks and events for mods, native mods,
content mods (`.hmod`), mixins, wasm mods, `cargo horizon-mod`, `horizon-mods`, mod builds and the registry, and
their docs ([mods](docs/mods.md), [native mods](docs/native-mods.md)). Open the pull request there from your usual
branch. Mod-related pull requests opened here are closed with a pointer to it. Engine extension points that Horizon's
own crates register (the [layer split](docs/layers.md)), resource and shader pack formats, and gameplay fixes stay
here.
## Build it
- **Prerequisites:** Rust (stable), Python 3.10+, JDK 25, and on Linux the ALSA and udev headers
Generated
+3 -7
View File
@@ -1418,6 +1418,7 @@ dependencies = [
name = "horizon-chat-filter"
version = "0.1.0"
dependencies = [
"horizon-server-files",
"serde",
"serde_json",
"toml",
@@ -1528,7 +1529,6 @@ dependencies = [
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-friends",
"horizon-hdr",
"horizon-hooks",
"horizon-items",
@@ -1592,7 +1592,6 @@ dependencies = [
"horizon-optifine",
"horizon-polytone",
"horizon-protocol",
"horizon-render-pipeline",
"horizon-shader-api",
"horizon-shaders",
"horizon-system",
@@ -1668,6 +1667,7 @@ dependencies = [
"getrandom 0.3.4",
"hkdf",
"horizon-addresses",
"horizon-platform",
"if-addrs",
"serde",
"serde_json",
@@ -1793,6 +1793,7 @@ dependencies = [
name = "horizon-official"
version = "0.1.0"
dependencies = [
"horizon-friends",
"horizon-game",
"horizon-heartbeat",
"horizon-platform",
@@ -1891,10 +1892,6 @@ dependencies = [
"ring",
]
[[package]]
name = "horizon-render-pipeline"
version = "0.1.0"
[[package]]
name = "horizon-server"
version = "0.1.0"
@@ -1957,7 +1954,6 @@ version = "0.1.0"
dependencies = [
"half",
"horizon-core",
"horizon-render-pipeline",
"naga",
"png",
"zip",
-1
View File
@@ -43,7 +43,6 @@ members = [
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-render-pipeline",
"crates/horizon-optifine",
"crates/horizon-polytone",
"crates/horizon-shader-api",
+2
View File
@@ -7,6 +7,8 @@ rust-version.workspace = true
description = "The dedicated server's chat filter: its [chat_filter] settings, the slur list's matcher and the mutes, with no game state (docs/chat-filter.md)"
[dependencies]
# The moderation durations, shared with `/tempban`.
horizon-server-files = { path = "../horizon-server-files" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1.1"
+3 -55
View File
@@ -10,6 +10,9 @@ use std::path::{Path, PathBuf};
mod normalise;
pub use horizon_server_files::duration::{
DurationUnit, duration_parts, duration_text, now_millis, parse_duration,
};
pub use normalise::{Run, Text};
/// The list built into the game, used when `words_file` is empty.
@@ -474,61 +477,6 @@ pub fn append_log(path: &Path, line: &str) -> std::io::Result<()> {
writeln!(f, "{line}")
}
/// `/tempmute`'s duration in milliseconds: a positive whole number and a unit, `s`, `m`, `h`, `d` or
/// `w` (`10m`, `2h`, `1d`).
pub fn parse_duration(text: &str) -> Option<i64> {
let unit = match text.chars().last()? {
's' => 1_000,
'm' => MINUTE,
'h' => 60 * MINUTE,
'd' => 24 * 60 * MINUTE,
'w' => 7 * 24 * 60 * MINUTE,
_ => return None,
};
let count: i64 = text[..text.len() - 1].parse().ok()?;
(count > 0).then(|| count.checked_mul(unit)).flatten()
}
/// The unit used when presenting a moderation duration to a player.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DurationUnit {
Minute,
Hour,
Day,
}
/// A duration's rounded count and unit, shared by logs and translated replies.
pub fn duration_parts(millis: i64) -> (i64, DurationUnit) {
let (unit, kind) = if millis <= 60 * MINUTE {
(MINUTE, DurationUnit::Minute)
} else if millis <= 48 * 60 * MINUTE {
(60 * MINUTE, DurationUnit::Hour)
} else {
(24 * 60 * MINUTE, DurationUnit::Day)
};
(
millis.div_euclid(unit) + i64::from(millis.rem_euclid(unit) > 0),
kind,
)
}
/// `millis` for logs, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let (count, unit) = duration_parts(millis);
let unit = match unit {
DurationUnit::Minute => "minute",
DurationUnit::Hour => "hour",
DurationUnit::Day => "day",
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_millis() as i64)
}
/// `YYYY-MM-DDTHH:MM:SSZ` for epoch milliseconds.
pub fn utc(millis: i64) -> String {
let secs = millis.div_euclid(1000);
+33
View File
@@ -670,6 +670,39 @@ pub fn with_horizon_icon(
attributes
}
/// The screen a window is on: its size in physical pixels, its refresh rate where the OS gives
/// it, and its scale. For `horizon-ctl status` and the performance statistics' `system.display`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Display {
pub width: u32,
pub height: u32,
pub refresh_hz: Option<u32>,
pub scale: f64,
}
/// The screen `window` is on, else the primary one, else the only one: right after the window
/// is made, Wayland doesn't say yet which screen it is on.
pub fn monitor(window: &winit::window::Window) -> Option<winit::monitor::MonitorHandle> {
let only = || {
let mut all = window.available_monitors();
all.next().filter(|_| all.next().is_none())
};
window
.current_monitor()
.or_else(|| window.primary_monitor())
.or_else(only)
}
/// [`monitor`]'s size, refresh rate and scale.
pub fn display(window: &winit::window::Window) -> Option<Display> {
monitor(window).map(|m| Display {
width: m.size().width,
height: m.size().height,
refresh_hz: m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
scale: m.scale_factor(),
})
}
#[cfg(test)]
mod tests {
use super::*;
+4 -5
View File
@@ -42,8 +42,6 @@ horizon-chat-filter = { path = "../horizon-chat-filter" }
horizon-shaders = { path = "../horizon-shaders" }
# Horizon's own shader packs: the format, the stages and the check (docs/shaders.md).
horizon-shader-api = { path = "../horizon-shader-api" }
# Play with Friends: invite codes and their tunnel; the `friends` feature.
horizon-friends = { path = "../horizon-friends", optional = true }
# The narrator (GameNarrator): text-to-speech of chat, system messages and screens.
horizon-narrator = { path = "../horizon-narrator" }
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
@@ -111,16 +109,17 @@ 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"]
default = ["audio", "controllers", "horizon-extras"]
# 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", "horizon-client-input/controllers"]
# Horizon's own parts (docs/forking.md, docs/layers.md): the features below, and the official
# server in the list (`official_servers`).
horizon-extras = ["discord"]
# "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"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Compatibility with earlier experiment commands: main already uses mimalloc.
-1
View File
@@ -26,7 +26,6 @@ horizon-narrator = { path = "../horizon-narrator" }
horizon-optifine = { path = "../horizon-optifine" }
horizon-polytone = { path = "../horizon-polytone" }
horizon-protocol = { path = "../horizon-protocol" }
horizon-render-pipeline = { path = "../horizon-render-pipeline" }
horizon-shader-api = { path = "../horizon-shader-api" }
horizon-shaders = { path = "../horizon-shaders" }
horizon-system = { path = "../horizon-system" }
@@ -121,8 +121,8 @@ impl CommandBlock {
condition_met: flag("conditionMet", false),
}
}
/// `CommandBlockEntity.saveAdditional`. The last output is written as a
/// plain string component.
/// `CommandBlockEntity.saveAdditional`. The last output is the component
/// itself (`ComponentSerialization.CODEC`), styles and click events kept.
pub fn save(&self) -> Vec<(&'static str, Tag)> {
let mut out = vec![
("Command", Tag::String(self.command.as_str().into())),
@@ -135,7 +135,7 @@ impl CommandBlock {
if self.track_output
&& let Some(last) = &self.last_output
{
out.push(("LastOutput", Tag::String(last.plain().as_str().into())));
out.push(("LastOutput", scoreboard::component_tag(&last.component())));
}
out.push((
"UpdateLastExecution",
+76 -204
View File
@@ -1,27 +1,34 @@
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
//! vanilla.
//! Custom sky layers: skyboxes a pack draws over the vanilla sky, in a format vanilla doesn't read
//! (OptiFine's), which the platform's pack formats fill ([`crate::pack_hooks`]). The layers'
//! textures are read once with the pack; each frame the game asks for the layers to draw, their
//! brightness and rotation ([`SkyLayers::draws`]), and `gfx::custom_sky` draws them over the
//! vanilla sky. A pack without layers keeps the vanilla sky.
use std::path::Path;
use std::sync::Arc;
use glam::{Mat4, Vec3};
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
use glam::Mat4;
/// A layer's texture, RGBA8.
/// A layer's texture, RGBA8: the skybox's six faces in a 3x2 grid, bottom, top and south in the
/// top row, west, north and east below (`gfx::custom_sky`'s faces).
pub struct Image {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
/// over all worlds, in `worlds` order).
#[derive(Default)]
pub struct CustomSky {
worlds: Vec<(i32, Vec<SkyLayer>)>,
pub images: Vec<Image>,
/// How a layer mixes with what is drawn below it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Blend {
Add,
Subtract,
Multiply,
Dodge,
Burn,
Screen,
Replace,
Overlay,
Alpha,
}
/// One layer of a frame: which texture, with which blend, how bright and turned how.
@@ -34,62 +41,44 @@ pub struct Draw {
pub rotation: Mat4,
}
/// The weather of a frame: thunder is a storm's rain, so it is only `Thunder`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Weather {
Clear,
Rain,
Thunder,
}
/// What a frame's layers may depend on.
#[derive(Clone, Copy, Debug)]
pub struct Conditions<'a> {
/// Ticks of the day, counting the days.
pub day_time: i64,
pub weather: Weather,
/// The biome at the camera, namespaced.
pub biome: &'a str,
/// The camera's block height.
pub y: i32,
}
/// The eased brightness of each layer, kept by the game between frames.
#[derive(Default)]
pub struct State {
world: Option<i32>,
eased: Vec<Transition>,
/// The world the layers were last drawn for.
pub world: Option<i32>,
/// Each layer's eased visibility, 0 to 1.
pub eased: Vec<f32>,
}
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
const TEXEL_BUDGET: u64 = 64 << 20;
impl CustomSky {
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
/// dropped with a warning.
pub fn load(root: &Path) -> Self {
let skies = Skies::scan(root);
let mut out = Self::default();
let mut texels = 0u64;
for (world, sky) in skies.worlds {
let mut layers = Vec::new();
for layer in sky.layers {
match crate::hud::decode(&root.join(&layer.source)) {
Ok((width, height, rgba))
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
{
texels += u64::from(width) * u64::from(height);
out.images.push(Image {
width,
height,
rgba,
});
layers.push(layer);
}
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
Err(e) => log::warn!("{}: {e:#}", layer.source),
}
}
if !layers.is_empty() {
out.worlds.push((world, layers));
}
}
out
}
pub fn is_empty(&self) -> bool {
self.worlds.is_empty()
}
/// Whether `world` has layers (`0` the Overworld, `1` the End).
pub fn has(&self, world: i32) -> bool {
self.worlds.iter().any(|(w, _)| *w == world)
}
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
pub fn draws(
/// A pack's custom sky layers, by world (`0` the Overworld, `1` the End).
pub trait SkyLayers: Send + Sync {
/// Every layer's texture: [`Draw::image`] indexes it.
fn images(&self) -> &[Image];
/// Whether `world` has layers.
fn has(&self, world: i32) -> bool;
/// This frame's layers of `world` into `out` (cleared first), in drawing order, eased by
/// `seconds` since the last frame. `sun_turns` is the sun's angle over 360° (`0` at noon).
fn draws(
&self,
world: i32,
conditions: &Conditions,
@@ -97,147 +86,30 @@ impl CustomSky {
seconds: f32,
state: &mut State,
out: &mut Vec<Draw>,
) {
);
}
/// No layers: the vanilla sky.
struct NoLayers;
impl SkyLayers for NoLayers {
fn images(&self) -> &[Image] {
&[]
}
fn has(&self, _: i32) -> bool {
false
}
fn draws(&self, _: i32, _: &Conditions, _: f32, _: f32, s: &mut State, out: &mut Vec<Draw>) {
out.clear();
let mut first = 0;
let mut found = None;
for (w, layers) in &self.worlds {
if *w == world {
found = Some(layers);
break;
}
first += layers.len();
}
let Some(layers) = found else {
state.world = None;
return;
};
// A new world (or pack) starts each layer where its conditions put it: no fade in.
let snap = state.world != Some(world) || state.eased.len() != layers.len();
if snap {
state.world = Some(world);
state.eased.clear();
state.eased.resize(layers.len(), Transition::default());
}
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
let shown = if snap {
eased.value = target;
target
} else {
eased.step(target, seconds, layer.transition)
};
let brightness = shown * layer.brightness(conditions.day_time);
if brightness <= 0.0 {
continue;
}
let turns = layer.turns(conditions.day_time, sun_turns);
let rotation = if turns == 0.0 {
Mat4::IDENTITY
} else {
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
};
out.push(Draw {
image: (first + i) as u16,
blend: layer.blend,
brightness,
rotation,
});
}
s.world = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::sky::Now;
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
let root = std::env::temp_dir().join(format!(
"horizon-custom-sky-{}-{}",
std::process::id(),
files.len()
));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("assets/minecraft/optifine/sky/world0");
std::fs::create_dir_all(&dir).unwrap();
let png = {
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[255, 0, 0, 255].repeat(24))
.unwrap();
writer.finish().unwrap();
bytes
};
for (name, text) in files {
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
}
let sky = CustomSky::load(&root);
(root, sky)
}
fn noon(biome: &str) -> Conditions<'_> {
Conditions {
day_time: 6000,
weather: Now::Clear,
biome,
y: 64,
}
}
#[test]
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
let (root, sky) = pack(&[
("sky1", "biomes=desert\ntransition=2"),
("sky2", "blend=alpha\nrotate=false"),
]);
let _ = std::fs::remove_dir_all(root);
assert!(sky.has(0) && !sky.has(1));
assert_eq!(sky.images[0].width, 6);
let (mut state, mut out) = (State::default(), Vec::new());
sky.draws(
0,
&noon("minecraft:desert"),
0.0,
0.05,
&mut state,
&mut out,
);
assert_eq!(
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
[1.0, 1.0]
);
// Out of the desert: the first layer fades over its two seconds.
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
assert_eq!(out[0].brightness, 0.75);
assert_eq!(out[1].blend, Blend::Alpha);
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
assert_eq!(out.len(), 1, "a dark layer is not drawn");
assert_eq!(out[0].image, 1);
// Into the End, which has no layers, and back: no fade.
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert!(out.is_empty());
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert_eq!(out.len(), 2);
}
#[test]
fn a_layer_turns_west_like_the_sun() {
let (root, sky) = pack(&[("sky1", "")]);
let _ = std::fs::remove_dir_all(root);
let (mut state, mut out) = (State::default(), Vec::new());
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
let c = Conditions {
day_time: 12000,
..noon("minecraft:plains")
};
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
let up = out[0].rotation.transform_vector3(Vec3::Y);
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
}
/// The custom sky layers of the overlay at `root`, from the installed pack formats.
pub fn load(root: &Path) -> Arc<dyn SkyLayers> {
crate::pack_hooks::formats()
.custom_sky(root)
.unwrap_or_else(|| Arc::new(NoLayers))
}
+288 -8
View File
@@ -1,5 +1,6 @@
//! The Horizon Extras page, on the settings shell (`settings_shell`): which rows it has and what
//! pressing them does. Each one's value and what it does stay in `extras::Item`.
//! pressing them does. Each one's value and what it does stay in `extras::Item`; the sections crates
//! add (`horizon_hooks::pages::Place::Extras`) keep theirs.
use crate::{
extras::{self, Item},
lang,
@@ -7,6 +8,7 @@ use crate::{
settings::Settings,
settings_shell::{self as shell, Binding, Group, Output, Row, Screen, Sheet, Value},
};
use horizon_hooks::pages::{self as added, Act, Label};
const TITLE: &str = "horizon.pages.extras";
const ABOUT: &str = "horizon.pages.extras.tooltip";
@@ -31,6 +33,121 @@ pub(crate) enum ExtraRow {
/// It isn't installed: where to get it. A note, not a button: Options > Mods already has the
/// one way into the Mods page (`every_setting_and_page_is_reached_from_one_place`).
ZoomInstall,
/// Row `.1` of the section registered page `.0` adds (`Place::Extras`).
Added(usize, usize),
}
#[cfg(test)]
thread_local! {
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
const { std::cell::Cell::new(None) };
}
/// The pages crates registered, all places.
fn pages() -> &'static [&'static dyn added::Page] {
#[cfg(test)]
if let Some(pages) = TEST_PAGES.get() {
return pages;
}
added::engine::all()
}
/// The sections of this page, by their index among the registered pages.
fn sections() -> impl Iterator<Item = (usize, &'static dyn added::Page)> {
let pages = pages().iter().copied().enumerate();
pages.filter(|(_, page)| page.place() == added::Place::Extras)
}
fn label_text(label: Label) -> String {
match label {
Label::Key(key) => lang::get(key),
Label::Value(text) => text,
}
}
/// The page's group named `name`, made at the end if it has none.
fn group_named<'a>(
groups: &'a mut Vec<Group<ExtraRow>>,
name: &'static str,
) -> &'a mut Group<ExtraRow> {
let at = groups.iter().position(|group| group.name == name);
let at = at.unwrap_or_else(|| {
groups.push(Group {
name,
rows: Vec::new(),
});
groups.len() - 1
});
&mut groups[at]
}
/// The sections' rows, in the groups their titles and headings name.
fn add_sections(groups: &mut Vec<Group<ExtraRow>>) {
for (page, section) in sections() {
let mut rows = Vec::new();
section.rows(&mut rows);
let mut name = section.title();
for (r, row) in rows.into_iter().enumerate() {
let id = ExtraRow::Added(page, r);
let row = match row {
added::Row::Heading(key) => {
name = key;
continue;
}
added::Row::Note(text) => Row::new(id, text, Value::Note).live(false),
added::Row::Toggle { label, on } => {
Row::new(id, lang::get(label), Value::Switch(on))
}
added::Row::Cycle { label, value } => {
Row::new(id, lang::get(label), Value::Choice(label_text(value)))
}
added::Row::Slider { label, value, text } => {
let position = value.clamp(0.0, 1.0);
let text = label_text(text);
let value = Value::Slider {
position,
steps: None,
text,
};
Row::new(id, lang::get(label), value)
}
added::Row::Button { label } => Row::new(id, lang::get(label), Value::Press),
added::Row::Key { label, key } => {
let key = crate::keybinds::display_name(&key).plain();
Row::new(id, lang::get(label), Value::Read(key))
}
// A row kind newer than this engine.
_ => continue,
};
let row = match section.tooltip(r) {
Some(about) => row.about(label_text(about)),
None => row,
};
group_named(groups, name).rows.push(row);
}
}
}
/// Row `r` of registered page `page` was acted on: the options screen saves when it closes.
fn act(page: usize, r: usize, act: Act) -> Vec<Output> {
let Some(section) = pages().get(page) else {
return Vec::new();
};
section.act(r, act);
vec![Output::Event(Event::Changed)]
}
/// Whether row `r` of registered page `page` steps (a switch or a cycle).
fn steps(page: usize, r: usize) -> bool {
let mut rows = Vec::new();
if let Some(section) = pages().get(page) {
section.rows(&mut rows);
}
matches!(
rows.get(r),
Some(added::Row::Toggle { .. } | added::Row::Cycle { .. })
)
}
fn item_row(item: Item, s: &Settings) -> Row<ExtraRow> {
@@ -85,9 +202,14 @@ impl Screen for ExtrasScreen {
fn note_lines(&self) -> usize {
NOTE_LINES
}
/// The rows read the extras, the mods and whether cosmetics are offered.
/// The rows read the extras, the mods, whether cosmetics are offered and the sections' rows.
fn stamp(&self, s: &Settings) -> u64 {
shell::stamp_of(&(&s.extras, &s.mods, horizon_cosmetics::enabled()))
let mut added = Vec::new();
for (_, section) in sections() {
section.rows(&mut added);
}
let cosmetics = horizon_cosmetics::enabled();
shell::stamp_of(&(&s.extras, &s.mods, cosmetics, added))
}
fn sheets(&self, s: &Settings) -> Vec<Sheet<(), ExtraRow>> {
let mut groups = vec![Group {
@@ -107,6 +229,7 @@ impl Screen for ExtrasScreen {
}
}
}
add_sections(&mut groups);
vec![Sheet::new((), lang::get(TITLE), lang::get(ABOUT), groups)]
}
fn press(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Vec<Output> {
@@ -125,16 +248,29 @@ impl Screen for ExtrasScreen {
Output::Event(Event::NativeSettings(i)),
],
ExtraRow::ZoomInstall => Vec::new(),
ExtraRow::Added(page, r) => {
act(page, r, if back { Act::PressBack } else { Act::Press })
}
}
}
fn slide(&mut self, _s: &mut Settings, row: ExtraRow, position: f32) {
if let ExtraRow::Added(page, r) = row {
act(page, r, Act::Slide(position));
}
}
/// With the wheel extra on, the wheel over an item changes it, without a click; the mod's rows
/// let the list scroll.
fn wheel_step(&mut self, s: &mut Settings, row: ExtraRow, back: bool) -> Option<Vec<Output>> {
let ExtraRow::Item(item) = row else {
return None;
};
item.cycle(&mut s.extras, back);
Some(vec![Output::Event(Event::Changed)])
match row {
ExtraRow::Item(item) => {
item.cycle(&mut s.extras, back);
Some(vec![Output::Event(Event::Changed)])
}
ExtraRow::Added(page, r) if steps(page, r) => {
Some(act(page, r, if back { Act::PressBack } else { Act::Press }))
}
_ => None,
}
}
}
@@ -292,4 +428,148 @@ mod tests {
shell.key(&mut s, K::ArrowDown, None, false, size);
assert_eq!(shell.focused_row(), Some(first));
}
/// A section with a switch in the page's Multiplayer group, then a heading of its own with a
/// number that steps either way, a note and a volume.
struct Section {
on: std::sync::Mutex<bool>,
step: std::sync::Mutex<i32>,
volume: std::sync::Mutex<f32>,
}
impl added::Page for Section {
fn id(&self) -> &'static str {
"test.section"
}
fn title(&self) -> &'static str {
"horizon.extras.header.multiplayer"
}
fn place(&self) -> added::Place {
added::Place::Extras
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::Toggle {
label: "test.section.switch",
on: *self.on.lock().unwrap(),
});
rows.push(added::Row::Heading("test.section.heading"));
rows.push(added::Row::Cycle {
label: "test.section.step",
value: Label::Value(self.step.lock().unwrap().to_string()),
});
rows.push(added::Row::Note("a note".into()));
rows.push(added::Row::Slider {
label: "test.section.volume",
value: *self.volume.lock().unwrap(),
text: Label::Value(String::new()),
});
}
fn act(&self, row: usize, act: Act) {
match (row, act) {
(0, Act::Press | Act::PressBack) => {
let mut on = self.on.lock().unwrap();
*on = !*on;
}
(2, Act::Press) => *self.step.lock().unwrap() += 1,
(2, Act::PressBack) => *self.step.lock().unwrap() -= 1,
(4, Act::Slide(v)) => *self.volume.lock().unwrap() = v,
_ => {}
}
}
}
/// A page for Horizon Settings: never on Extras.
struct Elsewhere;
impl added::Page for Elsewhere {
fn id(&self) -> &'static str {
"test.elsewhere"
}
fn title(&self) -> &'static str {
"test.elsewhere"
}
fn place(&self) -> added::Place {
added::Place::HorizonSettings
}
fn rows(&self, rows: &mut Vec<added::Row>) {
rows.push(added::Row::Button {
label: "test.elsewhere.button",
});
}
fn act(&self, _: usize, _: Act) {}
}
fn with_section() -> &'static Section {
let section: &'static Section = Box::leak(Box::new(Section {
on: false.into(),
step: 0.into(),
volume: 0.5.into(),
}));
let pages: &'static [&'static dyn added::Page] = Box::leak(Box::new([
&Elsewhere as &dyn added::Page,
section as &dyn added::Page,
]));
TEST_PAGES.set(Some(pages));
section
}
/// The section's first row joins the page's own Multiplayer group, after its rows; its heading
/// starts a group after the page's; a page for elsewhere adds nothing.
#[test]
fn a_section_joins_the_group_its_title_names_and_heads_its_own() {
with_section();
let s = Settings::default();
let sheets = ExtrasScreen.sheets(&s);
let groups = &sheets[0].groups;
let ids = |name: &str| -> Vec<ExtraRow> {
let group = groups.iter().find(|g| g.name == name).unwrap();
group.rows.iter().map(|r| r.id).collect()
};
let multiplayer = ids("horizon.extras.header.multiplayer");
assert_eq!(multiplayer.last(), Some(&ExtraRow::Added(1, 0)));
assert_eq!(groups.last().unwrap().name, "test.section.heading");
let own = [
ExtraRow::Added(1, 2),
ExtraRow::Added(1, 3),
ExtraRow::Added(1, 4),
];
assert_eq!(ids("test.section.heading"), own);
let all = rows_of(&s);
assert!(!all.iter().any(|r| matches!(r.id, ExtraRow::Added(0, _))));
}
/// A click flips the switch, a Shift click steps the number back, the wheel steps it, and the
/// note takes no click; the rows show what the section holds after each.
#[test]
fn a_sections_rows_act_on_it() {
let section = with_section();
let mut s = Settings::default();
let mut shell = Shell::<ExtrasScreen>::default();
let switch = ExtraRow::Added(1, 0);
let at = shell.point_at(&s, SIZE, switch);
assert!(
!shell
.click(&mut s, at, SIZE, false, false, false)
.is_empty()
);
assert!(*section.on.lock().unwrap());
let value = |s: &Settings, id| rows_of(s).into_iter().find(|r| r.id == id).unwrap().value;
assert_eq!(value(&s, switch), Value::Switch(true));
let step = ExtraRow::Added(1, 2);
let mut screen = ExtrasScreen;
screen.press(&mut s, step, true);
assert_eq!(value(&s, step), Value::Choice("-1".into()));
assert!(screen.wheel_step(&mut s, step, false).is_some());
assert_eq!(*section.step.lock().unwrap(), 0);
assert_eq!(
screen.wheel_step(&mut s, ExtraRow::Added(1, 3), false),
None
);
let note = shell.point_at(&s, SIZE, ExtraRow::Added(1, 3));
assert!(
shell
.click(&mut s, note, SIZE, false, false, false)
.is_empty()
);
screen.slide(&mut s, ExtraRow::Added(1, 4), 0.25);
assert_eq!(*section.volume.lock().unwrap(), 0.25);
}
}
+4 -4
View File
@@ -231,8 +231,8 @@ pub struct FogInputs<'a> {
/// `GameRenderer.nightVisionScale` while Night Vision is on without
/// Darkness, else 0 (`FogRenderer.computeFogColor`'s brightenFactor).
pub night_vision: f32,
/// The packs' Polytone `environment` fog settings.
pub environment: horizon_polytone::colors::Environment,
/// The packs' `environment` fog settings (Polytone's).
pub environment: crate::pack_palette::Environment,
}
/// `FogRenderer.setupFog` + `computeFogColor`.
@@ -581,7 +581,7 @@ mod tests {
let tables = FogTables {
water_fog_end: vec![1.0],
};
let environment = horizon_polytone::colors::Environment {
let environment = crate::pack_palette::Environment {
powder_snow_fog: Some(0xFF0000),
blindness_fog_distance: 40.0,
water_fog_brightening: 0.0,
@@ -613,7 +613,7 @@ mod tests {
}
fn inputs_with<'a>(
input: &FogInputs<'a>,
environment: horizon_polytone::colors::Environment,
environment: crate::pack_palette::Environment,
) -> FogInputs<'a> {
FogInputs {
environment,
+20 -67
View File
@@ -239,6 +239,7 @@ mod reuse;
mod stars;
mod surface_setup;
use surface_setup::{AdapterChoice, AdapterFailure, open_adapter, pick_adapter, set_up_surface};
mod off_page;
pub mod submit;
mod sunrise_fan;
mod timing;
@@ -250,6 +251,7 @@ mod wgsl_prefix;
mod world_post;
pub mod world_primitives;
mod world_warp;
use off_page::move_off_page;
/// A section's instance: its camera-relative origin and, in `w`, the step of
/// its fixed-point UVs (`terrain.wgsl`'s `terrain_uv`).
@@ -726,62 +728,6 @@ impl Part {
}
}
}
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
/// section arena, GPU culling's records (`records`) along. False when the other
/// pages have no room left.
fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
mut records: Option<&mut gpu_culling::Records>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
budget: &mut u64,
) -> bool {
debug_assert!(
records.is_none() || matches!(part, Part::Section { .. }),
"records follow the section arena's ranges"
);
let mut moved = false;
for s in meshes {
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
// range of page 0); in the other arenas, one without translucent quads.
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
continue;
};
let bytes = vertices * arena.stride();
if moved && bytes > *budget {
break;
}
let Some(to) = arena.alloc_moved(vertices) else {
return false;
};
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
let key = match part {
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
_ => to.0,
};
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
// Its solid quads start the range in either layout.
if let (Part::Section { .. }, Some(records), Some(id)) =
(part, records.as_deref_mut(), s.gpu_id)
{
records.relocate(id, to.0, u64::from(s.solid_base()));
}
*budget = budget.saturating_sub(bytes);
moved = true;
}
true
}
/// Starts moving a page of a large arena far larger than its ranges in use
/// (`Arena::oversized`) into a new page of the size they get (`Arena::open_page`),
/// for `Renderer::shrink_translucent`: the page closes and `move_off_page` empties
@@ -1329,7 +1275,7 @@ pub struct Renderer {
celestial_buffer: wgpu::Buffer,
end_sky: end_sky::EndSky,
stars: stars::Stars,
/// OptiFine custom sky layers (`crate::custom_sky`), and the frame's from the game.
/// Custom sky layers (`crate::custom_sky`), and the frame's from the game.
custom_sky: custom_sky::CustomSky,
custom_sky_draws: Vec<crate::custom_sky::Draw>,
/// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`).
@@ -5517,11 +5463,11 @@ impl Renderer {
))
});
}
/// The pack's OptiFine custom sky layer textures (`crate::custom_sky::CustomSky::load`).
pub fn set_custom_sky(&mut self, pack: &crate::custom_sky::CustomSky) {
/// The pack's custom sky layer textures (`crate::custom_sky::load`).
pub fn set_custom_sky(&mut self, pack: &dyn crate::custom_sky::SkyLayers) {
self.custom_sky.set_pack(&self.device, &self.queue, pack);
}
/// The custom sky layers the next frame draws (`CustomSky::draws`): the game refills it each
/// The custom sky layers the next frame draws (`SkyLayers::draws`): the game refills it each
/// frame, empty when the world's sky has none.
pub fn custom_sky_draws(&mut self) -> &mut Vec<crate::custom_sky::Draw> {
&mut self.custom_sky_draws
@@ -10116,7 +10062,11 @@ impl Renderer {
let pack_reads_world = pack_post || native_post;
laps.lap("r_uploads");
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
// Post effects (a resource pack's chains, `use_depth_buffer`) read the world's depth
// after the world pass, as `GameRenderer.render` hands them the main target's.
let post_reads_depth = !frame.title && !frame.post_effects.effects.is_empty();
let keep_depth = pack_reads_world
|| post_reads_depth
|| (taa && self.world_post.taa)
|| oit_on
|| native_render::render_needs().world_depth
@@ -13714,6 +13664,7 @@ mod tests {
Some(Reported {
paper_white: 240,
peak: 998,
peak_guessed: true,
})
);
assert_eq!(
@@ -13721,6 +13672,7 @@ mod tests {
Some(Reported {
paper_white: 480,
peak: 480,
peak_guessed: true,
})
);
// White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456).
@@ -13729,6 +13681,7 @@ mod tests {
Some(Reported {
paper_white: 456,
peak: 456,
peak_guessed: true,
})
);
assert_eq!(os_reported(&info(Some(1000.0), None)), None);
@@ -15419,11 +15372,9 @@ fn log_os_reported(reported: Option<crate::graphics::Reported>) {
None => "Horizon renderer: the system doesn't report the display's luminance".into(),
});
}
/// What the OS reports in absolute nits (Windows: DXGI's `MaxLuminance` and the SDR
/// white level the brightness slider sets), which is HDR output's scale there: SDR
/// white is Paper White, the display's peak is Peak Brightness. Both are kept within
/// the settings' ranges. The OS's figures often come from the display's EDID and may
/// run optimistic; the player's own value always wins.
/// What the OS reports in absolute nits, HDR output's scale (Windows: DXGI's SDR white level,
/// the brightness slider's, as Paper White, and its `MaxLuminance`, mostly the display's EDID
/// guess, as Peak Brightness), kept within the settings' ranges. The player's values win.
fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported> {
let luminance = info.luminance?;
let usable = |n: f32| n.is_finite() && n > 0.0;
@@ -15435,6 +15386,7 @@ fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported>
// Windows' SDR brightness slider can set white above the reported peak (480 against
// a calibrated 380): the highlights then stay at white instead of dropping below it.
peak: (peak.round() as u32).clamp(200, 10_000).max(paper_white),
peak_guessed: true,
})
}
@@ -15470,6 +15422,7 @@ fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
crate::graphics::Reported {
paper_white: 203,
peak: peak.clamp(200, 10_000),
peak_guessed: false,
}
}
@@ -15718,11 +15671,11 @@ mod shadow_caster_tests {
#[test]
fn the_shadow_pass_draws_terrain_outside_the_pack_s_planes() {
let kept = |near: Option<f32>, far: Option<f32>, y: f64, min: [f32; 3], depth: bool| {
let shadow = horizon_render_pipeline::Shadow {
let shadow = horizon_shaders::Shadow {
distance: 128.0,
near,
far,
..horizon_render_pipeline::Shadow::default()
..horizon_shaders::Shadow::default()
};
let camera = DVec3::new(13.5, y + 1.62, -13.5);
let view = pack_post::ShadowView {
@@ -1,12 +1,11 @@
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
//! Custom sky layers (`crate::custom_sky`) on the GPU: one texture per layer, kept with the renderer (its pack),
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
use glam::Mat4;
use horizon_optifine::sky::{Blend, Face};
use wgpu::util::DeviceExt;
use crate::custom_sky::{CustomSky as Pack, Draw};
use crate::custom_sky::{Blend, Draw, SkyLayers};
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
const MODES: [Blend; 9] = [
@@ -85,6 +84,76 @@ fn blend(mode: Blend) -> wgpu::BlendComponent {
}
}
/// A face of the skybox, as the 3x2 grid of its texture holds them: `Bottom`, `Top`, `South` in
/// the top row, `West`, `North`, `East` below.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Face {
Bottom,
Top,
South,
West,
North,
East,
}
impl Face {
const ALL: [Self; 6] = [
Self::Bottom,
Self::Top,
Self::South,
Self::West,
Self::North,
Self::East,
];
/// The face's cell of the texture, `[u0, v0, u1, v1]` in 0..1.
fn cell(self) -> [f32; 4] {
let (col, row) = match self {
Self::Bottom => (0, 0),
Self::Top => (1, 0),
Self::South => (2, 0),
Self::West => (0, 1),
Self::North => (1, 1),
Self::East => (2, 1),
};
let (u, v) = (col as f32 / 3.0, row as f32 / 2.0);
[u, v, u + 1.0 / 3.0, v + 0.5]
}
/// The face's corners (on the unit cube around the camera; x east, y up, z south) in the
/// order of its cell's `(u0, v0)`, `(u1, v0)`, `(u1, v1)`, `(u0, v1)`. The side faces are
/// seen from inside with up at the top of their cell; the top face has north at the bottom of
/// its cell, the bottom face north at the top (the template's `N` marks).
fn corners(self) -> [[f32; 3]; 4] {
match self {
// Seen looking north: west on the left.
Self::North => [
[-1., 1., -1.],
[1., 1., -1.],
[1., -1., -1.],
[-1., -1., -1.],
],
Self::South => [[1., 1., 1.], [-1., 1., 1.], [-1., -1., 1.], [1., -1., 1.]],
Self::East => [[1., 1., -1.], [1., 1., 1.], [1., -1., 1.], [1., -1., -1.]],
Self::West => [
[-1., 1., 1.],
[-1., 1., -1.],
[-1., -1., -1.],
[-1., -1., 1.],
],
// Looking up with north toward the cell's bottom edge: east on the right.
Self::Top => [[-1., 1., 1.], [1., 1., 1.], [1., 1., -1.], [-1., 1., -1.]],
// Looking down with north toward the cell's top edge: east on the right.
Self::Bottom => [
[-1., -1., -1.],
[1., -1., -1.],
[1., -1., 1.],
[-1., -1., 1.],
],
}
}
}
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
fn mesh() -> Vec<Vertex> {
let mut out = Vec::with_capacity(36);
@@ -256,10 +325,15 @@ impl CustomSky {
}
/// The pack's layer textures, replacing any before.
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
pub(super) fn set_pack(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
pack: &dyn SkyLayers,
) {
let limit = device.limits().max_texture_dimension_2d;
self.groups = pack
.images
.images()
.iter()
.map(|image| {
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
@@ -413,6 +487,38 @@ impl CustomSky {
mod tests {
use super::*;
#[test]
fn the_grid_holds_each_face_once_and_faces_point_out() {
let mut cells: Vec<_> = Face::ALL
.iter()
.map(|f| f.cell().map(|x| (x * 6.0) as i32))
.collect();
cells.dedup();
assert_eq!(cells.len(), 6);
assert_eq!(Face::East.cell(), [2.0 / 3.0, 0.5, 1.0, 1.0]);
for face in Face::ALL {
let c = face.corners();
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
let (a, b) = (sub(c[1], c[0]), sub(c[3], c[0]));
let n = [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
let centre: [f32; 3] =
std::array::from_fn(|i| c.iter().map(|p| p[i]).sum::<f32>() / 4.0);
// Seen from inside, u runs right and v down, so u x v points away from the camera.
let dot: f32 = (0..3).map(|i| n[i] * centre[i]).sum();
assert!(dot > 0.0, "{face:?} is mirrored");
}
// The top cell's bottom edge (v1) is north: its corners 2 and 3 have z = -1.
assert_eq!(Face::Top.corners()[2][2], -1.0);
assert_eq!(Face::Top.corners()[3][2], -1.0);
// The bottom cell's top edge (v0) is north.
assert_eq!(Face::Bottom.corners()[0][2], -1.0);
assert_eq!(Face::Bottom.corners()[1][2], -1.0);
}
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
@@ -6,7 +6,7 @@ struct Layer {
// Projection * view (camera-relative) * the layer's rotation.
clip_from_sky: mat4x4<f32>,
brightness: f32,
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
// crate::custom_sky::Blend, in gfx/custom_sky.rs's MODES order.
mode: u32,
pad: vec2<u32>,
hdr_settings: vec4<f32>,
@@ -338,7 +338,7 @@ pub struct NativeFrame {
reflection_sampler: wgpu::Sampler,
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
pub shadow_map: super::native_shadow::ShadowMap,
shadow_camera: Option<horizon_render_pipeline::Shadow>,
shadow_camera: Option<horizon_shaders::Shadow>,
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
/// is drawn and `has_reflection` stays 0.
pub reflects: bool,
@@ -1497,7 +1497,7 @@ pub fn frame_inputs(
size: [u32; 2],
seconds: f32,
index: u32,
shadow: Option<&horizon_render_pipeline::Shadow>,
shadow: Option<&horizon_shaders::Shadow>,
) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
@@ -6,8 +6,8 @@
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_render_pipeline::Shadow;
use horizon_shader_api::{Api, Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
use super::wgsl_prefix::{TERRAIN, prefixed, section};
@@ -0,0 +1,59 @@
//! `move_off_page`, moved out of `gfx.rs` unchanged.
use super::*;
/// `Renderer::compact_arena`'s and `shrink_translucent`'s moves: what the meshes
/// hold on `page` of `arena` (`part`) goes to its other pages in video memory,
/// until the next range would pass `budget` bytes (it keeps what is left; the
/// first range moves whatever its size, so none keeps the page closed), the
/// copies into `encoder`, the ranges' flat draw ids (`flat`) and, in the
/// section arena, GPU culling's records (`records`) along. False when the other
/// pages have no room left.
pub(super) fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
mut records: Option<&mut gpu_culling::Records>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
budget: &mut u64,
) -> bool {
debug_assert!(
records.is_none() || matches!(part, Part::Section { .. }),
"records follow the section arena's ranges"
);
let mut moved = false;
for s in meshes {
// Nothing of a mesh here: split, one without solid quads (its (0, 0) is no
// range of page 0); in the other arenas, one without translucent quads.
let Some((_, start, vertices)) = part.of(s).filter(|r| r.0 == page) else {
continue;
};
let bytes = vertices * arena.stride();
if moved && bytes > *budget {
break;
}
let Some(to) = arena.alloc_moved(vertices) else {
return false;
};
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
let key = match part {
Part::Translucent => to.0 + flat::TRANSLUCENT_KEYS,
_ => to.0,
};
flat.place(device, encoder, uploads, id, key, to.1, vertices);
}
part.set(s, to);
// Its solid quads start the range in either layout.
if let (Part::Section { .. }, Some(records), Some(id)) =
(part, records.as_deref_mut(), s.gpu_id)
{
records.relocate(id, to.0, u64::from(s.solid_base()));
}
*budget = budget.saturating_sub(bytes);
moved = true;
}
true
}
@@ -300,6 +300,7 @@ pub(crate) mod tests {
luminance: Some(Reported {
paper_white: 400,
peak: 1000,
peak_guessed: false,
}),
hdr: system,
},
@@ -650,6 +651,7 @@ pub(crate) mod tests {
window_reading.luminance = Some(Reported {
paper_white: 300,
peak: 800,
peak_guessed: false,
});
assert_eq!(window_output(window_reading).nits[..2], [300.0, 800.0]);
assert_eq!(
@@ -549,6 +549,7 @@ mod tests {
reading.luminance = Some(Reported {
paper_white: 250,
peak: 600,
peak_guessed: false,
});
// 150 nits under the reported paper white of 250: 0.6 of it.
settings.hdr_ui_nits = 150;
@@ -584,6 +585,7 @@ mod tests {
reading.luminance = Some(Reported {
paper_white,
peak: 1000,
peak_guessed: false,
});
let next = candidate(&settings, caps, reading, &published);
let again = commit(&mut swapchain, &published, next, caps, &mut rejected);
@@ -729,6 +731,7 @@ mod tests {
luminance: (hdr == Some(true)).then_some(Reported {
paper_white: 300,
peak: 800,
peak_guessed: false,
}),
hdr,
};
@@ -4,8 +4,7 @@
use std::collections::BTreeMap;
use horizon_render_pipeline::{Format, Kind};
use horizon_shaders::{BufferObject, CustomImage};
use horizon_shaders::{BufferObject, CustomImage, Format, Kind};
pub(super) struct PackImage {
pub(super) def: CustomImage,
@@ -18,8 +18,7 @@
use std::collections::HashMap;
use glam::{DMat4, DVec3};
use horizon_render_pipeline::{BUFFERS, Format, Kind, SHADOW_COLORS};
use horizon_shaders::{Group, Post};
use horizon_shaders::{BUFFERS, Format, Group, Kind, Post, SHADOW_COLORS};
use smallvec::SmallVec;
use super::pack_draw::DrawUniforms;
@@ -893,7 +892,7 @@ fn depth_linear(interface: &horizon_shaders::Interface, linear: u8) -> [f32; 4]
}
fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool) -> Source {
let shadow_color = horizon_render_pipeline::shadow_color(name);
let shadow_color = horizon_shaders::shadow_color(name);
let shadow_depth = match name {
"shadowtex0" | "watershadow" | "shadowtex0HW" => Some(0),
"shadowtex1" | "shadowtex1HW" => Some(1),
@@ -912,7 +911,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
};
}
}
if shadow_space && horizon_render_pipeline::buffer_index(name).is_some() {
if shadow_space && horizon_shaders::buffer_index(name).is_some() {
// colortex buffers are not this pass's.
return Source::Dummy;
}
@@ -926,7 +925,7 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
"normals" => Source::Normals,
"specular" => Source::Specular,
horizon_shaders::WATER_MASK => Source::WaterMask,
n => horizon_render_pipeline::buffer_index(n).map_or(Source::Dummy, Source::Color),
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1583,7 +1582,7 @@ fn bindings(
!unbound
&& (t.stage != pack_textures::stage(group)
|| released.0 != t.stage
|| horizon_render_pipeline::buffer_index(&t.name)
|| horizon_shaders::buffer_index(&t.name)
.is_none_or(|b| released.1 & 1 << b == 0))
});
let image = pack
@@ -2433,7 +2432,7 @@ impl PackPost {
let shadow_config = post.config.shadow.clone();
let noise_size = post.config.noise_resolution.unwrap_or(256);
let half_lives = post.config.half_lives;
for (name, ticks) in horizon_render_pipeline::HALF_LIVES.iter().zip(half_lives) {
for (name, ticks) in horizon_shaders::HALF_LIVES.iter().zip(half_lives) {
if let Some(t) = ticks {
notes.push(format!("`{name}` is {t} ticks"));
}
@@ -10,7 +10,7 @@
//! a fit to one driver's values, not a rule Iris states. `RGBA4` and `RGBA2` read back the `RGBA8`
//! bytes in the same probe, as Horizon's 8-bit buffers already do.
use horizon_render_pipeline::{Format, Kind};
use horizon_shaders::{Format, Kind};
use naga::{BinaryOperator, Expression, Literal, MathFunction, Statement, VectorSize};
/// The bits each channel keeps in a buffer of format `f`, for the formats that keep fewer than
@@ -3,7 +3,7 @@
//! with two sides that `shadowcomp` programs flip as composite programs flip colortex (6.1.6).
//! Square, `shadowMapResolution` pixels.
use horizon_render_pipeline::SHADOW_COLORS;
use horizon_shaders::SHADOW_COLORS;
type Ops<T> = wgpu::Operations<T>;
type Color = wgpu::Color;
@@ -97,11 +97,7 @@ pub(super) fn stand_in(device: &wgpu::Device) -> wgpu::TextureView {
}
impl ShadowTargets {
pub fn reset_history(
&mut self,
device: &wgpu::Device,
shadow: &horizon_render_pipeline::Shadow,
) {
pub fn reset_history(&mut self, device: &wgpu::Device, shadow: &horizon_shaders::Shadow) {
let mut colors = 0;
let mut doubled = 0;
let formats = std::array::from_fn(|n| match &self.color[n] {
@@ -129,7 +125,7 @@ impl ShadowTargets {
/// resolution is kept within the device's limit.
pub fn new(
device: &wgpu::Device,
shadow: &horizon_render_pipeline::Shadow,
shadow: &horizon_shaders::Shadow,
formats: [wgpu::TextureFormat; SHADOW_COLORS],
colors: u16,
doubled: u16,
@@ -1,8 +1,7 @@
//! A shader pack's custom textures (`texture.<stage>.<name>`, `texture.noise`) on the GPU, and
//! which of them a program's sampler reads.
use horizon_render_pipeline::Kind;
use horizon_shaders::{CustomTexture, Group, Texels, Texture, TextureDim, TextureSource};
use horizon_shaders::{CustomTexture, Group, Kind, Texels, Texture, TextureDim, TextureSource};
/// What a custom texture is once uploaded: an image of ours, or one the game already has.
pub(super) enum Image {
@@ -119,12 +118,12 @@ pub(super) fn find(textures: &[PackTexture], group: Group, ty: &str, name: &str)
} else {
wgpu::TextureViewDimension::D2
};
let buffer = horizon_render_pipeline::buffer_index(name);
let buffer = horizon_shaders::buffer_index(name);
let staged = textures.iter().position(|t| {
t.stage == stage(group)
&& t.dimension == dimension
&& (t.name == name
|| buffer.is_some() && horizon_render_pipeline::buffer_index(&t.name) == buffer)
|| buffer.is_some() && horizon_shaders::buffer_index(&t.name) == buffer)
});
staged.or_else(|| {
textures.iter().position(|t| {
@@ -28,7 +28,7 @@
//! [-w, w], and the depth buffers packs read hold GL window depth.
use glam::{DMat4, DVec3, Mat4, Vec3, Vec4};
use horizon_render_pipeline::Shadow;
use horizon_shaders::Shadow;
use super::Frame;
@@ -440,7 +440,7 @@ pub struct FrameUniforms {
center_depth: Option<f32>,
/// The pack's shadow settings (7.1).
pub shadow: Shadow,
/// The pack's smoothing half-lives in ticks (`horizon_render_pipeline::HALF_LIVES`).
/// The pack's smoothing half-lives in ticks (`horizon_shaders::HALF_LIVES`).
pub half_lives: [Option<f32>; 4],
}
@@ -30,6 +30,22 @@ fn marker_driver(text: &str) -> &str {
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
/// The peak is the display's own claim (DXGI's `MaxLuminance`, mostly its EDID figure),
/// which often runs high: Auto still uses it, but the HDR page asks to calibrate.
pub peak_guessed: bool,
}
/// Whether HDR is set up for the display, and whether the player kept the system's setup.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HdrSetup {
/// The system says nothing: the player calibrates.
Unknown,
/// The system says, but only guesses the peak: the player calibrates.
Guessed,
/// Peak Brightness and Paper White follow the system.
Automatic,
/// The system said, and the player changed them anyway.
Changed,
}
/// The top of Peak Brightness's scale: `peak_nits_at(PEAK_STEPS)` is PQ's 10 000 nits.
@@ -1207,6 +1223,15 @@ impl Graphics {
n => n,
}
}
/// How HDR is set up for the display the system `reported`.
pub fn hdr_setup(&self, reported: Option<Reported>) -> HdrSetup {
match reported {
None => HdrSetup::Unknown,
Some(_) if self.hdr_peak != 0 || self.hdr_paper_white != 0 => HdrSetup::Changed,
Some(r) if r.peak_guessed => HdrSetup::Guessed,
Some(_) => HdrSetup::Automatic,
}
}
/// The black level, in nits. Not the system's: files from before saved 0 for that
/// (Auto), and a reported black would lift every display's shadows by default.
pub fn black_level_nits(&self) -> f32 {
@@ -1320,6 +1345,7 @@ mod tests {
let reported = Some(Reported {
paper_white: 300,
peak: 1000,
peak_guessed: false,
});
assert_eq!(g.ui_nits(reported), 300);
g.hdr_paper_white = 120;
@@ -1328,6 +1354,33 @@ mod tests {
assert_eq!((g.ui_nits(None), g.ui_nits(reported)), (400, 400));
}
#[test]
fn hdr_setup_asks_to_calibrate_where_the_peak_is_guessed() {
let wayland = Reported {
paper_white: 203,
peak: 1015,
peak_guessed: false,
};
let windows = Reported {
paper_white: 400,
peak: 1000,
peak_guessed: true,
};
let mut g = Graphics::default();
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Automatic);
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Guessed);
// Auto still takes the guess, over the 1000-nit default.
assert_eq!(g.peak_nits(Some(windows)), 1000);
assert_eq!(g.paper_white_nits(Some(windows)), 400);
for (peak, paper_white) in [(800, 0), (0, 240)] {
(g.hdr_peak, g.hdr_paper_white) = (peak, paper_white);
assert_eq!(g.hdr_setup(Some(windows)), HdrSetup::Changed);
assert_eq!(g.hdr_setup(Some(wayland)), HdrSetup::Changed);
assert_eq!(g.hdr_setup(None), HdrSetup::Unknown);
}
}
#[test]
fn hdr_modes_keep_boolean_files_and_round_trip_auto() {
assert_eq!(Graphics::parse("").hdr, HdrMode::Off);
+11 -23
View File
@@ -8,7 +8,8 @@ use crate::{
gfx::EffectiveOutput,
graphics::{
EntityCullingMode, FarDetail, FrameGeneration, GpuCullingMode, GraphicsApi, HdrMode,
LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode, Preset, Upscaling,
HdrSetup as Setup, LeafCullingMode, Overlay, Pacing, ParticleCullingMode, PresentMode,
Preset, Upscaling,
},
graphics_style::Cost,
lang,
@@ -675,28 +676,9 @@ const LOD_STEPS: [u32; 24] = [
crate::lod::MAX_CHUNKS,
];
/// Whether the system set HDR up for the display, and whether the player kept it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Setup {
/// The system says nothing: the player calibrates.
Unknown,
/// Peak Brightness and Paper White follow the system.
Automatic,
/// The system said, and the player changed them anyway.
Changed,
}
/// `out`: the output of the renderer the settings are shown for, with what its display reports.
fn setup(s: &Settings, out: &EffectiveOutput) -> Setup {
setup_of(&s.horizon, out.reported.is_some())
}
fn setup_of(g: &crate::graphics::Graphics, reported: bool) -> Setup {
if !reported {
Setup::Unknown
} else if g.hdr_peak == 0 && g.hdr_paper_white == 0 {
Setup::Automatic
} else {
Setup::Changed
}
s.horizon.hdr_setup(out.reported)
}
/// 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`).
@@ -714,13 +696,19 @@ fn description(
(Item::Horizon(H::HdrCalibrate), Setup::Unknown) => {
"Your display didn't say how bright it gets: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Guessed) => {
"Your display's reported peak is often too high: calibrate to set it up."
}
(Item::Horizon(H::HdrCalibrate), Setup::Automatic) => {
"Already set up for your display. Only calibrate if HDR looks wrong."
}
(Item::Horizon(H::HdrCalibrate), Setup::Changed) => {
"Puts Peak Brightness and Paper White back to your display's setup."
}
(Item::Horizon(H::HdrPeak | H::HdrPaperWhite), Setup::Automatic | Setup::Changed) => {
// A guessed peak's Auto is what calibration replaced; paper white is the player's own.
(Item::Horizon(h @ (H::HdrPeak | H::HdrPaperWhite)), Setup::Automatic | Setup::Changed)
if h == H::HdrPaperWhite || out.reported.is_some_and(|r| !r.peak_guessed) =>
{
"Set up for your display. Leave on Auto unless it looks wrong."
}
(Item::Horizon(H::GpuCulling), _) if s.horizon.gpu_culling == GpuCullingMode::Auto => {
@@ -944,7 +932,7 @@ impl H {
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
H::Hdr => labelled("HDR", g.hdr.label()),
H::HdrCalibrate => Text::literal(lang::get(match setup(s, out) {
Setup::Unknown => "Calibrate...",
Setup::Unknown | Setup::Guessed => "Calibrate...",
Setup::Automatic => "Calibrate Anyway...",
Setup::Changed => "Use Automatic Setup",
})),
@@ -120,14 +120,86 @@ fn auto_levels_and_setup_read_their_own_renderers_report() {
assert_eq!(label(H::HdrCalibrate, &silent), "Calibrate...");
}
/// The setup rows' label and description for each kind of report: none, a compositor's
/// (Wayland), and Windows', whose peak is the display's own guess.
#[test]
fn hdr_setup_follows_what_the_system_reported() {
let mut g = crate::graphics::Graphics::default();
assert_eq!(setup_of(&g, false), Setup::Unknown);
assert_eq!(setup_of(&g, true), Setup::Automatic);
g.hdr_paper_white = 80;
assert_eq!(setup_of(&g, true), Setup::Changed);
assert_eq!(setup_of(&g, false), Setup::Unknown);
fn the_setup_rows_ask_to_calibrate_unless_the_system_set_the_display_up() {
let trusted = offering(true, Some(true));
let mut guessed = trusted;
guessed.reported = guessed.reported.map(|r| crate::graphics::Reported {
peak_guessed: true,
..r
});
let rows = |s: &Settings, out: &EffectiveOutput| {
[H::HdrCalibrate, H::HdrPeak, H::HdrPaperWhite].map(|h| {
let item = Item::Horizon(h);
(item.label(s, out).plain(), description(item, s, out, "-"))
})
};
let calibrate = "Calibrate...";
let set_up = "Set up for your display. Leave on Auto unless it looks wrong.";
let mut s = Settings::default();
s.horizon.hdr = HdrMode::On;
let none = (
calibrate.into(),
"Your display didn't say how bright it gets: calibrate to set it up.",
);
assert_eq!(
rows(&s, &NONE),
[
none.clone(),
("Peak Brightness: Auto (1000)".into(), "-"),
("Paper White: Auto (203)".into(), "-"),
]
);
assert_eq!(
rows(&s, &guessed),
[
(
calibrate.into(),
"Your display's reported peak is often too high: calibrate to set it up."
),
("Peak Brightness: Auto (1000)".into(), "-"),
("Paper White: Auto (400)".into(), "-"),
]
);
assert_eq!(
rows(&s, &trusted),
[
(
"Calibrate Anyway...".into(),
"Already set up for your display. Only calibrate if HDR looks wrong."
),
("Peak Brightness: Auto (1000)".into(), set_up),
("Paper White: Auto (400)".into(), set_up),
]
);
// Calibrated or set by hand, either report offers its automatic setup back.
s.horizon.hdr_peak = 800;
// Peak's Auto stays a guess on Windows: only Paper White is said to be set up there.
let changed = |peak| {
[
(
"Use Automatic Setup".into(),
"Puts Peak Brightness and Paper White back to your display's setup.",
),
("Peak Brightness: 800".into(), peak),
("Paper White: Auto (400)".into(), set_up),
]
};
assert_eq!(rows(&s, &guessed), changed("-"));
assert_eq!(rows(&s, &trusted), changed(set_up));
assert_eq!(rows(&s, &NONE)[0], none);
let mut ui = ui();
ui.screen.output = guessed;
let at = |[x, y, ..]: [f32; 4]| (x + 8.0, y + 4.0);
let calibrate = row(&mut ui, &s, Item::Horizon(H::HdrCalibrate));
assert_eq!(press(&mut ui, &mut s, at(calibrate)), [CLICK, CHANGED]);
assert_eq!((s.horizon.hdr_peak, s.horizon.hdr_paper_white), (0, 0));
assert_eq!(
press(&mut ui, &mut s, at(calibrate)),
[CLICK, Output::Push(OptionsPage::HdrCalibration)]
);
}
/// UI Brightness reads as Paper White does, in nits apart from it: Auto shows the paper white
+2 -1
View File
@@ -67,7 +67,6 @@ 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;
@@ -89,6 +88,8 @@ pub mod options_fixes;
pub mod options_ui;
pub mod overlay;
pub mod pack_browser;
pub mod pack_formats;
pub mod pack_hooks;
pub mod pack_options_ui;
pub mod pack_palette;
pub mod pack_screen;
+2 -2
View File
@@ -2593,8 +2593,7 @@ impl Lod {
self.returned_at = std::time::Instant::now();
let nodes = &self.plan.nodes;
self.mesh_samples.retain(|key| nodes.contains(key));
// Queued tiles the view left are not sampled (a running one is
// dropped when it finishes, `finish_sample`).
// Queued tiles the view left are not sampled; a running one is dropped (`finish_sample`).
drop_queued_samples(&self.shared, &mut self.pending, |key| {
self.plan.nodes.contains(key)
});
@@ -2680,6 +2679,7 @@ impl Lod {
}
if let Some((columns, mask)) = self.kept.take(&key) {
self.ready.insert(key, columns);
self.sync_sample(key);
if let Some(mask) = mask {
self.detail_masks.insert(key, mask);
}
+10 -24
View File
@@ -339,6 +339,7 @@ impl Opt {
Opt::SharePresence => "options.sharePresence",
Opt::DirectionalAudio => "options.directionalAudio",
Opt::MusicToast => "options.musicToast",
Opt::Page(page, row) => pages::slider_caption(page, row),
}
}
pub(crate) fn kind(self, s: &Settings) -> Kind {
@@ -402,6 +403,7 @@ impl Opt {
Opt::PadLookDeadzone => unit(s.controller.look_deadzone, 0.0, 0.5),
Opt::PadTriggerThreshold => unit(s.controller.trigger_threshold, 0.1, 0.9),
Opt::SprintWindow => unit(s.sprint_window as f32, 0.0, 10.0),
Opt::Page(page, row) => pages::slider_kind(page, row),
_ => Kind::Cycle,
}
}
@@ -878,6 +880,7 @@ impl Opt {
gamepad::TextKeyboard::Horizon => "horizon.options.keyboard.horizon",
gamepad::TextKeyboard::Steam => "horizon.options.keyboard.steam",
}),
Opt::Page(page, row) => pages::slider_text(page, row),
_ => Text::empty(),
}
}
@@ -952,6 +955,7 @@ impl Opt {
Opt::PadLookDeadzone => s.controller.look_deadzone = step(0.0, 50.0) / 100.0,
Opt::PadTriggerThreshold => s.controller.trigger_threshold = step(10.0, 90.0) / 100.0,
Opt::SprintWindow => s.sprint_window = step(0.0, 10.0) as i32,
Opt::Page(page, row) => pages::slide(page, row, v),
_ => {}
}
if self.affects_preset() {
@@ -1624,8 +1628,6 @@ pub struct OptionsUi {
/// The native mod's key waiting: (mod, key) in `mods.installed`, or a registered page's
/// (page, row) ([`pages::PAGE_KEY`]).
selected_native_key: Option<(usize, usize)>,
/// The pages crates registered (`horizon_hooks::pages`).
pages: &'static [&'static dyn horizon_hooks::pages::Page],
/// Bindings: the action waiting for a pad button.
pub selected_pad_action: Option<&'static str>,
/// The glyphs menus show (`Glyphs::of`: the setting, else the pad's family, and a Nintendo
@@ -1737,7 +1739,6 @@ impl Default for OptionsUi {
scrollbar_drag_y: None,
selected_key: None,
selected_native_key: None,
pages: horizon_hooks::pages::engine::all(),
selected_pad_action: None,
pad_glyphs: crate::pad_glyph::Glyphs::default(),
pad_driving: false,
@@ -1869,25 +1870,6 @@ fn wide_extras(s: &Settings, width: impl Fn(&str) -> f32) -> Vec<crate::extras::
})
.collect()
}
/// What a button opening one of Horizon's pages says on hover.
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Some(match page {
Page::Horizon => "horizon.pages.settings.tooltip",
Page::Studio => "horizon.pages.studio.tooltip",
Page::Graphics => "horizon.pages.graphics.tooltip",
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
Page::Controller => "horizon.pages.controller.tooltip",
Page::Extras => "horizon.pages.extras.tooltip",
Page::Pvp => "horizon.pages.pvp.tooltip",
Page::Agent => "horizon.pages.agent.tooltip",
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
Page::Music => "horizon.pages.music.tooltip",
_ => return None,
})
}
/// Rows of an OptionsList: (big?, options) or a header.
enum Row {
Big(Opt),
@@ -1907,14 +1889,17 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
return None;
};
let key = opt.tooltip_key(s);
return lang::has(&key).then(|| tr(&key));
let Opt::Page(page, row) = opt else {
return lang::has(&key).then(|| tr(&key));
};
return pages::tooltip(*page, *row);
};
let mut tip = None;
if let Act::Extra(item) = act {
tip = Some(Text::literal(item.description_text()));
}
if let Act::Push(page) = act
&& let Some(text) = horizon_page_tooltip(*page)
&& let Some(text) = pages::horizon_page_tooltip(*page)
{
tip = Some(tr(text));
}
@@ -1934,6 +1919,7 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
}
match act {
Act::Cycle(opt) if !opt.available() => Some(tr("horizon.options.unavailable")),
Act::PageRow(page, row) => pages::tooltip(*page, *row),
Act::ToggleMod(i) => match s.mods.installed.get(*i) {
Some(installed) => {
let description = installed
@@ -8,9 +8,88 @@ use horizon_hooks::pages as added;
/// `selected_key` while a registered page's key waits (`selected_native_key`: page, row).
pub(super) const PAGE_KEY: &str = "horizon.pageKey";
#[cfg(test)]
thread_local! {
/// A test's own pages, instead of the game's registered ones: each test runs on its thread.
static TEST_PAGES: std::cell::Cell<Option<&'static [&'static dyn added::Page]>> =
const { std::cell::Cell::new(None) };
}
/// The pages crates registered.
fn registered() -> &'static [&'static dyn added::Page] {
#[cfg(test)]
if let Some(pages) = TEST_PAGES.get() {
return pages;
}
added::engine::all()
}
/// A registered page's title (a translation key).
pub(super) fn title(page: usize) -> &'static str {
added::engine::all().get(page).map_or("", |p| p.title())
registered().get(page).map_or("", |p| p.title())
}
/// Row `r` of a registered page, as it is now.
fn row(page: usize, r: usize) -> Option<added::Row> {
let mut rows = Vec::new();
registered().get(page)?.rows(&mut rows);
rows.into_iter().nth(r)
}
/// A registered page's slider ([`Opt::Page`]): its caption (a translation key).
pub(super) fn slider_caption(page: usize, r: usize) -> &'static str {
match row(page, r) {
Some(added::Row::Slider { label, .. }) => label,
_ => "",
}
}
/// A registered page's slider: its position.
pub(super) fn slider_kind(page: usize, r: usize) -> Kind {
match row(page, r) {
Some(added::Row::Slider { value, .. }) => Kind::Slider(value.clamp(0.0, 1.0)),
_ => Kind::Cycle,
}
}
/// A registered page's slider: the value its message shows after the caption.
pub(super) fn slider_text(page: usize, r: usize) -> Text {
match row(page, r) {
Some(added::Row::Slider { text, .. }) => label_text(text),
_ => Text::empty(),
}
}
/// A registered page's slider moved.
pub(super) fn slide(page: usize, r: usize, value: f32) {
if let Some(registered) = registered().get(page) {
registered.act(r, added::Act::Slide(value));
}
}
/// What a registered page's row says on hover.
pub(super) fn tooltip(page: usize, r: usize) -> Option<Text> {
registered().get(page)?.tooltip(r).map(label_text)
}
/// What a button opening one of Horizon's pages says on hover.
pub(super) fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Some(match page {
Page::Horizon => "horizon.pages.settings.tooltip",
Page::Studio => "horizon.pages.studio.tooltip",
Page::Graphics => "horizon.pages.graphics.tooltip",
Page::GraphicsAdvanced => "horizon.pages.graphics_advanced.tooltip",
Page::Controller => "horizon.pages.controller.tooltip",
Page::Extras => "horizon.pages.extras.tooltip",
Page::Pvp => "horizon.pages.pvp.tooltip",
Page::Agent => "horizon.pages.agent.tooltip",
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
Page::Music => "horizon.pages.music.tooltip",
_ => return None,
})
}
fn label_text(label: added::Label) -> Text {
@@ -66,8 +145,7 @@ impl OptionsUi {
/// The entries of the pages registered for Horizon Settings, two to a row like the engine's.
fn added_entries(&self, rows: &mut Vec<Row>) {
let mut entries = self
.pages
let mut entries = registered()
.iter()
.enumerate()
.filter(|(_, page)| page.place() == added::Place::HorizonSettings)
@@ -79,7 +157,7 @@ impl OptionsUi {
/// A registered page's rows, asked of it each time the page is drawn.
pub(super) fn added_rows(&self, page: usize) -> Vec<Row> {
let Some(registered) = self.pages.get(page) else {
let Some(registered) = registered().get(page) else {
return Vec::new();
};
let mut rows = Vec::new();
@@ -98,6 +176,7 @@ impl OptionsUi {
added::Row::Note(text) => Row::Note(text),
added::Row::Toggle { label, on } => wide(generic(label, on_off(on))),
added::Row::Cycle { label, value } => wide(generic(label, label_text(value))),
added::Row::Slider { .. } => Row::Big(Opt::Page(page, r)),
added::Row::Button { label } => wide(tr(label)),
added::Row::Key { label, key } => {
let mut message = keybinds::display_name(&key);
@@ -120,7 +199,7 @@ impl OptionsUi {
/// A registered page's row was pressed: a key row waits for a key, any other row hears it.
pub(super) fn page_act(&mut self, page: usize, r: usize, shift: bool) -> Vec<Event> {
let Some(registered) = self.pages.get(page) else {
let Some(registered) = registered().get(page) else {
return Vec::new();
};
let mut rows = Vec::new();
@@ -199,7 +278,7 @@ impl OptionsUi {
) -> Vec<Event> {
let (i, k) = self.selected_native_key.take().unwrap_or_default();
if name == PAGE_KEY {
if let Some(page) = self.pages.get(i) {
if let Some(page) = registered().get(i) {
page.act(k, added::Act::Bind(value));
}
return vec![Event::Changed];
@@ -221,11 +300,12 @@ mod tests {
use super::*;
use std::sync::Mutex;
/// A page with a heading, a switch, a number that steps either way and a key.
/// A page with a heading, a switch, a number that steps either way, a key and a volume.
struct Sample {
on: Mutex<bool>,
step: Mutex<i32>,
key: Mutex<String>,
volume: Mutex<f32>,
}
impl added::Page for Sample {
@@ -252,6 +332,12 @@ mod tests {
label: "test.sample.key",
key: self.key.lock().unwrap().clone(),
});
let volume = *self.volume.lock().unwrap();
rows.push(added::Row::Slider {
label: "test.sample.volume",
value: volume,
text: added::Label::Value(format!("{}%", (volume * 100.0).round())),
});
}
fn act(&self, row: usize, act: added::Act) {
match (row, act) {
@@ -262,15 +348,20 @@ mod tests {
(2, added::Act::Press) => *self.step.lock().unwrap() += 1,
(2, added::Act::PressBack) => *self.step.lock().unwrap() -= 1,
(3, added::Act::Bind(key)) => *self.key.lock().unwrap() = key,
(4, added::Act::Slide(v)) => *self.volume.lock().unwrap() = v,
other => panic!("row and act the page doesn't expect: {other:?}"),
}
}
fn tooltip(&self, row: usize) -> Option<added::Label> {
(row == 1).then_some(added::Label::Key("test.sample.switch.tooltip"))
}
}
static SAMPLE: Sample = Sample {
on: Mutex::new(false),
step: Mutex::new(0),
key: Mutex::new(String::new()),
volume: Mutex::new(0.0),
};
static PAGES: [&dyn added::Page; 1] = [&SAMPLE];
@@ -297,10 +388,8 @@ mod tests {
#[test]
fn a_registered_page_opens_from_horizon_settings_and_hears_its_rows() {
let mut s = Settings::default();
let mut ui = OptionsUi {
pages: &PAGES,
..OptionsUi::default()
};
TEST_PAGES.set(Some(&PAGES));
let mut ui = OptionsUi::default();
ui.show(Page::Horizon);
press(&mut ui, &mut s, Act::Push(Page::Added(0)), false);
assert_eq!(ui.page(), Page::Added(0));
@@ -323,5 +412,53 @@ mod tests {
assert_eq!(events, [Event::Changed]);
assert_eq!(*SAMPLE.key.lock().unwrap(), "key.keyboard.z");
assert_eq!(ui.selected_key, None);
// The volume is one of the screen's own sliders: dragged to its middle, then let go.
let size = (480.0, 240.0);
let (widgets, ..) = ui.layout(&s, size.0, size.1);
let slider = widgets
.iter()
.find(|w| {
matches!(
w.kind,
WKind::Slider {
opt: Opt::Page(0, 4),
..
}
)
})
.expect("the volume's slider");
let (x, y) = (slider.x + slider.w / 2.0, slider.y + 1.0);
ui.click(&mut s, &atlas, x, y, size, [480, 240], false, false);
let volume = *SAMPLE.volume.lock().unwrap();
assert!((0.45..0.55).contains(&volume), "{volume}");
assert!(ui.release().contains(&Event::Changed));
// The slider shows the page's value once let go.
let (widgets, ..) = ui.layout(&s, size.0, size.1);
let shown = widgets.iter().find_map(|w| match w.kind {
WKind::Slider {
opt: Opt::Page(0, 4),
value,
..
} => Some(value),
_ => None,
});
assert_eq!(shown, Some(volume));
// The page's tooltip for its switch.
let switch = widgets
.iter()
.find(|w| {
matches!(
&w.kind,
WKind::Button {
act: Act::PageRow(0, 1),
..
}
)
})
.expect("the switch");
assert_eq!(
tooltip_text(&s, switch).map(|t| t.plain()),
Some(tr("test.sample.switch.tooltip").plain())
);
}
}
@@ -0,0 +1,67 @@
//! Polytone's custom colours (`horizon_polytone::colors`, `colors.json` or `color.properties`)
//! as the engine's pack colours ([`crate::pack_palette::PackColors`]).
use std::path::Path;
use horizon_polytone::colors::PackColors;
use crate::block_entity_render::DYES;
use crate::pack_palette::{self, Environment};
/// The colours of every namespace's file under the pack overlay at `root`.
pub fn read(root: &Path) -> pack_palette::PackColors {
let colors = PackColors::read(root);
let by_dye = |f: &dyn Fn(&str) -> Option<u32>| std::array::from_fn(|i| f(DYES[i].0));
let env = colors.env;
pack_palette::PackColors {
dye_diffuse: by_dye(&|name| colors.dye(name, "diffuse")),
dye_text: by_dye(&|name| colors.dye(name, "text")),
sheep: by_dye(&|name| colors.sheep(name)),
redstone: std::array::from_fn(|i| colors.redstone(i as u8)),
effects: colors.effects(),
entity_shadows: colors.entity_shadows(),
xp_bar: colors.xp_bar(),
env: Environment {
powder_snow_fog: env.powder_snow_fog,
blindness_fog_distance: env.blindness_fog_distance,
darkness_fog_distance: env.darkness_fog_distance,
boss_fog_distance: env.boss_fog_distance,
water_fog_brightening: env.water_fog_brightening,
water_fog_brightening_time: env.water_fog_brightening_time,
horizon_height: env.horizon_height,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pack_palette::{diffuse, redstone, sheep_wool, text};
#[test]
fn a_pack_recolours_only_what_it_names() {
// Dyes no other test draws: the table is process-wide.
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
let polytone = dir.join("assets/obs/polytone");
std::fs::create_dir_all(&polytone).unwrap();
std::fs::write(
polytone.join("colors.json"),
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
)
.unwrap();
crate::pack_palette::set(&read(&dir));
let (cyan, pink, purple) = (9, 6, 10);
assert_eq!(diffuse(cyan), 0x00ff00);
assert_eq!(text(cyan), 0x0000ff);
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
assert_eq!(sheep_wool(cyan as u8), 0x107575);
assert_eq!(diffuse(purple), 0x8932B8);
assert_eq!(text(purple), 0xA020F0);
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
crate::pack_palette::set(&Default::default());
assert_eq!(redstone(7), None);
assert_eq!(diffuse(cyan), 0x169C9C);
assert_eq!(sheep_wool(pink as u8), 0xB6687F);
std::fs::remove_dir_all(&dir).ok();
}
}
@@ -0,0 +1,252 @@
//! OptiFine custom skies (`horizon_optifine::sky`) on the engine's sky layers
//! ([`crate::custom_sky`]): a pack's layers with their decoded textures, read once with the pack,
//! and each frame's choice of layers, their brightness and rotation. A pack without
//! `optifine/sky` has none, and the sky stays vanilla.
use std::path::Path;
use glam::{Mat4, Vec3};
use horizon_optifine::sky::{Blend, Conditions, Now, Skies, SkyLayer, Transition};
use crate::custom_sky::{self as engine, Draw, Image, SkyLayers, State};
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
/// over all worlds, in `worlds` order).
#[derive(Default)]
pub struct CustomSky {
worlds: Vec<(i32, Vec<SkyLayer>)>,
pub images: Vec<Image>,
}
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
const TEXEL_BUDGET: u64 = 64 << 20;
impl CustomSky {
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
/// dropped with a warning.
pub fn load(root: &Path) -> Self {
let skies = Skies::scan(root);
let mut out = Self::default();
let mut texels = 0u64;
for (world, sky) in skies.worlds {
let mut layers = Vec::new();
for layer in sky.layers {
match crate::hud::decode(&root.join(&layer.source)) {
Ok((width, height, rgba))
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
{
texels += u64::from(width) * u64::from(height);
out.images.push(Image {
width,
height,
rgba,
});
layers.push(layer);
}
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
Err(e) => log::warn!("{}: {e:#}", layer.source),
}
}
if !layers.is_empty() {
out.worlds.push((world, layers));
}
}
out
}
pub fn is_empty(&self) -> bool {
self.worlds.is_empty()
}
}
/// The engine's sky blend for OptiFine's.
fn blend(blend: Blend) -> engine::Blend {
match blend {
Blend::Add => engine::Blend::Add,
Blend::Subtract => engine::Blend::Subtract,
Blend::Multiply => engine::Blend::Multiply,
Blend::Dodge => engine::Blend::Dodge,
Blend::Burn => engine::Blend::Burn,
Blend::Screen => engine::Blend::Screen,
Blend::Replace => engine::Blend::Replace,
Blend::Overlay => engine::Blend::Overlay,
Blend::Alpha => engine::Blend::Alpha,
}
}
impl SkyLayers for CustomSky {
fn images(&self) -> &[Image] {
&self.images
}
/// Whether `world` has layers (`0` the Overworld, `1` the End).
fn has(&self, world: i32) -> bool {
self.worlds.iter().any(|(w, _)| *w == world)
}
/// The layers whose conditions hold, eased over their `transition`, at the brightness of
/// their fade times.
fn draws(
&self,
world: i32,
conditions: &engine::Conditions,
sun_turns: f32,
seconds: f32,
state: &mut State,
out: &mut Vec<Draw>,
) {
let conditions = &Conditions {
day_time: conditions.day_time,
weather: match conditions.weather {
engine::Weather::Clear => Now::Clear,
engine::Weather::Rain => Now::Rain,
engine::Weather::Thunder => Now::Thunder,
},
biome: conditions.biome,
y: conditions.y,
};
out.clear();
let mut first = 0;
let mut found = None;
for (w, layers) in &self.worlds {
if *w == world {
found = Some(layers);
break;
}
first += layers.len();
}
let Some(layers) = found else {
state.world = None;
return;
};
// A new world (or pack) starts each layer where its conditions put it: no fade in.
let snap = state.world != Some(world) || state.eased.len() != layers.len();
if snap {
state.world = Some(world);
state.eased.clear();
state.eased.resize(layers.len(), 0.0);
}
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
let shown = if snap {
*eased = target;
target
} else {
let mut t = Transition { value: *eased };
*eased = t.step(target, seconds, layer.transition);
*eased
};
let brightness = shown * layer.brightness(conditions.day_time);
if brightness <= 0.0 {
continue;
}
let turns = layer.turns(conditions.day_time, sun_turns);
let rotation = if turns == 0.0 {
Mat4::IDENTITY
} else {
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
};
out.push(Draw {
image: (first + i) as u16,
blend: blend(layer.blend),
brightness,
rotation,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::custom_sky::{Conditions, Weather};
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
let root = std::env::temp_dir().join(format!(
"horizon-custom-sky-{}-{}",
std::process::id(),
files.len()
));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("assets/minecraft/optifine/sky/world0");
std::fs::create_dir_all(&dir).unwrap();
let png = {
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[255, 0, 0, 255].repeat(24))
.unwrap();
writer.finish().unwrap();
bytes
};
for (name, text) in files {
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
}
let sky = CustomSky::load(&root);
(root, sky)
}
fn noon(biome: &str) -> Conditions<'_> {
Conditions {
day_time: 6000,
weather: Weather::Clear,
biome,
y: 64,
}
}
#[test]
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
let (root, sky) = pack(&[
("sky1", "biomes=desert\ntransition=2"),
("sky2", "blend=alpha\nrotate=false"),
]);
let _ = std::fs::remove_dir_all(root);
assert!(sky.has(0) && !sky.has(1));
assert_eq!(sky.images[0].width, 6);
let (mut state, mut out) = (State::default(), Vec::new());
sky.draws(
0,
&noon("minecraft:desert"),
0.0,
0.05,
&mut state,
&mut out,
);
assert_eq!(
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
[1.0, 1.0]
);
// Out of the desert: the first layer fades over its two seconds.
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
assert_eq!(out[0].brightness, 0.75);
assert_eq!(out[1].blend, engine::Blend::Alpha);
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
assert_eq!(out.len(), 1, "a dark layer is not drawn");
assert_eq!(out[0].image, 1);
// Into the End, which has no layers, and back: no fade.
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert!(out.is_empty());
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert_eq!(out.len(), 2);
}
#[test]
fn a_layer_turns_west_like_the_sun() {
let (root, sky) = pack(&[("sky1", "")]);
let _ = std::fs::remove_dir_all(root);
let (mut state, mut out) = (State::default(), Vec::new());
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
let c = Conditions {
day_time: 12000,
..noon("minecraft:plains")
};
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
let up = out[0].rotation.transform_vector3(Vec3::Y);
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
}
}
@@ -0,0 +1,146 @@
//! Polytone's item modifiers' durability bar colours, on the engine's
//! [`crate::pack_palette::ItemBars`].
use std::path::Path;
use std::sync::Arc;
/// 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>,
}
/// The packs' damage bars, the last read first.
struct Bars(Vec<DamageBar>);
impl crate::pack_palette::ItemBars for Bars {
fn color(&self, item: &str, damage: f32) -> Option<u32> {
let bar = self
.0
.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])
}
}
/// 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. `None` without any.
pub fn read(root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
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);
}
}
}
}
(!bars.is_empty()).then(|| Arc::new(Bars(bars)) as _)
}
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)?,
})
}
#[cfg(test)]
mod tests {
use super::*;
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]]);
let bars = read(&dir).unwrap();
// The real mod: damage 200 of 1561 shows the first pixel, 1400 the last.
assert_eq!(
bars.color("minecraft:diamond_pickaxe", 200.0 / 1561.0),
Some(0xFF0000)
);
assert_eq!(
bars.color("minecraft:diamond_pickaxe", 1400.0 / 1561.0),
Some(0xFFFF00)
);
assert_eq!(bars.color("minecraft:iron_axe", 0.9), Some(0x040506));
assert_eq!(bars.color("minecraft:diamond_shovel", 0.5), None);
assert!(read(&dir.join("none")).is_none());
std::fs::remove_dir_all(&dir).ok();
}
}
@@ -0,0 +1,36 @@
//! The resource pack formats vanilla doesn't read (OptiFine's, Polytone's) on the engine's
//! extension points ([`crate::pack_hooks`]). This is platform code (docs/layers.md) kept in this
//! crate until the platform's executable installs it: it uses only the crate's public API, so it
//! moves to a platform crate unchanged, and nothing else in the crate names it.
mod colors;
mod custom_sky;
mod item_bars;
use std::path::Path;
use std::sync::Arc;
use crate::pack_hooks::PackFormats;
/// OptiFine's and Polytone's formats.
pub struct Formats;
impl PackFormats for Formats {
fn colors(&self, root: &Path) -> Option<crate::pack_palette::PackColors> {
Some(colors::read(root))
}
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
let sky = custom_sky::CustomSky::load(root);
(!sky.is_empty()).then(|| Arc::new(sky) as _)
}
fn item_bars(&self, root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
item_bars::read(root)
}
}
/// Installs these formats ([`crate::pack_hooks::install`]); the game does at startup.
pub fn install() {
crate::pack_hooks::install(&Formats);
}
@@ -0,0 +1,45 @@
//! The engine's extension points for resource pack formats vanilla doesn't read (OptiFine's,
//! Polytone's: platform code, docs/layers.md). The engine asks [`formats`] once per pack load and
//! gets engine types back (tables, or an object it calls at most once a frame), never a format's
//! own types. The platform installs its formats at startup ([`install`]); with none installed,
//! packs load as vanilla reads them.
use std::path::Path;
use std::sync::{Arc, OnceLock};
/// What the pack formats beyond vanilla's add to a pack. Each method reads one overlay of a pack
/// (its folder at `root`); the default is "nothing in this format".
pub trait PackFormats: Send + Sync {
/// The colours the overlay replaces where vanilla hardcodes them.
fn colors(&self, _root: &Path) -> Option<crate::pack_palette::PackColors> {
None
}
/// The sky layers the overlay draws over the vanilla sky, if it has any.
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
None
}
/// The durability bar colours the overlay sets by item, if any.
fn item_bars(&self, _root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
None
}
}
/// No format beyond vanilla's.
struct Vanilla;
impl PackFormats for Vanilla {}
static FORMATS: OnceLock<&'static dyn PackFormats> = OnceLock::new();
/// Installs the pack formats, before the first pack loads. The first call wins: later ones are
/// ignored, so each test or probe that needs the formats may install them.
pub fn install(formats: &'static dyn PackFormats) {
let _ = FORMATS.set(formats);
}
/// The installed pack formats, or vanilla's alone.
pub fn formats() -> &'static dyn PackFormats {
FORMATS.get().copied().unwrap_or(&Vanilla)
}
+113 -184
View File
@@ -1,14 +1,87 @@
//! Polytone's dye, sheep and redstone colours (`dye.<name>.diffuse|text`, `sheep.<name>`,
//! `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.
//! The colours a pack replaces where vanilla hardcodes them (Polytone's `colors.json`, read by the
//! platform's pack formats, [`crate::pack_hooks`]): dye, sheep and redstone colours, for drawing
//! only (the pack recolours collars, tropical fish, banners, wool, sign text and redstone wire and
//! dust, while gameplay keeps vanilla's `DyeColor`), mob effect colours, the experience number's,
//! entity shadows, the effect fogs' settings and durability bar colours. Lock-free: the draw paths
//! and the mesher's workers read the dye, sheep and redstone tables per face.
use std::path::Path;
use std::sync::RwLock;
use std::sync::atomic::{AtomicU64, Ordering::Relaxed};
use std::sync::{Arc, RwLock};
use crate::block_entity_render::DYES;
use crate::signs::DYE_TEXT;
use horizon_polytone::colors::PackColors;
/// The fog settings vanilla hardcodes, as the packs set them (vanilla's by default).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Environment {
/// The fog colour inside powder snow (RGB).
pub powder_snow_fog: Option<u32>,
/// The fog ends of Blindness (5), Darkness (15) and bosses that add world fog (96).
pub blindness_fog_distance: f32,
pub darkness_fog_distance: f32,
pub boss_fog_distance: f32,
/// How much the underwater fog colour washes out (1 vanilla, 0 none).
pub water_fog_brightening: f32,
/// The ticks under water it takes (vanilla's 600).
pub water_fog_brightening_time: f32,
/// Below it the sky draws its dark disc (vanilla's 63; flat worlds keep their floor).
pub horizon_height: f32,
}
impl Default for Environment {
fn default() -> Self {
Self {
powder_snow_fog: None,
blindness_fog_distance: 5.0,
darkness_fog_distance: 15.0,
boss_fog_distance: 96.0,
water_fog_brightening: 1.0,
water_fog_brightening_time: 600.0,
horizon_height: 63.0,
}
}
}
/// The packs' colours; `None` (and the defaults) where they keep vanilla's.
#[derive(Clone, Debug, PartialEq)]
pub struct PackColors {
/// By dye id (`DyeColor` order): `getTextureDiffuseColor`, `getTextColor`, the sheep's wool.
pub dye_diffuse: [Option<u32>; 16],
pub dye_text: [Option<u32>; 16],
pub sheep: [Option<u32>; 16],
/// `RedStoneWireBlock.COLORS` by power.
pub redstone: [Option<u32>; 16],
/// Mob effects' colours, by effect id without vanilla's namespace.
pub effects: Vec<(String, u32)>,
/// False hides every entity's round shadow, whatever the video option says.
pub entity_shadows: bool,
/// The experience level number's colour and outline.
pub xp_bar: [Option<u32>; 2],
/// The effect fogs' settings: the fog reads them every frame.
pub env: Environment,
}
impl Default for PackColors {
fn default() -> Self {
Self {
dye_diffuse: [None; 16],
dye_text: [None; 16],
sheep: [None; 16],
redstone: [None; 16],
effects: Vec::new(),
entity_shadows: true,
xp_bar: [None; 2],
env: Environment::default(),
}
}
}
impl PackColors {
/// Whether entities draw their round shadow, as far as the packs say.
pub fn entity_shadows(&self) -> bool {
self.entity_shadows
}
}
/// 0 is unset, else `1 << 32 | rgb`.
static DIFFUSE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
@@ -16,6 +89,30 @@ static TEXT: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
static SHEEP: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
static REDSTONE: [AtomicU64; 16] = [const { AtomicU64::new(0) }; 16];
/// The colours of the pack overlay at `root`, from the installed pack formats; their dye, sheep
/// and redstone colours go to the draw paths' tables ([`set`]).
pub fn read(root: &Path) -> PackColors {
let formats = crate::pack_hooks::formats();
let colors = formats.colors(root).unwrap_or_default();
set(&colors);
*ITEM_BARS.write().unwrap_or_else(|e| e.into_inner()) = formats.item_bars(root);
colors
}
/// The packs' durability bar colours by item (Polytone's item modifiers' `bar_color`).
pub trait ItemBars: Send + Sync {
/// The bar colour (RGB) of `item` at this damage (0 to 1), when a pack sets one.
fn color(&self, item: &str, damage: f32) -> Option<u32>;
}
static ITEM_BARS: RwLock<Option<Arc<dyn ItemBars>>> = RwLock::new(None);
/// 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());
bars.as_ref()?.color(item, damage)
}
/// Takes the colours of the packs just loaded (vanilla's where they set none).
pub fn set(colors: &PackColors) {
let store = |table: &[AtomicU64; 16], i: usize, color: Option<u32>| {
@@ -24,129 +121,39 @@ pub fn set(colors: &PackColors) {
Relaxed,
);
};
for (i, (name, _)) in DYES.iter().enumerate() {
store(&DIFFUSE, i, colors.dye(name, "diffuse"));
store(&TEXT, i, colors.dye(name, "text"));
store(&SHEEP, i, colors.sheep(name));
store(&REDSTONE, i, colors.redstone(i as u8));
for i in 0..16 {
store(&DIFFUSE, i, colors.dye_diffuse[i]);
store(&TEXT, i, colors.dye_text[i]);
store(&SHEEP, i, colors.sheep[i]);
store(&REDSTONE, i, colors.redstone[i]);
}
}
/// 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> {
fn get(table: &[AtomicU64; 16], i: usize) -> Option<u32> {
let v = table[i & 15].load(Relaxed);
(v != 0).then_some(v as u32)
}
/// `DyeColor.getTextureDiffuseColor` (RGB) as the packs draw it.
pub fn diffuse(dye: usize) -> u32 {
read(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
get(&DIFFUSE, dye).unwrap_or(DYES[dye & 15].1)
}
/// `DyeColor.getTextColor` (RGB) as the packs draw it.
pub fn text(dye: usize) -> u32 {
read(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
get(&TEXT, dye).unwrap_or(DYE_TEXT[dye & 15].1)
}
/// A pack's `RedStoneWireBlock.COLORS` entry for this power (RGB), when it sets one.
pub fn redstone(power: u8) -> Option<u32> {
read(&REDSTONE, usize::from(power))
get(&REDSTONE, usize::from(power))
}
/// `ColorLerper.Type.SHEEP` wool colour: a pack's `sheep.<name>`, else white 0xE6E6E6 and the
/// others 75% of vanilla's dye (a pack's `dye.*.diffuse` doesn't reach wool).
pub fn sheep_wool(dye: u8) -> u32 {
let i = usize::from(dye & 15);
if let Some(color) = read(&SHEEP, i) {
if let Some(color) = get(&SHEEP, i) {
return color;
}
if i == 0 {
@@ -156,81 +163,3 @@ pub fn sheep_wool(dye: u8) -> u32 {
let f = |c: u32| ((c as f32 * 0.75).floor() as u32).min(255);
f(src >> 16 & 255) << 16 | f(src >> 8 & 255) << 8 | f(src & 255)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_pack_recolours_only_what_it_names() {
// Dyes no other test draws: the table is process-wide.
let dir = std::env::temp_dir().join(format!("pack-dyes-{}", std::process::id()));
let polytone = dir.join("assets/obs/polytone");
std::fs::create_dir_all(&polytone).unwrap();
std::fs::write(
polytone.join("colors.json"),
r#"{"dye": {"cyan": {"diffuse": "00ff00", "text": "0000ff"}}, "sheep": {"pink": "2040ff"}, "redstone": {"7": "0x123456"}}"#,
)
.unwrap();
set(&PackColors::read(&dir));
let (cyan, pink, purple) = (9, 6, 10);
assert_eq!(diffuse(cyan), 0x00ff00);
assert_eq!(text(cyan), 0x0000ff);
assert_eq!(sheep_wool(pink as u8), 0x2040ff);
// Wool reads vanilla's dye, not the pack's diffuse: 75% of 0x169C9C.
assert_eq!(sheep_wool(cyan as u8), 0x107575);
assert_eq!(diffuse(purple), 0x8932B8);
assert_eq!(text(purple), 0xA020F0);
assert_eq!((redstone(7), redstone(15)), (Some(0x123456), None));
set(&PackColors::default());
assert_eq!(redstone(7), None);
assert_eq!(diffuse(cyan), 0x169C9C);
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();
}
}
+45 -111
View File
@@ -31,6 +31,7 @@ mod native;
pub mod polytone;
mod polytone_gpu;
mod polytone_model;
mod spawn;
pub use native::{ParticleController, ParticleUpdate};
/// `ParticleOptions` with the parameters of the option classes used here.
@@ -1222,8 +1223,9 @@ pub struct ParticleLevel<'a> {
pub collision: &'a BlockCollisionRegistry<'a>,
/// Local player feet position and vertical velocity (`PlayerCloudParticle`).
pub player: Option<([f64; 3], f64)>,
pub cached_chunk: std::cell::Cell<Option<((i32, i32), Option<&'a crate::chunk::Chunk>)>>,
}
impl ParticleLevel<'_> {
impl<'a> ParticleLevel<'a> {
fn collide(&self, bb: Aabb, d: [f64; 3]) -> [f64; 3] {
let mut scene = SceneCollisions {
world: self.world,
@@ -1231,9 +1233,6 @@ impl ParticleLevel<'_> {
subject: None,
};
let movement = Vec3::new(d[0], d[1], d[2]);
// `Entity.collideBoundingBox(null, ...)`: no entity shapes, no border. Where no
// block of the swept box's cells can collide, it finds no shape and the
// movement stands, as the resolution below would return it.
if scene.cells_without_collision(bb.expand_towards(movement)) {
return d;
}
@@ -1249,38 +1248,46 @@ impl ParticleLevel<'_> {
p[2].floor() as i32,
]
}
#[inline]
pub fn block(&self, x: i32, y: i32, z: i32) -> horizon_world::registry::BlockStateId {
let (cx, cz) = (x >> 4, z >> 4);
let chunk = match self.cached_chunk.get() {
Some(((ccx, ccz), c)) if ccx == cx && ccz == cz => c,
_ => {
let c = self.world.chunks.get(&(cx, cz)).map(|c| c.as_ref());
self.cached_chunk.set(Some(((cx, cz), c)));
c
}
};
chunk.map_or(self.world.air, |c| c.block(x & 15, y, z & 15))
}
/// The fluid at a block and `FluidState.getHeight`.
fn fluid(&self, b: [i32; 3]) -> (Fluid, f64) {
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
let s = self.facts.get(self.block(b[0], b[1], b[2]));
if s.fluid == Fluid::None {
return (Fluid::None, 0.0);
}
let above = self.facts.get(self.world.block(b[0], b[1] + 1, b[2]));
let height = if above.fluid == s.fluid {
let above = self.facts.get(self.block(b[0], b[1] + 1, b[2]));
let h = if above.fluid == s.fluid {
1.0
} else {
f64::from(s.fluid_level) / 9.0
};
(s.fluid, height)
(s.fluid, h)
}
/// The fluid at a block and whether it is a source (`FluidState.isSource`).
pub(crate) fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
let s = self.facts.get(self.block(b[0], b[1], b[2]));
(s.fluid, s.fluid_level == 8 && !s.fluid_falling)
}
/// `getCollisionShape(...).max(Y, x, z)` for cached shapes.
fn collision_top(&self, b: [i32; 3], fx: f64, fz: f64) -> f64 {
let state = self.world.block(b[0], b[1], b[2]);
let state = self.block(b[0], b[1], b[2]);
match self.collision.cached_shapes().cached_shape(state) {
Ok(Some(shape)) if !shape.is_empty() => shape.max_at(Axis::Y, fx, fz),
Ok(Some(_)) => 0.0,
_ => {
if self.facts.get(state).full_collision {
1.0
} else {
0.0
}
}
_ if self.facts.get(state).full_collision => 1.0,
_ => 0.0,
}
}
}
@@ -2020,73 +2027,6 @@ impl ParticleEngine {
(eye[0] - pos[0]).powi(2) + (eye[1] - pos[1]).powi(2) + (eye[2] - pos[2]).powi(2) <= 9.0
})
}
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
pub fn send_particles(
&mut self,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
) {
if count == 0 {
let v = [speed * spread[0], speed * spread[1], speed * spread[2]];
self.add(options, at, v);
return;
}
for _ in 0..count {
let r = &mut self.random;
let p = [
at[0] + r.next_gaussian() * spread[0],
at[1] + r.next_gaussian() * spread[1],
at[2] + r.next_gaussian() * spread[2],
];
let v = [
r.next_gaussian() * speed,
r.next_gaussian() * speed,
r.next_gaussian() * speed,
];
self.add(options, p, v);
}
}
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
if let Some(requests) = &mut self.created_requests {
requests.push((options.clone(), p, v));
return;
}
let seed = self.seeds.next_long();
if let Some(mut particle) = self.make(options, p, v, seed) {
*self.made.entry(options.ty.name()).or_default() += 1;
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
if let Some(registry) = &self.polytone
&& registry.modifies_particles()
{
let id = match &options.mod_particle {
Some(name) => name.clone(),
None => format!("minecraft:{}", options.ty.name()),
};
polytone::modify(registry, &id, options.source.as_deref(), &mut particle);
}
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
// shortcut: counts live plus queued particles by scanning instead of
// vanilla's counted map; use a counter if spore spawning shows in profiles.
if particle.ty == ParticleType::SporeBlossomAir
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
&& self
.particles
.iter()
.chain(self.pending.iter())
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
.count()
>= SPORE_BLOSSOM_LIMIT
{
return;
}
self.pending.push(particle);
}
}
/// How many particles of each type were made so far.
pub fn made(&self) -> &std::collections::BTreeMap<&'static str, u64> {
&self.made
@@ -4052,15 +3992,12 @@ impl ParticleEngine {
}
Behaviour::Lava => {
p.base_tick(level);
if !p.removed {
let odds = p.age as f32 / p.lifetime as f32;
if p.random.next_float() > odds {
self.add(
&ParticleOptions::simple(ParticleType::Smoke),
p.position,
p.velocity,
);
}
if !p.removed && p.random.next_float() > p.age as f32 / p.lifetime as f32 {
self.add(
&ParticleOptions::simple(ParticleType::Smoke),
p.position,
p.velocity,
);
}
}
Behaviour::Crit => {
@@ -4144,9 +4081,7 @@ impl ParticleEngine {
*c *= f64::from(0.98f32);
}
if p.on_ground {
if p.random.next_float() < 0.5 {
p.removed = true;
}
p.removed |= p.random.next_float() < 0.5;
p.velocity[0] *= f64::from(0.7f32);
p.velocity[2] *= f64::from(0.7f32);
}
@@ -4186,13 +4121,10 @@ impl ParticleEngine {
} => {
p.base_tick(level);
let (fluid, source) = level.fluid_source(ParticleLevel::block_at(p.position));
if !(p.removed || (fluid == Fluid::Water && source)) {
p.removed = true;
}
if !p.removed && p.position[1] >= y_end {
p.removed = true;
}
if !p.removed && p.position[1] <= y_prev {
if !(fluid == Fluid::Water && source)
|| p.position[1] >= y_end
|| p.position[1] <= y_prev
{
p.removed = true;
}
// randomHorizontalWiggling, then a second, horizontal move.
@@ -4216,9 +4148,8 @@ impl ParticleEngine {
p.base_tick(level);
self.sprite_from_age(p);
if p.age as f32 > fade_start {
let frames_since_fade_out_start = p.age as f32 - fade_start;
p.rgba[3] =
(p.lifetime as f32 - frames_since_fade_out_start) / p.lifetime as f32;
(p.lifetime as f32 - (p.age as f32 - fade_start)) / p.lifetime as f32;
}
}
Behaviour::NoxiousGasCloud => {
@@ -4682,7 +4613,7 @@ impl ParticleEngine {
self.sprite_from_age(p);
half(p);
let b = ParticleLevel::block_at(p.position);
if level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
if level.facts.is_air(level.block(b[0], b[1], b[2])) {
p.velocity[1] -= f64::from(0.0074f32);
}
}
@@ -4690,7 +4621,7 @@ impl ParticleEngine {
Behaviour::Firefly => {
p.base_tick(level);
let b = ParticleLevel::block_at(p.position);
if !level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
if !level.facts.is_air(level.block(b[0], b[1], b[2])) {
p.removed = true;
} else {
p.rgba[3] = fade_amount(p.lifetime_progress(p.age as f32), 0.3, 0.5);
@@ -5154,11 +5085,12 @@ fn hdr_tier(ty: ParticleType) -> u8 {
}
/// `Particle.LifetimeAlpha.currentAlphaForAge`.
fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 {
if (a[0] - a[1]).abs() < 1.0e-5 {
return a[0];
}
let t = ((age as f32 + partial) / lifetime as f32 - a[2]) / (a[3] - a[2]);
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
if (a[0] - a[1]).abs() < 1.0e-5 {
a[0]
} else {
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
}
}
struct Starter {
@@ -5589,6 +5521,7 @@ mod tests {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.set_sprites.push(vec![10, 11, 12]);
@@ -5680,6 +5613,7 @@ mod tests {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let mut particle = Particle::blank(ParticleType::Smoke, [0.0; 3]);
@@ -124,6 +124,7 @@ mod tests {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut e = engine();
let mut p = e
@@ -490,14 +490,14 @@ enum Subject<'a> {
}
/// What one expression runs against: its subject, the frame and the level.
struct Ctx<'a> {
struct Ctx<'a, 'l> {
subject: Subject<'a>,
random: &'a mut LegacyRandom,
custom: &'a mut f64,
customs: &'a mut BTreeMap<String, f64>,
frame: &'a Frame,
registry: &'a Registry,
level: Option<&'a ParticleLevel<'a>>,
level: Option<&'a ParticleLevel<'l>>,
blocks: Vec<[i32; 3]>,
}
@@ -515,7 +515,7 @@ fn axis(name: &str) -> usize {
usize::from(name.as_bytes()[0] - b'x')
}
impl Ctx<'_> {
impl Ctx<'_, '_> {
fn position(&self) -> [f64; 3] {
match &self.subject {
Subject::Particle(p) => p.position,
@@ -683,7 +683,7 @@ impl Ctx<'_> {
}
}
impl Host for Ctx<'_> {
impl Host for Ctx<'_, '_> {
fn root(&mut self, root: Root) -> V {
match root {
Root::Object => V::Object(SELF),
@@ -1662,6 +1662,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
// Air has no emitters; the torch fires its first emitter only.
@@ -1701,6 +1702,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_animate(&level, [1, 64, 1]);
@@ -1722,6 +1724,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_animate(&level, [1, 64, 1]);
@@ -1767,6 +1770,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let registry = Registry::read(&root, Default::default(), blocks);
let frame = registry.frame.clone();
@@ -1820,6 +1824,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let registry = Registry::read(&root, Default::default(), blocks);
let mut engine = engine(registry);
@@ -1853,6 +1858,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let rect = crate::hud::Rect {
@@ -1905,6 +1911,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let rect = crate::hud::Rect {
@@ -1966,6 +1973,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_entity(&level, "minecraft:player", [1.0, 70.0, 1.0]);
@@ -2014,6 +2022,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let rect = crate::hud::Rect {
@@ -2080,6 +2089,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let registry = Arc::new(Registry::read(&root, Default::default(), blocks));
// The chunk's biome is id 0: plains, whose foliage is #336699.
@@ -2198,6 +2208,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_entity(&level, "minecraft:player", [1.5, 70.0, 1.5]);
@@ -2276,6 +2287,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
// The test chunk's biome is the table's first: a cold one first, then a warm one.
@@ -0,0 +1,73 @@
//! Where particles come into the engine: a server's particle packet and a single particle made
//! where the game asks for one.
use super::*;
impl ParticleEngine {
/// `ClientPacketListener.handleParticleEvent` for `ServerLevel.sendParticles`.
pub fn send_particles(
&mut self,
options: &ParticleOptions,
at: [f64; 3],
spread: [f64; 3],
speed: f64,
count: u32,
) {
if count == 0 {
let v = [speed * spread[0], speed * spread[1], speed * spread[2]];
self.add(options, at, v);
return;
}
for _ in 0..count {
let r = &mut self.random;
let p = [
at[0] + r.next_gaussian() * spread[0],
at[1] + r.next_gaussian() * spread[1],
at[2] + r.next_gaussian() * spread[2],
];
let v = [
r.next_gaussian() * speed,
r.next_gaussian() * speed,
r.next_gaussian() * speed,
];
self.add(options, p, v);
}
}
/// `ParticleEngine.createParticle`: always spawned, but `ParticleEngine.add`
/// drops a spore blossom air particle once `ParticleLimit.SPORE_BLOSSOM` is full.
pub fn create(&mut self, options: &ParticleOptions, p: [f64; 3], v: [f64; 3]) {
if let Some(requests) = &mut self.created_requests {
requests.push((options.clone(), p, v));
return;
}
let seed = self.seeds.next_long();
if let Some(mut particle) = self.make(options, p, v, seed) {
*self.made.entry(options.ty.name()).or_default() += 1;
// Resource packs' Polytone particle modifiers (#11486), once as it spawns.
if let Some(registry) = &self.polytone
&& registry.modifies_particles()
{
let id = match &options.mod_particle {
Some(name) => name.clone(),
None => format!("minecraft:{}", options.ty.name()),
};
polytone::modify(registry, &id, options.source.as_deref(), &mut particle);
}
// The provider has drawn, so rejecting here keeps vanilla's RNG order.
// shortcut: counts live plus queued particles by scanning instead of
// vanilla's counted map; use a counter if spore spawning shows in profiles.
if particle.ty == ParticleType::SporeBlossomAir
&& self.particles.len() + self.pending.len() >= SPORE_BLOSSOM_LIMIT
&& self
.particles
.iter()
.chain(self.pending.iter())
.filter(|q| q.ty == ParticleType::SporeBlossomAir)
.count()
>= SPORE_BLOSSOM_LIMIT
{
return;
}
self.pending.push(particle);
}
}
}
@@ -93,6 +93,7 @@ fn particle_tick_cost() {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let ty = |name: &str| ParticleType::from_name(name).unwrap();
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
@@ -864,6 +864,8 @@ pub enum Opt {
PadGameplayGuide,
/// `options.modelPart.*`: one skin layer (index into `options_file::MODEL_PARTS`).
ModelPart(usize),
/// A slider of a page a crate registered (`horizon_hooks::pages`): (page, row).
Page(usize, usize),
/// 3D skin layers: the player's outer layer as extruded slabs.
SkinLayers3d,
MainHand,
+27 -1
View File
@@ -192,7 +192,12 @@ impl SignData {
let side = |t: Option<&Tag>| -> SignText {
let mut out = SignText::default();
let Some(t) = t else { return out };
if let Some(Tag::List(messages)) = t.get("messages") {
if let Some(messages) = t.get("messages") {
// `SignText.CODEC` reads a side all or nothing: a messages
// field that is not a list leaves the side empty.
let Tag::List(messages) = messages else {
return SignText::default();
};
for (i, m) in messages.iter().take(4).enumerate() {
out.lines[i] = component_text(m);
out.click[i] = click_event(m);
@@ -307,6 +312,27 @@ pub fn editor_sprite(folder: &str, wood: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
/// `SignText.CODEC` reads messages as a list: a string field fails the
/// whole side, taking its colour and glow with it.
#[test]
fn messages_that_are_not_a_list_load_an_empty_side() {
let tag = Tag::compound([(
"front_text",
Tag::compound([
("messages", Tag::String("not a list".into())),
("color", Tag::String("red".into())),
("has_glowing_text", Tag::Byte(1)),
]),
)]);
let mut decoded = SignData::default();
decoded.decode(&tag);
assert_eq!(
decoded.front,
SignText::default(),
"a failed message list takes the side's colour and glow with it"
);
}
#[test]
fn waxed_and_glowing_text_take_any_number() {
let tag = Tag::compound([
+7 -2
View File
@@ -51,7 +51,7 @@ pub fn enabled() -> bool {
}
/// Record this thread's spans even with spans off, their wall time only (no CPU clock read):
/// what the render thread's slow frames spent their time in, for the opt-in performance
/// statistics (`telemetry::Session::frame`). The thread takes them every frame ([`take`]).
/// statistics (`play_session::Session::frame`). The thread takes them every frame ([`take`]).
pub fn record_here(on: bool) {
HERE.with(|h| h.set(on));
}
@@ -466,7 +466,7 @@ pub mod startup {
Mutex, OnceLock,
atomic::{AtomicBool, AtomicU32, Ordering},
},
time::Instant,
time::{Duration, Instant},
};
static ON: AtomicBool = AtomicBool::new(false);
@@ -489,6 +489,11 @@ pub mod startup {
ON.store(sink.is_some(), Ordering::Relaxed);
}
/// The time since the process started ([`init`]'s origin; zero before it).
pub fn since_start() -> Duration {
ORIGIN.get().map_or(Duration::ZERO, Instant::elapsed)
}
/// Whether a trace is being written.
#[inline]
pub fn on() -> bool {
+41 -2
View File
@@ -1238,6 +1238,9 @@ pub struct EditBox {
/// Grey completion shown after the text (EditBox.setSuggestion).
pub suggestion: Option<String>,
pub text_color: u32,
/// `EditBox.setCentered`: the text starts in the middle of the box's
/// width, not at its left edge (`BookSignScreen`'s title).
pub centered: bool,
/// `EditBox.textColorUneditable`: the colour of a box `setEditable(false)`
/// made read-only, which still shows its value and caret.
pub text_color_uneditable: u32,
@@ -1536,6 +1539,7 @@ impl EditBox {
focused_at: Instant::now(),
suggestion: None,
text_color: rgba(224, 224, 224, 255),
centered: false,
text_color_uneditable: rgba(112, 112, 112, 255),
editable: true,
message: String::new(),
@@ -1559,6 +1563,16 @@ impl EditBox {
self.y
}
}
/// `EditBox.updateTextPosition`: where the text starts, `(width - text
/// width) / 2` in for a centred box (vanilla's `textX`), else at
/// `text_x` (4 in from a border).
pub fn text_start_x(&self, text_width: f32) -> f32 {
if self.centered {
self.x + ((self.width - text_width) / 2.0).floor()
} else {
self.text_x()
}
}
pub fn reset_blink(&mut self) {
self.focused_at = Instant::now();
}
@@ -1641,8 +1655,13 @@ impl EditBox {
/// EditBox.findClickedPositionInText: the character boundary under GUI `x`.
pub fn position_at(&mut self, atlas: &HudAtlas, x: f32) -> usize {
self.layout(atlas);
let room = (x.floor() - self.text_x()).min(self.inner_width());
let rest: String = self.value.chars().skip(self.display).collect();
let shown: String = rest
.chars()
.take(atlas.font.fit(&rest, self.inner_width()))
.collect();
let start = self.text_start_x(atlas.font.width_text(&Text::literal(shown.as_str())));
let room = (x.floor() - start).min(self.inner_width());
self.display + atlas.font.fit(&rest, room)
}
/// EditBox.onClick: the cursor to the pointer, extending the selection
@@ -1849,7 +1868,12 @@ impl EditBox {
),
}
};
let (tx, ty) = (self.text_x(), self.text_y());
let start = if self.centered {
self.text_start_x(atlas.font.width_text(&Text::literal(displayed.as_str())))
} else {
self.text_x()
};
let (tx, ty) = (start, self.text_y());
let mut draw_x = tx;
if !displayed.is_empty() {
let half = if on_screen {
@@ -2650,4 +2674,19 @@ mod tests {
assert!(edit.key(winit::keyboard::KeyCode::Backspace, false, false));
assert_eq!(edit.value, "name");
}
/// `EditBox.setCentered` (`BookSignScreen`'s title box): with the flag the
/// text starts `(width - text width) / 2` in, which is also where a click
/// maps; without it the text starts at the box's left edge.
#[test]
fn a_centred_box_starts_its_text_in_the_middle() {
let mut edit = EditBox::new(10.0, 0.0, 114.0, 9.0);
edit.bordered = false;
assert_eq!(edit.text_start_x(0.0), 10.0);
edit.centered = true;
assert_eq!(edit.text_start_x(0.0), 67.0);
assert_eq!(edit.text_start_x(18.0), 58.0);
// (114 - 5) / 2 = 54.5, rounded down.
assert_eq!(edit.text_start_x(5.0), 64.0);
}
}
+4 -3
View File
@@ -122,12 +122,13 @@ controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-duals
# 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"]
horizon-extras = ["horizon-client-lib/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"]
# Play with Friends: sharing a world behind an invite code (game/friends.rs). Joining one is
# horizon-friends' `game` side, which horizon-official registers (`horizon_hooks::tunnel`).
friends = ["dep:horizon-friends"]
# 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
@@ -441,6 +441,7 @@
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
"Your display's reported peak is often too high: calibrate to set it up.": "Your display's reported peak is often too high: calibrate to set it up.",
"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.",
@@ -97,6 +97,7 @@ pub fn main() -> Result<()> {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = ParticleEngine::new(&root, &Default::default(), &Default::default());
let mut out = BufWriter::new(File::create(&args[2])?);
@@ -194,6 +194,7 @@ pub fn main() -> Result<()> {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let v = |key: &str| -> [f64; 3] {
std::array::from_fn(|i| case[key][i].as_f64().unwrap_or(0.0))
+49 -4
View File
@@ -612,6 +612,7 @@ fn do_animate_tick(
facts: ctx.facts,
collision: ctx.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
engine.polytone_animate(&level, pos);
let facts = ctx.facts.get(state);
@@ -1994,8 +1995,10 @@ pub fn campfire_particles<'a>(
}
}
}
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
/// `smoking` adds the plain smoke puff at the logs.
/// `CampfireBlock.makeParticles`: the cosy or signal smoke through
/// `addAlwaysVisibleParticle(.., true, ..)` (it overrides the limiter but still
/// rolls the Particles option); `smoking` adds the plain smoke puff at the logs
/// through `addParticle`, so the limiter can drop it.
pub fn make_campfire_smoke(
engine: &mut ParticleEngine,
pos: [i32; 3],
@@ -2014,7 +2017,13 @@ pub fn make_campfire_smoke(
} else {
T::CampfireCosySmoke
};
engine.create(&ParticleOptions::simple(ty), [x, y, z], [0.0, 0.07, 0.0]);
engine.add_with(
&ParticleOptions::simple(ty),
[x, y, z],
[0.0, 0.07, 0.0],
true,
true,
);
if smoking {
let r = engine.random();
let dx = r.next_double() / 4.0 * sign(r);
@@ -2024,13 +2033,49 @@ pub fn make_campfire_smoke(
f64::from(pos[1]) + 0.4,
f64::from(pos[2]) + 0.5 + dz,
];
engine.create(&ParticleOptions::simple(T::Smoke), at, [0.0, 0.005, 0.0]);
add(engine, T::Smoke, at, [0.0, 0.005, 0.0]);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `CampfireBlock.makeParticles` through `ClientLevel.doAddParticle`: the
/// cosy smoke overrides the limiter, the plain smoke does not, so it is
/// dropped beyond 32 blocks and at Particles: Minimal, and at Decreased
/// each puff is dropped when `calculateParticleLevel`'s `nextInt(3)` is 0.
#[test]
fn doused_campfire_smoke_goes_through_the_particle_limiter() {
use horizon_core::random::RandomSource as _;
use std::collections::{HashMap, HashSet};
let run = |level: u8, camera: [f64; 3]| {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.seed_randoms(5, 9, 0);
engine.created_requests = Some(Vec::new());
engine.particle_level = level;
engine.camera = camera;
for _ in 0..20 {
make_campfire_smoke(&mut engine, [0, 64, 0], false, true);
}
let made = engine.created_requests.take().unwrap();
let count = |ty| made.iter().filter(|(o, ..)| o.ty == ty).count();
(count(T::CampfireCosySmoke), count(T::Smoke))
};
let near = [0.5, 65.0, 0.5];
assert_eq!(run(0, near), (20, 20), "All");
assert_eq!(run(2, near), (20, 0), "Minimal");
assert_eq!(run(0, [100.5, 65.0, 0.5]), (20, 0), "100 blocks away");
// Decreased: per puff the cosy smoke's roll, then the plain smoke's.
let mut level = LegacyRandom::new(9);
let kept = (0..20)
.filter(|_| {
level.next_int_bound(3).unwrap();
level.next_int_bound(3).unwrap() != 0
})
.count();
assert!(0 < kept && kept < 20);
assert_eq!(run(1, near), (20, kept), "Decreased");
}
#[test]
fn classifies_animated_blocks() {
let none = |_: &str| None;
+5 -3
View File
@@ -19,8 +19,8 @@ pub use horizon_client_render::{
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_palette, pack_screen,
packs, pad_cursor, pad_glyph, par, particles, poi_sections, polytone_colors,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
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,
@@ -70,7 +70,6 @@ pub mod falling_block;
pub mod fire_render;
pub mod first_person;
pub mod flatgen;
pub mod friends_screen;
pub mod furnace;
pub mod game_mode_switcher;
pub mod game_rules_screen;
@@ -103,12 +102,15 @@ pub mod native_data;
pub mod net;
pub mod nether_portal;
pub mod no_resources;
#[cfg(feature = "horizon-extras")]
pub mod official_servers;
pub mod offline_name;
pub mod old_world;
pub mod osk;
pub mod patrol;
pub mod perf_overlay;
pub mod pick_item;
pub mod play_session;
pub mod player_sync;
pub mod portal_sections;
pub mod pose;
+1 -1
View File
@@ -84,7 +84,7 @@ fn slime_at(world: &World, facts: &BlockFacts, position: BlockPos) -> bool {
mod fluids;
mod item_fall;
mod item_no_physics;
pub use fluids::{fluid_flow, update_entity_fluids, water_clip};
pub use fluids::{fall_reset_hit, fluid_flow, update_entity_fluids, water_clip};
#[cfg(test)]
mod drop_motion_tests {
@@ -45,6 +45,30 @@ pub fn fluid_flow(world: &World, fluids: &FluidResources, pos: [i32; 3]) -> [f64
let flow = flow_at(&mut scene, state, at).unwrap_or(Vec3::ZERO);
[flow.x, flow.y, flow.z]
}
/// `Entity.move`'s fall-reset ray along a move from `from`, up to 8 blocks:
/// `ClipContext.Fluid.WATER` (the water's shape) and
/// `ClipContext.Block.FALLDAMAGE_RESETTING` (a block `tagged` says is a reset
/// block clips as a full block). Both shapes are queried for every visited
/// block, the water's first (its first query fixes the shared cached shape);
/// either one hitting ends the ray.
pub fn fall_reset_hit(
world: &World,
fluids: &FluidResources,
tagged: impl Fn(BlockPos) -> bool,
from: Vec3,
moved: Vec3,
) -> bool {
let to = from.add_vector(moved.normalize().scale(moved.length().min(8.0)));
let hit = horizon_core::geometry::traverse_blocks(from, to, 1_000_000, |p| {
let water = water_clip(world, fluids, from, to, p);
let reset = tagged(p)
&& horizon_core::geometry::VoxelShape::block()
.clip(from, to, p)
.is_some();
(water || reset).then_some(())
});
matches!(hit, Ok(Some(())))
}
/// `ClipContext.Fluid.WATER` in a fall-reset ray through `p`: the water's
/// shape from the shared `FlowingFluid.shapes` cache (the first query of a
/// fluid state fixes its height), as the player's own ray reads it.
+5 -5
View File
@@ -233,7 +233,7 @@ fn is_animal(id: &str) -> bool {
use crate::mobs::persistent_anger;
/// The single player's UUID (`offline_uuid("Player")`): the entity the
/// neutral mobs are angry at.
fn player_uuid() -> [i32; 4] {
pub(crate) fn player_uuid() -> [i32; 4] {
let u = horizon_protocol::crypto::offline_uuid("Player");
[
(u >> 96) as u32 as i32,
@@ -1297,7 +1297,7 @@ pub fn mob_to_nbt(
crate::mobs::nautilus::to_nbt(m, map);
}
"minecraft:sulfur_cube" => crate::mobs::sulfur_cube::to_nbt(m, map),
"minecraft:bee" => crate::mobs::bee::to_nbt(m, map),
"minecraft:bee" => crate::mobs::bee::to_nbt(m, map, &mut uuid_of),
"minecraft:sniffer" => crate::mobs::sniffer::to_nbt(m, map),
"minecraft:wither" => {
map.insert("Invul".into(), Tag::Int(m.extra.counter));
@@ -2666,9 +2666,9 @@ pub fn mob_from_nbt_with_random(
m.extra.counter = number(tag.get("still_timeout")).unwrap_or(0) as i32;
}
if m.species() == crate::mobs::Species::Shulker {
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
m.extra.color = number(tag.get("Color")).map_or(16, |c| c.clamp(0, 16) as u8);
m.extra.variant3 = (number(tag.get("AttachFace")).unwrap_or(0) % 6).unsigned_abs() as u8;
m.extra.counter = number(tag.get("Peek")).map_or(0, |v| v as i8 as i32);
m.extra.color = number(tag.get("Color")).map_or(16, |v| v as i8 as u8);
m.anim.v[0] = m.extra.counter as f32 * 0.01;
m.anim.vo[0] = m.anim.v[0];
// Shulker.setPos: square on its block.
+9 -2
View File
@@ -13,18 +13,25 @@ pub fn main_with(mods: crate::mod_api::Mods) {
log::warn!("Trait mods already installed; ignoring the second list");
}
horizon_threads::configure_allocator();
// OptiFine's and Polytone's pack formats (platform, docs/layers.md), before any pack loads.
horizon_client::pack_formats::install();
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
if !cfg!(feature = "horizon-extras") {
horizon_client::options_ui::HORIZON_EXTRAS
.store(false, std::sync::atomic::Ordering::Relaxed);
}
horizon_client::telemetry::mark_start();
// The process's start, for the startup trace and the start's milestones.
horizon_client::spans::startup::init(std::time::Instant::now());
// Opt-in telemetry keeps each crash report (docs/telemetry.md), whatever the mode.
crash::on_report(horizon_client::telemetry::keep_crash);
// Opt-in telemetry's play session plugs in through the hooks until it moves to a Horizon
// crate in horizon-official's parts (layer plan M5). horizon-extras: telemetry is Horizon's
// (docs/layers.md); without it no session is kept.
#[cfg(feature = "horizon-extras")]
let _ = horizon_hooks::register(&horizon_client::telemetry::HOOKS);
// 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();
let game_args = args.iter().take_while(|a| *a != "--dedicated");
+12 -73
View File
@@ -650,7 +650,7 @@ enum Screen {
/// WorldOptionsScreen (game/lan_screen.rs).
WorldOptions,
/// Play with Friends, from the pause menu's Friends button
/// (`friends_screen`, game/friends.rs).
/// (game/friends.rs, game/friends/screen.rs).
Friends,
/// `SocialInteractionsScreen` (`social_screen`, game/social.rs).
Social,
@@ -2018,8 +2018,8 @@ pub struct PackResources {
clouds: Arc<(u32, u32, Vec<bool>)>,
/// misc/forcefield.png: (width, height, RGBA) for the world border wall.
forcefield: Arc<(u32, u32, Vec<u8>)>,
/// OptiFine custom sky layers (`optifine/sky`) with their textures; empty without them.
custom_sky: Arc<horizon_client::custom_sky::CustomSky>,
/// Custom sky layers (OptiFine's `optifine/sky`) with their textures; none without them.
custom_sky: Arc<dyn horizon_client::custom_sky::SkyLayers>,
/// The overworld's, Nether's and End's attribute stacks with the packs' Polytone biome and
/// dimension modifiers (#11486), when a pack has some: what the frame draws.
environments: Option<Arc<[Environment; 3]>>,
@@ -2028,7 +2028,7 @@ pub struct PackResources {
/// The packs' Polytone post chains (#11486), when a pack has some.
polytone_post: Option<Arc<horizon_client::polytone_post::PolytonePost>>,
/// The packs' Polytone custom colours (`polytone/colors.json`).
polytone_colors: Arc<horizon_client::polytone_colors::PackColors>,
polytone_colors: Arc<horizon_client::pack_palette::PackColors>,
}
/// A quick craft drag as the screen draws it: each dragged slot's count and
/// whether the slot caps it (None: hidden), and what stays in hand.
@@ -2801,7 +2801,7 @@ struct Game<'a> {
agent_last: Option<Instant>,
frame_stats: horizon_client::control::FrameStats,
/// This play session's frame and tick times, for opt-in performance statistics.
perf: horizon_client::telemetry::Session,
perf: horizon_client::play_session::Session,
/// Control replies waiting for a rendered frame or a benchmark deadline.
pending_screenshot: Option<std::sync::mpsc::Sender<String>>,
/// `screenshot-sequence` while it runs.
@@ -5490,34 +5490,6 @@ impl Game<'_> {
);
cancel
}
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
/// moves only while the world runs.
pub(super) fn visual_ticks(&self) -> u64 {
if self.clock.still() {
self.world.game_time.max(0) as u64
} else {
self.ticks
}
}
/// The clock of the animated textures: `Minecraft.runTick` ticks the `TextureManager`
/// only while the level runs normally. Under `HORIZON_STILL` it is the world's ticks.
pub(super) fn texture_clock(&self) -> u64 {
if self.clock.still() {
self.visual_ticks()
} else {
self.texture_ticks
}
}
/// `Util.getMillis` for what the frame shows (the world border's scroll, the hearts' blink,
/// the glint's under the switch): under `HORIZON_STILL`, the world's ticks in milliseconds.
pub(super) fn visual_millis(&self) -> i64 {
if self.clock.still() {
self.visual_ticks() as i64 * 50
} else {
self.started.elapsed().as_millis() as i64
}
}
/// `PresenceHandler.getPresenceStatus`: what an all-sharing player is doing, for
/// the friends service's `updatePresence`.
fn presence_status(&self) -> horizon_client::social::PresenceStatus {
@@ -6170,7 +6142,7 @@ impl Game<'_> {
let atlas = &g.gui.atlas;
let width = |s: &str| HudBatch::text_width(atlas, s) as i32;
let draws = hud_layout::experience_colored(
g.pack.polytone_colors.xp_bar(),
g.pack.polytone_colors.xp_bar,
wi,
hi,
g.experience.progress,
@@ -6503,18 +6475,6 @@ impl Game<'_> {
self.draw_tooltip_lines(batch, &lines, self.cursor.0, self.cursor.1);
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
/// changes hands: a screen that opens or closes starts with its own
/// state, and a press whose release never reached the screen -- the
/// pointer left the window, or the screen closed first -- would
/// otherwise leave every later press on every later screen swallowed.
pub(super) fn reset_inventory_clicks(&mut self) {
self.inv_clicks.cancel_drag();
self.inv_drag = None;
self.last_inv_click = None;
}
pub(super) fn close_container(&mut self) {
// `Gui.setScreen` clears the old screen's focus before it goes: the
// key that closed the container (Tab bound to the inventory) has
@@ -6558,20 +6518,6 @@ impl Game<'_> {
}
self.close_ender_chest();
}
/// A sound a menu click made the player play (a bundle's insert or
/// remove): `Entity.playSound` at the player.
pub(super) fn player_menu_sound(&mut self, sound: &str) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
(1.0, 1.0)
} else {
(0.8, 0.8 + self.world.random.next_float() * 0.4)
};
self.audio.play(sound, Some(at), volume, pitch);
}
/// `Camera.setRotation`: the camera's orientation, turned round in
/// third-person front view.
pub(super) fn camera_orientation(&self) -> glam::Quat {
@@ -12689,11 +12635,11 @@ pub fn run_with() -> Result<()> {
options.spikes = Some(dedicated::SLOW_TICK_MS);
}
tick_profile::want_spans(tick_profile::SPANS_SPIKES, options.spikes.is_some());
// A player who allows performance statistics also says what their slow frames spent their
// time in: this thread, which draws the frames, times its steps (wall time only).
// A hook that wants to know what slow frames spent their time in (a player who allows
// performance statistics): this thread, which draws the frames, times its steps (wall time
// only).
if options.execution_role.is_client()
&& horizon_telemetry::Consent::load(&horizon_client::logging::game_home())
.allows(horizon_telemetry::Kind::Perf)
&& horizon_hooks::engine::wants_frame_steps(&horizon_client::logging::game_home())
{
horizon_client::spans::record_here(true);
}
@@ -12720,16 +12666,9 @@ pub fn run_with() -> Result<()> {
if !options.offscreen && !synchronous_startup(&options) {
let version = horizon_client::telemetry::version();
horizon_hooks::engine::started(&horizon_hooks::Started::new(&version));
let player_dir = horizon_client::logging::player_dir();
// horizon-extras: the official server is Horizon's (docs/forking.md).
if cfg!(feature = "horizon-extras")
&& let Err(e) = horizon_client::multiplayer::add_official_server(&player_dir)
{
log::warn!("could not add the official server to servers.dat: {e}");
}
if let Err(e) = horizon_client::multiplayer::withdraw_partner_servers(&player_dir) {
log::warn!("could not take the withdrawn partner server out of servers.dat: {e}");
}
#[cfg(feature = "horizon-extras")]
horizon_client::official_servers::update(&horizon_client::logging::player_dir());
}
mods::register_components(&vanilla_root)?;
mods::register_standalone_models();
+8 -16
View File
@@ -686,23 +686,15 @@ impl Game<'_> {
if fall == 0.0 || length < 1.0 {
return fall;
}
let to = from.add_vector(moved.normalize().scale(moved.length().min(8.0)));
let reset = &self.res.motion().fall_reset;
let fluids = &self.res.motion().fluids;
let hit = horizon_core::geometry::traverse_blocks(from, to, 1_000_000, |p| {
// A tagged block clips as a full block, not its collider.
let name = &self.res.facts().get(self.world.block(p.x, p.y, p.z)).name;
if reset.contains(&**name) && VoxelShape::block().clip(from, to, p).is_some() {
return Some(());
}
// The water's shape, through the shared fluid shape cache.
horizon_client::entities::water_clip(&self.world, fluids, from, to, p).then_some(())
});
if matches!(hit, Ok(Some(()))) {
0.0
} else {
fall
}
let hit = horizon_client::entities::fall_reset_hit(
&self.world,
&self.res.motion().fluids,
|p| reset.contains(&*self.res.facts().get(self.world.block(p.x, p.y, p.z)).name),
from,
moved,
);
if hit { 0.0 } else { fall }
}
/// `AbstractBoat.checkFallDamage` after a move that went `moved_y` (with
/// `b` already where it landed): on the ground the fall resets; a move
+78 -3
View File
@@ -33,6 +33,8 @@ fn book_title_box() -> ui::EditBox {
edit.bordered = false;
edit.max_length = 15;
edit.text_color = BLACK;
// BookSignScreen sets the title box centred.
edit.centered = true;
edit.focused = true;
edit
}
@@ -68,6 +70,23 @@ pub(super) enum BookScreen {
lectern: Option<(i32, i32, i32)>,
},
}
impl BookScreen {
/// `BookSignScreen`'s constructor: its title box opens with the title
/// kept from the last time, which Cancel stored (`titleValue`).
fn begin_signing(&mut self) {
if let Self::Edit {
signing,
title,
title_edit,
..
} = self
{
*signing = true;
title_edit.set_value(title.as_str());
title_edit.reset_blink();
}
}
}
/// The pages a book screen reads: a written book's components (so their
/// click events stay), else a writable book's text.
fn view_pages(data: Option<&StackData>) -> Vec<Text> {
@@ -986,7 +1005,7 @@ impl Game<'_> {
title,
..
}) if !title.trim().is_empty() => self.save_book(true),
Some(BookScreen::Edit { signing, .. }) => *signing = true,
Some(book @ BookScreen::Edit { .. }) => book.begin_signing(),
_ => return Err("not a book and quill".into()),
},
Some("done") => match self.book.as_mut() {
@@ -1188,8 +1207,8 @@ impl Game<'_> {
self.turn_page(false);
} else if inside(w / 2.0 - 100.0, 196.0, 98.0, 20.0) {
click(self);
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
*signing = true;
if let Some(book) = self.book.as_mut() {
book.begin_signing();
}
} else if inside(w / 2.0 + 2.0, 196.0, 98.0, 20.0) {
click(self);
@@ -1618,6 +1637,19 @@ pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos
}
}
#[cfg(test)]
mod title_box_tests {
use super::book_title_box;
/// `BookSignScreen` sets its 15 character title box centred and unbordered.
#[test]
fn the_sign_title_box_is_centred() {
let edit = book_title_box();
assert!(edit.centered);
assert!(!edit.bordered);
assert_eq!(edit.max_length, 15);
}
}
#[cfg(test)]
mod lectern_books_tag_tests {
use super::super::native_machines::tests::server_game;
@@ -1794,3 +1826,46 @@ mod lectern_reach_tests {
);
}
}
#[cfg(test)]
mod sign_tests {
use super::{BookScreen, book_page_box, book_title_box};
/// `BookSignScreen`'s constructor sets its title box to `titleValue`, what
/// Cancel kept; reopening the sign screen shows the title typed before,
/// not an empty box beside a stale `title`.
#[test]
fn the_sign_screen_opens_with_the_title_it_kept() {
let mut book = BookScreen::Edit {
slot: 0,
pages: vec!["page".into()],
page: 0,
signing: false,
title: "My Book".into(),
title_edit: book_title_box(),
page_edit: book_page_box(),
};
assert_eq!(book_title_value(&book), "");
book.begin_signing();
let BookScreen::Edit {
signing,
title,
title_edit,
..
} = &book
else {
panic!("the book stays open");
};
assert!(signing);
assert_eq!(title.as_str(), "My Book");
assert_eq!(title_edit.value, "My Book");
assert_eq!(title_edit.cursor, 7);
}
fn book_title_value(book: &BookScreen) -> String {
match book {
BookScreen::Edit { title_edit, .. } => title_edit.value.clone(),
BookScreen::View { .. } => String::new(),
}
}
}
+16 -14
View File
@@ -42,8 +42,8 @@ impl Reply {
}
pub(super) fn duration(millis: i64) -> Text {
use horizon_chat_filter::DurationUnit;
let (count, unit) = horizon_chat_filter::duration_parts(millis);
use horizon_server_files::duration::{DurationUnit, duration_parts};
let (count, unit) = duration_parts(millis);
let key = match (unit, count == 1) {
(DurationUnit::Minute, true) => "horizon.moderation.duration.minute",
(DurationUnit::Minute, false) => "horizon.moderation.duration.minutes",
@@ -93,7 +93,7 @@ pub(super) const MUTE_USAGES: [&str; 4] = [
fn mute_time(words: &[&str]) -> (Option<i64>, usize) {
match words
.get(2)
.and_then(|w| horizon_chat_filter::parse_duration(w))
.and_then(|w| horizon_server_files::duration::parse_duration(w))
{
Some(ms) => (Some(ms), 3),
None => (None, 2),
@@ -133,7 +133,7 @@ impl Game<'_> {
count: u32,
action: Action,
) {
let now = horizon_chat_filter::now_millis();
let now = horizon_server_files::duration::now_millis();
let summary = format!(
"[chat-filter] Refused {name}'s {} (hit {count}: {})",
place.name(),
@@ -281,7 +281,7 @@ impl Game<'_> {
let reason = |from: usize| (words.len() > from).then(|| words[from..].join(" "));
match words {
["mutelist"] => {
let now = horizon_chat_filter::now_millis();
let now = horizon_server_files::duration::now_millis();
let mutes = host.hub.mutes();
if mutes.is_empty() {
return vec![Reply::translated("horizon.moderation.mutelist.empty", &[])];
@@ -319,13 +319,15 @@ impl Game<'_> {
let (time, from) = mute_time(words);
vec![self.mute(host, name, time, reason(from), by)]
}
["tempmute", name, time, ..] => match horizon_chat_filter::parse_duration(time) {
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
None => vec![Reply::translated(
"horizon.moderation.invalid_duration",
&[Text::literal(*time)],
)],
},
["tempmute", name, time, ..] => {
match horizon_server_files::duration::parse_duration(time) {
Some(ms) => vec![self.mute(host, name, Some(ms), reason(3), by)],
None => vec![Reply::translated(
"horizon.moderation.invalid_duration",
&[Text::literal(*time)],
)],
}
}
["mute", ..] => vec![Reply::translated("horizon.moderation.usage.mute", &[])],
["tempmute", ..] => vec![Reply::translated("horizon.moderation.usage.tempmute", &[])],
["unmute", ..] => vec![Reply::translated("horizon.moderation.usage.unmute", &[])],
@@ -353,10 +355,10 @@ impl Game<'_> {
let Some((uuid, name)) = user else {
return Reply::no_player();
};
let until = time.map(|ms| horizon_chat_filter::now_millis() + ms);
let until = time.map(|ms| horizon_server_files::duration::now_millis() + ms);
let what = time.map_or_else(
|| "until unmuted".into(),
|ms| format!("for {}", horizon_chat_filter::duration_text(ms)),
|ms| format!("for {}", horizon_server_files::duration::duration_text(ms)),
);
let why = reason
.as_deref()
@@ -227,16 +227,16 @@ impl Game<'_> {
);
}
for m in self.mobs.iter() {
if !m.alive() {
// A mob with no effects swirls nothing: skipped before its size
// is worked out, which every mob in a crowded world would pay
// for (#14053).
if !m.alive() || (m.extra.effect_particles.is_empty() && m.extra.effects.is_empty()) {
continue;
}
let size = (f64::from(m.width()), f64::from(m.height()));
let invisible = m.invisible || m.has_effect("minecraft:invisibility");
if m.extra.effect_particles.is_empty() {
// A mob of this game: its own effects, visible and not ambient.
if m.extra.effects.is_empty() {
continue;
}
let list: Vec<_> = m
.extra
.effects
@@ -92,6 +92,7 @@ fn a_mods_fireworks_burst_and_bang_on_the_client_only() {
facts: game.res.facts(),
collision: game.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
game.particles.tick(&level);
};
+5 -5
View File
@@ -1567,11 +1567,11 @@ impl Game<'_> {
"streaming": self.streaming.stages().into_iter().map(|(k, v)| (k.to_string(), v.into())).collect::<serde_json::Map<_, _>>(),
"dirty_light": self.world.dirty_light.len(),
"renderer": renderer,
"display": self.window.as_deref().and_then(horizon_client::telemetry::monitor).map(|m| json!({
"width": m.size().width,
"height": m.size().height,
"scale": m.scale_factor(),
"refresh_hz": m.refresh_rate_millihertz().map(|r| r.div_ceil(1000)),
"display": self.window.as_deref().and_then(horizon_client::window::display).map(|d| json!({
"width": d.width,
"height": d.height,
"scale": d.scale,
"refresh_hz": d.refresh_hz,
})),
"uptime_seconds": self.started.elapsed().as_secs_f64(),
"server": self.remote_status(),
@@ -623,3 +623,6 @@ fn stack_drop_requires_ctrl_even_with_sprint_latched() {
}
}
}
#[path = "play_ping_tests.rs"]
mod play_ping_tests;
+2 -783
View File
@@ -2,8 +2,10 @@
//! chunk-generation spawning (`NaturalSpawner`), mob events (attacks,
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
mod mob_geometry;
mod projectile_world;
mod pufferfish_player;
mod spawn_eggs;
use super::{
Dim, Game, Hand, InteractTarget, entity_culling::EntityCullDecision,
@@ -7450,171 +7452,6 @@ impl Game<'_> {
}
Ok(true)
}
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
/// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby
/// at the mob's feet, taking the parent's variant as `getBreedOffspring`
/// does, and one egg used. False (PASS) when the mob has no baby form.
fn spawn_egg_offspring(&mut self, index: usize) -> bool {
let Some(stack) = self.held().cloned() else {
return false;
};
let parent = &self.mobs[index];
if !parent.alive()
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|| !horizon_client::mobs::species::can_be_a_baby(parent.species())
// WanderingTrader.getBreedOffspring returns null: no offspring, the
// click carries on to trading.
|| parent.species() == Species::WanderingTrader
{
return false;
}
let (kind, at) = (
parent.kind.id.clone(),
[parent.position.x, parent.position.y, parent.position.z],
);
let (species, variant, color) =
(parent.species(), parent.extra.variant, parent.extra.color);
let target = self.mob_entity(&self.mobs[index]);
// SpawnEggItem.spawnOffspringFromSpawnEgg: `type.create(level, SPAWN_ITEM_USE)`
// creates the baby without finalizeSpawn, and `snapTo(pos, 0, 0)` faces
// it south without a level-random draw.
let Some(id) =
self.spawn_mob_internal(&kind, at, false, false, false, false, SpawnYaw::Fixed(0.0))
else {
return false;
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
// `Mob.checkAndHandleImportantInteractions` then calls
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
// who used the egg (a fox egg used on a block trusts nobody).
if species == Species::Fox {
let spawner = self.acting_player_uuid();
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
}
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
true
}
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
pub(super) fn spawn_egg_fluid_hit(&self) -> Option<super::Hit> {
let hit = self.bucket_hit()?;
matches!(
&*self.block_name(hit.pos),
"minecraft:water" | "minecraft:lava"
)
.then_some(hit)
}
/// Shared peaceful-mode eligibility for spawn eggs.
pub(super) fn spawn_egg_allowed(&self, item: &str) -> bool {
let kind = item.trim_end_matches("_spawn_egg");
self.ui.difficulty != 0
|| self
.res
.mob_types()
.get(kind)
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
}
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
let fluid = matches!(self.guest_use, Some(super::GuestUse::Item(_)));
let Some(hit) = (if fluid {
self.spawn_egg_fluid_hit()
} else {
self.pick()
}) else {
return Ok(false);
};
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
if !self.spawn_egg_allowed(&stack.item) {
self.use_failed = true;
return Ok(true);
}
if fluid
&& !self.may_build()
&& !self.can_place_on_in_adventure([
hit.pos[0] - hit.face[0],
hit.pos[1] - hit.face[1],
hit.pos[2] - hit.face[2],
])
{
self.use_failed = true;
return Ok(true);
}
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
let (spawner_id, data) = horizon_client::block_entity_item::spawn_egg_on_spawner(
stack,
self.acting_player_is_op(),
);
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &spawner_id, data.as_ref()) {
if done {
self.swing_arm();
self.award_stat("used", &stack.item, 1);
self.consume_held();
}
return Ok(true);
}
// useOn: a clicked block without a collision shape (vines, grass)
// takes the mob itself, any other the one on the clicked face.
let p = if fluid || self.collision_empty(hit.pos) {
hit.pos
} else {
[
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
]
};
let at = [
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
];
// SpawnEggItem.spawnMob: `EntityType.spawn`.
let Some(id) = self.spawn_mob_spawned(&kind, at, SpawnYaw::Created) else {
return Ok(false);
};
// SpawnEggItem.spawnMob: `EntityType.create` finalizes the mob, then
// `PostSpawnProcessor` puts the egg's components on it.
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::entity_nbt::apply_item_components(m, stack);
}
self.apply_spawn_egg_entity_data(id, stack);
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
if !self.creative
&& let Some(s) = self.held_mut().as_mut()
{
s.count -= 1;
if s.count == 0 {
*self.held_mut() = None;
}
}
Ok(true)
}
/// `EntityType.updateCustomEntityTag`: the egg's `minecraft:entity_data` on
/// the mob it spawned, once the guards let it through, as
/// `TypedEntityData.loadInto`: the mob's own compound, the data merged over
/// it, loaded back into the same mob (`/data merge entity`'s loader).
pub(super) fn apply_spawn_egg_entity_data(&mut self, id: u32, stack: &ItemStack) {
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
return;
};
let kind = self.mobs[i].kind.id.clone();
let op = self.acting_player_is_op();
let Some(data) = horizon_client::block_entity_item::spawn_egg_entity_data(stack, &kind, op)
else {
return;
};
let own = self.mob_tree_nbt(i);
let tag = horizon_client::data_cmd::merged(&own, data);
// Nothing to report: the mob's own compound always loads back.
let _ = self.apply_entity_nbt(super::commands::Target::Mob(id), tag);
}
/// A beam target's centre (half its height), interpolated.
fn beam_target(&self, who: Option<Who>, alpha: f64) -> Option<glam::DVec3> {
match who? {
@@ -7785,624 +7622,6 @@ impl Game<'_> {
}
}
}
pub(super) fn mob_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
// The coarse mob bounds pay off only in dense async scenes; otherwise,
// keep the existing exact mesh-culling path.
if self.settings.horizon.entity_culling == EntityCullingMode::Asynchronous
&& self
.async_occlusion_culler
.as_ref()
.is_some_and(|culler| culler.camera_is_stationary())
&& self.settings.horizon.entity_culling_precheck != 0
&& self.mobs.len() >= self.settings.horizon.entity_culling_precheck as usize
&& !self.pack_shadow_casters.0
&& horizon_client::mod_api::mods().is_empty()
{
self.mob_geometry_inner::<true>(camera, alpha, out);
} else {
self.mob_geometry_inner::<false>(camera, alpha, out);
}
}
fn mob_geometry_inner<const PRECHECK: bool>(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
let skinning = self.skinning();
// What custom entity model animations read of the frame.
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
time: self.world.game_time as f64 + alpha,
day_time: self.world.day_time as f64 + alpha,
dimension: match self.dim {
Dim::Overworld => 0.0,
Dim::Nether => -1.0,
Dim::End => 1.0,
},
player: self.player.as_ref().map_or(camera.to_array(), |p| {
[p.position.x, p.position.y, p.position.z]
}),
player_rot: [self.pitch, self.yaw],
paused: self.pause,
first_person: self.camera_type.first_person(),
third_person_reversed: self.camera_type
== super::perspective::CameraType::ThirdPersonFront,
..Default::default()
});
for mob in &self.mobs {
// LevelRenderer: the entity the view looks out of is not drawn
// in first person.
if self.hides_as_camera(mob.id) {
continue;
}
let is_player = &*mob.kind.id == "minecraft:player";
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the view scale. RemotePlayer's box counts
// ten times.
let (w, h, _) = mob.dims();
let edge = (w + h + w) / 3.0 * if is_player { 10.0 } else { 1.0 };
let range = edge * 64.0 * scale;
if d.length_squared() >= range * range {
continue;
}
let beam = horizon_client::mobs::guardian::beam(mob, alpha as f32);
// EntityRenderer.shouldRender: the renderer's interpolated culling box,
// grown by 0.5 (GuardianRenderer also draws while its beam is out).
if let Some(view) = &self.view_frustum
&& beam.is_none()
{
let (lo, hi) = super::entity_culling::cull_box(mob, alpha);
let (lo, hi) = (
(lo - camera - glam::DVec3::splat(0.5)).as_vec3(),
(hi - camera + glam::DVec3::splat(0.5)).as_vec3(),
);
if !view.intersects(lo, hi) {
continue;
}
}
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
// A player keeps its armour, elytra and held items
// (`LivingEntityRenderer.submit` skips only the body).
let layers_only = mob.invisible && outline.is_none() && is_player;
if mob.invisible && outline.is_none() && !layers_only {
continue;
}
let culling = if PRECHECK
&& outline.is_none()
&& !is_player
&& mob.kind.id.starts_with("minecraft:")
&& [
&mob.extra.head,
&mob.extra.chest,
&mob.extra.legs,
&mob.extra.feet,
&mob.extra.main_hand,
&mob.extra.off_hand,
]
.iter()
.all(|item| item.is_none())
&& beam.is_none()
{
self.entity_cull_decision(mob, alpha)
} else {
EntityCullDecision::CheckVertices
};
if culling.hides_entity() {
continue;
}
let entity_start = out.len();
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
self.mark_entity(entity_start, &mob.kind.id);
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
// `EntityRenderer.getPackedLightCoords`: the block the mob's light
// probe (its eye) is in, at the position this frame draws rather
// than this tick's.
let probe = mob.light_probe(alpha);
let light = self.world.light(probe[0], probe[1], probe[2]);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
// renderers' overrides; the sky light stays the level's.
if mob.fire_ticks > 0
|| matches!(
mob.species(),
Species::Allay
| Species::Blaze
| Species::MagmaCube
| Species::Vex
| Species::Wither
)
{
packed = (packed & 0xFF) | ((15 * 16) << 8);
}
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
// dark ticks run out (the block light is the second byte).
if mob.species() == Species::GlowSquid {
let lit = (packed >> 12) & 15;
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
packed = (packed & 0xFF) | ((block * 16) << 8);
}
// A native mod's own model in place of the vanilla one
// (`native_mob_submit`), before any of the vanilla model's parts
// (a sulfur cube's swallowed block) are drawn.
// A player's own hook (`native_player_submit`) is asked for the
// other players in view.
let who = is_player
.then(|| self.remote_player_name(mob.id))
.flatten()
.map(|name| NativePlayerRender { name, local: false });
let modded = if layers_only {
false
} else if let Some(who) = who {
self.mod_player_geometry(mob, who, alpha as f32, camera, packed, out)
} else {
!is_player && self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
};
if modded {
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
if outline.is_none() {
self.cull_entity_vertices(out, entity_start);
}
continue;
}
if mob.species() == Species::SulfurCube
&& !horizon_client::mobs::trader::is_invisible(mob)
{
self.sulfur_cube_body_geometry(
mob,
alpha as f32,
horizon_client::mobs::render::base_transform(mob, alpha as f32, camera),
packed,
out,
);
}
// Other players on a server: PlayerRenderer's HumanoidArmorLayer.
let armor = if is_player {
self.worn_armor_layers(mob)
} else {
Vec::new()
};
let body = out.len();
// The GPU path: an ordinary mob, no layers the CPU path changes after drawing them
// (a player's, the HDR tiers below, the sulfur cube's shell). A glowing mob's
// silhouette is posed too (`SkinFrame::outline_since`), unless it is invisible:
// then only the outline draws.
let silhouette_on_gpu = outline.is_none() || !mob.invisible;
let posable = skinning
&& !is_player
&& !layers_only
&& silhouette_on_gpu
&& armor.is_empty()
&& !matches!(
mob.species(),
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
);
let mut skinned = None;
if posable {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mark = frame.mark();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
horizon_client::mobs::render::render_skinned(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
out,
&mut skin,
);
skinned = Some((mark, skin.bounds));
} else {
let render = if layers_only {
horizon_client::mobs::render::render_layers
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
}
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
// fs_hdr_mark); a tier's window lifts only their bright texels.
if let Some(tier) = match mob.species() {
Species::Blaze => Some(horizon_client::mesher::BRIGHT),
Species::MagmaCube | Species::GlowSquid => Some(horizon_client::mesher::LOW),
_ => None,
} {
for v in &mut out[body..] {
v.layer |= u32::from(tier) << 16;
}
}
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
let layers = horizon_client::mobs::render::layers_shown(mob);
if is_player && layers {
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
// and SpinAttackEffectLayer. Only the cape hides with the body;
// the others have no `isInvisible` check.
let chest = mob.extra.chest.as_deref();
if !layers_only {
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
}
if let Some((_, name)) = self
.remote_player_names()
.into_iter()
.find(|(id, _)| *id == mob.id)
{
self.player_ears_geometry(mob, &name, camera, alpha as f32, packed, out);
}
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(mob, camera, alpha as f32, packed, out);
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
// with it.
if !layers_only
&& super::cosmetics::enabled()
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
{
let mark = self.skin_frame.borrow().mark();
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
let gpu = skinning;
if let Some((lo, hi)) = self.cosmetics_geometry(
mob,
false,
base,
Some(camera),
alpha as f32,
packed,
gpu,
out,
) {
skinned = Some(match skinned {
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
Some((first, None)) => (first, Some((lo, hi))),
None => (mark, Some((lo, hi))),
});
}
}
// CustomHeadLayer (a pumpkin, skull or head item) included.
if layers {
// Held items join the GPU path's draws, their silhouette too.
let skin = (skinning && silhouette_on_gpu).then_some(&mut skinned);
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
}
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if let Some(((first, _, _), _)) = skinned {
let color = horizon_client::mobs::render::abgr(rgb);
self.skin_frame.borrow_mut().outline_since(first, color);
}
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
// Glowing outlines and a guardian's beam remain visible independently
// of whether the body is hidden behind terrain.
if outline.is_none() && culling.checks_vertices() && beam.is_none() {
// What the GPU draws of the mob counts by its bounds' corners.
self.cull_skinned(out, entity_start, skinned);
}
// GuardianRenderer: the laser to the locked target.
if let Some((time, scale)) = beam
&& let Some(target) = self.beam_target(mob.ai.target, alpha)
&& let Some(uv) = lookup("entity/guardian/guardian_beam")
{
let a = alpha;
let eye = glam::DVec3::new(
mob.previous.x + (mob.position.x - mob.previous.x) * a,
mob.previous.y + (mob.position.y - mob.previous.y) * a + mob.eye_height(),
mob.previous.z + (mob.position.z - mob.previous.z) * a,
) - camera;
horizon_client::mobs::guardian::beam_geometry(
eye.as_vec3(),
(target - camera).as_vec3(),
time,
scale,
uv,
out,
);
}
}
// What follows isn't a tracked mob: close the last one's span.
self.mark_motion(out.len(), 0);
self.elder_apparition_geometry(alpha, out);
self.spawner_geometry(camera, alpha, out);
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
let (right, up) = thrown_item_axes(self.camera_type, self.yaw, self.pitch);
let live = self.creatures.projectiles.iter().map(|a| {
let at = [
a.previous.x + (a.position.x - a.previous.x) * alpha,
a.previous.y + (a.position.y - a.previous.y) * alpha,
a.previous.z + (a.position.z - a.previous.z) * alpha,
];
(a, at)
});
// ItemPickupParticle: a picked-up arrow flying to its collector.
let taken = self.pickups.live.iter().filter_map(|p| match &p.taken {
super::pickups::Taken::Arrow(a) => Some((&**a, super::pickups::position(p, alpha))),
_ => None,
});
// A glowing projectile's outline: what it drew up to the next one.
let mut glowing = None;
for (a, at) in live.chain(taken) {
if let Some((start, rgb)) = glowing.take() {
self.mark_outline(&out[start..], rgb);
}
if a.id != 0
&& let Some(rgb) = self.thing_outline(a.id, a.kind.id())
{
glowing = Some((out.len(), rgb));
}
let p = glam::DVec3::from_array(at) - camera;
let c = p.as_vec3();
// EntityRenderer.getPackedLightCoords: the block its light probe
// (the eye) is in, at the position this frame draws rather than
// this tick's, and not the one under its feet.
let light = self.world.light(
at[0].floor() as i32,
a.light_probe_y(alpha).floor() as i32,
at[2].floor() as i32,
);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a shulker bullet and anything
// on fire are drawn at 15 block light (the fireball renderers'
// own override); the sky light stays the level's.
if let Some(block) = a.block_light_override() {
packed = (packed & 0xFF) | (u32::from(block) * 16) << 8;
}
if a.is_arrow() {
let Some(model) = self.arrow_model.as_ref() else {
continue;
};
// SpectralArrowRenderer, and TippableArrowRenderer's `getColor() > 0`.
let texture = if a.is_spectral_arrow() {
"entity/projectiles/arrow_spectral"
} else if a.arrow.as_ref().is_some_and(|s| s.effect_color > 0) {
"entity/projectiles/arrow_tipped"
} else {
"entity/projectiles/arrow"
};
let Some(r) = atlas.sprites.get(texture).copied() else {
continue;
};
// ArrowRenderer: `Mth.lerp(partialTick, state.yRotO, state.yRot)`
// and the same for the pitch, so a turning arrow is drawn
// between the two ticks it turned over.
let alpha32 = alpha as f32;
let yaw = a.previous_yaw + alpha32 * (a.yaw - a.previous_yaw);
let pitch = a.previous_pitch + alpha32 * (a.pitch - a.previous_pitch);
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z(pitch.to_radians());
// ArrowRenderer: `state.shake = shakeTime - partialTicks`; ArrowModel.setupAnim
// rolls the root while the shake timer runs.
let shake = a
.arrow
.as_ref()
.map_or(0.0, |s| s.shake as f32 - alpha as f32);
let roll = if shake > 0.0 {
-(shake * 3.0).msin() * shake * (std::f32::consts::PI / 180.0)
} else {
0.0
};
let pose_for = |part: &horizon_client::mobs::Part| {
let mut pose = part.pose;
if std::ptr::eq(part, model) {
pose[5] += roll;
}
pose
};
horizon_client::mobs::render_model(
model,
m,
&pose_for,
[r.u0, r.v0, r.u1, r.v1],
packed,
0,
out,
);
if a.arrow.as_ref().is_some_and(|s| s.on_fire()) {
flame_geometry(atlas, c, 0.5, 0.5, self.yaw, light, out);
}
continue;
}
// ThrownTridentRenderer.
if matches!(a.kind, ProjectileKind::Trident) {
let Some(model) = self.trident_model.as_ref() else {
continue;
};
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
continue;
};
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
let poses = horizon_client::mobs::anim::Poses::new(model);
horizon_client::mobs::render::draw(
model,
m,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
continue;
}
// WindChargeRenderer.
if matches!(
a.kind,
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
) {
self.wind_charge_geometry(a, c, packed, alpha as f32, out);
continue;
}
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
let model = &self.entity_models.shulker_bullet;
if let (Some(model), Some(r)) =
(model, atlas.sprites.get("entity/shulker/spark").copied())
{
use horizon_core::geometry::trig;
let tc = a.age as f32 + alpha as f32;
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("main", a.yaw.to_radians());
poses.xrot("main", a.pitch.to_radians());
let light = (packed & 0xFF) | ((15 * 16) << 8);
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
* glam::Mat4::from_rotation_y(
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_x(
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_z(
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
)
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
let uv = [r.u0, r.v0, r.u1, r.v1];
horizon_client::mobs::render::draw(
model,
base,
&poses,
uv,
0xFFFF_FFFF,
light,
0,
out,
);
// The translucent shell blends over the opaque core
// (`RenderTypes.entityTranslucent`).
let shell = out.len();
horizon_client::mobs::render::draw(
model,
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
&poses,
uv,
0x26FF_FFFF,
light,
0,
out,
);
horizon_client::gfx::mark_oit(&mut out[shell..]);
}
continue;
}
// WitherSkullRenderer: the skull model facing its flight.
if let Some(dangerous) = super::wither::skull_kind(a) {
let model = &self.entity_models.wither_skull;
let tex = if dangerous {
"entity/wither/wither_invulnerable"
} else {
"entity/wither/wither"
};
if let (Some(model), Some(r)) = (model, atlas.sprites.get(tex).copied()) {
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("head", a.yaw.to_radians());
poses.xrot("head", a.pitch.to_radians());
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0));
horizon_client::mobs::render::draw(
model,
base,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
}
continue;
}
if let Some(stack) = a.drawn_item() {
let owner = super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation());
self.submit_item_world(&state, pose, packed, out);
continue;
}
// `Entity.displayFireAnimation`: a thrown item that flew through a
// fire block burns (`ThrowableProjectile.tick`'s
// `applyEffectsFromBlocks`), and ThrownItemRenderer draws the flame
// over its box as any other entity renderer does.
if a.on_fire() {
let size = a.kind.size() as f32;
flame_geometry(atlas, c, size, size, self.yaw, light, out);
}
// `ThrownItemRenderer` for a fireball: the item `Fireball.getItem`
// returns (a fire charge until `setItem` gave it another), its model
// at the renderer's scale for the kind. `packed` is already 15, the
// renderer's full bright.
if let Some(stack) = a.item.as_ref() {
let owner = super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation())
* glam::Mat4::from_scale(glam::Vec3::splat(a.sprite_scale()));
self.submit_item_world(&state, pose, packed, out);
continue;
}
let Some(name) = a.item_sprite() else {
continue;
};
let Some(r) = atlas.sprites.get(name).copied() else {
continue;
};
let lift = is_item_billboard(&a.kind);
let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift);
for (v, uv) in quad
.iter()
.zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]])
{
out.push(Vertex {
normal: [0; 4],
pos: v.to_array(),
uv,
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
if let Some((start, rgb)) = glowing {
self.mark_outline(&out[start..], rgb);
}
}
}
/// `ThrownItemRenderer.submit` turns the item by `CameraRenderState.orientation`
/// (`Camera.setRotation`): the billboard's right and up axes are the camera's.
@@ -0,0 +1,629 @@
//! Drawing the mobs, players and projectiles the client sees, each frame (`mob_geometry`).
use super::*;
impl Game<'_> {
pub(in crate::game) fn mob_geometry(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
// The coarse mob bounds pay off only in dense async scenes; otherwise,
// keep the existing exact mesh-culling path.
if self.settings.horizon.entity_culling == EntityCullingMode::Asynchronous
&& self
.async_occlusion_culler
.as_ref()
.is_some_and(|culler| culler.camera_is_stationary())
&& self.settings.horizon.entity_culling_precheck != 0
&& self.mobs.len() >= self.settings.horizon.entity_culling_precheck as usize
&& !self.pack_shadow_casters.0
&& horizon_client::mod_api::mods().is_empty()
{
self.mob_geometry_inner::<true>(camera, alpha, out);
} else {
self.mob_geometry_inner::<false>(camera, alpha, out);
}
}
fn mob_geometry_inner<const PRECHECK: bool>(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
let skinning = self.skinning();
// What custom entity model animations read of the frame.
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
time: self.world.game_time as f64 + alpha,
day_time: self.world.day_time as f64 + alpha,
dimension: match self.dim {
Dim::Overworld => 0.0,
Dim::Nether => -1.0,
Dim::End => 1.0,
},
player: self.player.as_ref().map_or(camera.to_array(), |p| {
[p.position.x, p.position.y, p.position.z]
}),
player_rot: [self.pitch, self.yaw],
paused: self.pause,
first_person: self.camera_type.first_person(),
third_person_reversed: self.camera_type
== super::super::perspective::CameraType::ThirdPersonFront,
..Default::default()
});
for mob in &self.mobs {
// LevelRenderer: the entity the view looks out of is not drawn
// in first person.
if self.hides_as_camera(mob.id) {
continue;
}
let is_player = &*mob.kind.id == "minecraft:player";
let d = glam::DVec3::new(mob.position.x, mob.position.y, mob.position.z) - camera;
// Entity.shouldRenderAtSqrDistance: 64 times the bounding box's
// mean edge, scaled by the view scale. RemotePlayer's box counts
// ten times.
let (w, h, _) = mob.dims();
let edge = (w + h + w) / 3.0 * if is_player { 10.0 } else { 1.0 };
let range = edge * 64.0 * scale;
if d.length_squared() >= range * range {
continue;
}
let beam = horizon_client::mobs::guardian::beam(mob, alpha as f32);
// EntityRenderer.shouldRender: the renderer's interpolated culling box,
// grown by 0.5 (GuardianRenderer also draws while its beam is out).
if let Some(view) = &self.view_frustum
&& beam.is_none()
{
let (lo, hi) = super::super::entity_culling::cull_box(mob, alpha);
let (lo, hi) = (
(lo - camera - glam::DVec3::splat(0.5)).as_vec3(),
(hi - camera + glam::DVec3::splat(0.5)).as_vec3(),
);
if !view.intersects(lo, hi) {
continue;
}
}
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
// A player keeps its armour, elytra and held items
// (`LivingEntityRenderer.submit` skips only the body).
let layers_only = mob.invisible && outline.is_none() && is_player;
if mob.invisible && outline.is_none() && !layers_only {
continue;
}
let culling = if PRECHECK
&& outline.is_none()
&& !is_player
&& mob.kind.id.starts_with("minecraft:")
&& [
&mob.extra.head,
&mob.extra.chest,
&mob.extra.legs,
&mob.extra.feet,
&mob.extra.main_hand,
&mob.extra.off_hand,
]
.iter()
.all(|item| item.is_none())
&& beam.is_none()
{
self.entity_cull_decision(mob, alpha)
} else {
EntityCullDecision::CheckVertices
};
if culling.hides_entity() {
continue;
}
let entity_start = out.len();
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
self.mark_entity(entity_start, &mob.kind.id);
self.rendered_mobs.set(self.rendered_mobs.get() + 1);
// `EntityRenderer.getPackedLightCoords`: the block the mob's light
// probe (its eye) is in, at the position this frame draws rather
// than this tick's.
let probe = mob.light_probe(alpha);
let light = self.world.light(probe[0], probe[1], probe[2]);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a burning mob has 15 block
// light, and so do the Allay, Blaze, Magma Cube, Vex and Wither
// renderers' overrides; the sky light stays the level's.
if mob.fire_ticks > 0
|| matches!(
mob.species(),
Species::Allay
| Species::Blaze
| Species::MagmaCube
| Species::Vex
| Species::Wither
)
{
packed = (packed & 0xFF) | ((15 * 16) << 8);
}
// GlowSquidRenderer.getBlockLightLevel: the body brightens as the
// dark ticks run out (the block light is the second byte).
if mob.species() == Species::GlowSquid {
let lit = (packed >> 12) & 15;
let block = horizon_client::mobs::aquatic::glow_squid_block_light(mob, lit);
packed = (packed & 0xFF) | ((block * 16) << 8);
}
// A native mod's own model in place of the vanilla one
// (`native_mob_submit`), before any of the vanilla model's parts
// (a sulfur cube's swallowed block) are drawn.
// A player's own hook (`native_player_submit`) is asked for the
// other players in view.
let who = is_player
.then(|| self.remote_player_name(mob.id))
.flatten()
.map(|name| NativePlayerRender { name, local: false });
let modded = if layers_only {
false
} else if let Some(who) = who {
self.mod_player_geometry(mob, who, alpha as f32, camera, packed, out)
} else {
!is_player && self.mod_mob_geometry(mob, alpha as f32, camera, packed, out)
};
if modded {
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
if outline.is_none() {
self.cull_entity_vertices(out, entity_start);
}
continue;
}
if mob.species() == Species::SulfurCube
&& !horizon_client::mobs::trader::is_invisible(mob)
{
self.sulfur_cube_body_geometry(
mob,
alpha as f32,
horizon_client::mobs::render::base_transform(mob, alpha as f32, camera),
packed,
out,
);
}
// Other players on a server: PlayerRenderer's HumanoidArmorLayer.
let armor = if is_player {
self.worn_armor_layers(mob)
} else {
Vec::new()
};
let body = out.len();
// The GPU path: an ordinary mob, no layers the CPU path changes after drawing them
// (a player's, the HDR tiers below, the sulfur cube's shell). A glowing mob's
// silhouette is posed too (`SkinFrame::outline_since`), unless it is invisible:
// then only the outline draws.
let silhouette_on_gpu = outline.is_none() || !mob.invisible;
let posable = skinning
&& !is_player
&& !layers_only
&& silhouette_on_gpu
&& armor.is_empty()
&& !matches!(
mob.species(),
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
);
let mut skinned = None;
if posable {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mark = frame.mark();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
horizon_client::mobs::render::render_skinned(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
out,
&mut skin,
);
skinned = Some((mark, skin.bounds));
} else {
let render = if layers_only {
horizon_client::mobs::render::render_layers
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
}
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
// fs_hdr_mark); a tier's window lifts only their bright texels.
if let Some(tier) = match mob.species() {
Species::Blaze => Some(horizon_client::mesher::BRIGHT),
Species::MagmaCube | Species::GlowSquid => Some(horizon_client::mesher::LOW),
_ => None,
} {
for v in &mut out[body..] {
v.layer |= u32::from(tier) << 16;
}
}
// AvatarRenderer.shouldRenderLayers: a spectator draws no layers.
let layers = horizon_client::mobs::render::layers_shown(mob);
if is_player && layers {
// PlayerRenderer's CapeLayer, WingsLayer, ParrotOnShoulderLayer
// and SpinAttackEffectLayer. Only the cape hides with the body;
// the others have no `isInvisible` check.
let chest = mob.extra.chest.as_deref();
if !layers_only {
self.player_cape_geometry(mob, chest, camera, alpha as f32, packed, out);
}
if let Some((_, name)) = self
.remote_player_names()
.into_iter()
.find(|(id, _)| *id == mob.id)
{
self.player_ears_geometry(mob, &name, camera, alpha as f32, packed, out);
}
self.player_elytra_geometry(mob, chest, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(mob, camera, alpha as f32, packed, out);
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
// with it.
if !layers_only
&& super::super::cosmetics::enabled()
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
{
let mark = self.skin_frame.borrow().mark();
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
let gpu = skinning;
if let Some((lo, hi)) = self.cosmetics_geometry(
mob,
false,
base,
Some(camera),
alpha as f32,
packed,
gpu,
out,
) {
skinned = Some(match skinned {
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
Some((first, None)) => (first, Some((lo, hi))),
None => (mark, Some((lo, hi))),
});
}
}
// CustomHeadLayer (a pumpkin, skull or head item) included.
if layers {
// Held items join the GPU path's draws, their silhouette too.
let skin = (skinning && silhouette_on_gpu).then_some(&mut skinned);
self.mob_block_geometry(mob, camera, alpha, packed, skin, out);
// `native_mob_layer`: a native mod's layer, over the vanilla ones.
self.mod_mob_layer_geometry(mob, who, alpha as f32, camera, packed, out);
}
if let Some(rgb) = outline {
self.mark_outline(&out[entity_start..], rgb);
if let Some(((first, _, _), _)) = skinned {
let color = horizon_client::mobs::render::abgr(rgb);
self.skin_frame.borrow_mut().outline_since(first, color);
}
if mob.invisible {
out.truncate(entity_start);
continue;
}
}
// Glowing outlines and a guardian's beam remain visible independently
// of whether the body is hidden behind terrain.
if outline.is_none() && culling.checks_vertices() && beam.is_none() {
// What the GPU draws of the mob counts by its bounds' corners.
self.cull_skinned(out, entity_start, skinned);
}
// GuardianRenderer: the laser to the locked target.
if let Some((time, scale)) = beam
&& let Some(target) = self.beam_target(mob.ai.target, alpha)
&& let Some(uv) = lookup("entity/guardian/guardian_beam")
{
let a = alpha;
let eye = glam::DVec3::new(
mob.previous.x + (mob.position.x - mob.previous.x) * a,
mob.previous.y + (mob.position.y - mob.previous.y) * a + mob.eye_height(),
mob.previous.z + (mob.position.z - mob.previous.z) * a,
) - camera;
horizon_client::mobs::guardian::beam_geometry(
eye.as_vec3(),
(target - camera).as_vec3(),
time,
scale,
uv,
out,
);
}
}
// What follows isn't a tracked mob: close the last one's span.
self.mark_motion(out.len(), 0);
self.elder_apparition_geometry(alpha, out);
self.spawner_geometry(camera, alpha, out);
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
let (right, up) = thrown_item_axes(self.camera_type, self.yaw, self.pitch);
let live = self.creatures.projectiles.iter().map(|a| {
let at = [
a.previous.x + (a.position.x - a.previous.x) * alpha,
a.previous.y + (a.position.y - a.previous.y) * alpha,
a.previous.z + (a.position.z - a.previous.z) * alpha,
];
(a, at)
});
// ItemPickupParticle: a picked-up arrow flying to its collector.
let taken = self.pickups.live.iter().filter_map(|p| match &p.taken {
super::super::pickups::Taken::Arrow(a) => {
Some((&**a, super::super::pickups::position(p, alpha)))
}
_ => None,
});
// A glowing projectile's outline: what it drew up to the next one.
let mut glowing = None;
for (a, at) in live.chain(taken) {
if let Some((start, rgb)) = glowing.take() {
self.mark_outline(&out[start..], rgb);
}
if a.id != 0
&& let Some(rgb) = self.thing_outline(a.id, a.kind.id())
{
glowing = Some((out.len(), rgb));
}
let p = glam::DVec3::from_array(at) - camera;
let c = p.as_vec3();
// EntityRenderer.getPackedLightCoords: the block its light probe
// (the eye) is in, at the position this frame draws rather than
// this tick's, and not the one under its feet.
let light = self.world.light(
at[0].floor() as i32,
a.light_probe_y(alpha).floor() as i32,
at[2].floor() as i32,
);
let mut packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
// EntityRenderer.getBlockLightLevel: a shulker bullet and anything
// on fire are drawn at 15 block light (the fireball renderers'
// own override); the sky light stays the level's.
if let Some(block) = a.block_light_override() {
packed = (packed & 0xFF) | (u32::from(block) * 16) << 8;
}
if a.is_arrow() {
let Some(model) = self.arrow_model.as_ref() else {
continue;
};
// SpectralArrowRenderer, and TippableArrowRenderer's `getColor() > 0`.
let texture = if a.is_spectral_arrow() {
"entity/projectiles/arrow_spectral"
} else if a.arrow.as_ref().is_some_and(|s| s.effect_color > 0) {
"entity/projectiles/arrow_tipped"
} else {
"entity/projectiles/arrow"
};
let Some(r) = atlas.sprites.get(texture).copied() else {
continue;
};
// ArrowRenderer: `Mth.lerp(partialTick, state.yRotO, state.yRot)`
// and the same for the pitch, so a turning arrow is drawn
// between the two ticks it turned over.
let alpha32 = alpha as f32;
let yaw = a.previous_yaw + alpha32 * (a.yaw - a.previous_yaw);
let pitch = a.previous_pitch + alpha32 * (a.pitch - a.previous_pitch);
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z(pitch.to_radians());
// ArrowRenderer: `state.shake = shakeTime - partialTicks`; ArrowModel.setupAnim
// rolls the root while the shake timer runs.
let shake = a
.arrow
.as_ref()
.map_or(0.0, |s| s.shake as f32 - alpha as f32);
let roll = if shake > 0.0 {
-(shake * 3.0).msin() * shake * (std::f32::consts::PI / 180.0)
} else {
0.0
};
let pose_for = |part: &horizon_client::mobs::Part| {
let mut pose = part.pose;
if std::ptr::eq(part, model) {
pose[5] += roll;
}
pose
};
horizon_client::mobs::render_model(
model,
m,
&pose_for,
[r.u0, r.v0, r.u1, r.v1],
packed,
0,
out,
);
if a.arrow.as_ref().is_some_and(|s| s.on_fire()) {
flame_geometry(atlas, c, 0.5, 0.5, self.yaw, light, out);
}
continue;
}
// ThrownTridentRenderer.
if matches!(a.kind, ProjectileKind::Trident) {
let Some(model) = self.trident_model.as_ref() else {
continue;
};
let Some(r) = atlas.sprites.get("entity/trident/trident").copied() else {
continue;
};
let m = glam::Mat4::from_translation(c)
* glam::Mat4::from_rotation_y((a.yaw - 90.0).to_radians())
* glam::Mat4::from_rotation_z((a.pitch + 90.0).to_radians());
let poses = horizon_client::mobs::anim::Poses::new(model);
horizon_client::mobs::render::draw(
model,
m,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
continue;
}
// WindChargeRenderer.
if matches!(
a.kind,
ProjectileKind::WindCharge | ProjectileKind::BreezeWindCharge
) {
self.wind_charge_geometry(a, c, packed, alpha as f32, out);
continue;
}
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
let model = &self.entity_models.shulker_bullet;
if let (Some(model), Some(r)) =
(model, atlas.sprites.get("entity/shulker/spark").copied())
{
use horizon_core::geometry::trig;
let tc = a.age as f32 + alpha as f32;
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("main", a.yaw.to_radians());
poses.xrot("main", a.pitch.to_radians());
let light = (packed & 0xFF) | ((15 * 16) << 8);
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_translation(glam::Vec3::new(0.0, 0.15, 0.0))
* glam::Mat4::from_rotation_y(
(trig::sin(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_x(
(trig::cos(f64::from(tc * 0.1)) * 180.0).to_radians(),
)
* glam::Mat4::from_rotation_z(
(trig::sin(f64::from(tc * 0.15)) * 360.0).to_radians(),
)
* glam::Mat4::from_scale(glam::Vec3::new(-0.5, -0.5, 0.5));
let uv = [r.u0, r.v0, r.u1, r.v1];
horizon_client::mobs::render::draw(
model,
base,
&poses,
uv,
0xFFFF_FFFF,
light,
0,
out,
);
// The translucent shell blends over the opaque core
// (`RenderTypes.entityTranslucent`).
let shell = out.len();
horizon_client::mobs::render::draw(
model,
base * glam::Mat4::from_scale(glam::Vec3::splat(1.5)),
&poses,
uv,
0x26FF_FFFF,
light,
0,
out,
);
horizon_client::gfx::mark_oit(&mut out[shell..]);
}
continue;
}
// WitherSkullRenderer: the skull model facing its flight.
if let Some(dangerous) = super::super::wither::skull_kind(a) {
let model = &self.entity_models.wither_skull;
let tex = if dangerous {
"entity/wither/wither_invulnerable"
} else {
"entity/wither/wither"
};
if let (Some(model), Some(r)) = (model, atlas.sprites.get(tex).copied()) {
let mut poses = horizon_client::mobs::anim::Poses::new(model);
poses.yrot("head", a.yaw.to_radians());
poses.xrot("head", a.pitch.to_radians());
let base = glam::Mat4::from_translation(c)
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0));
horizon_client::mobs::render::draw(
model,
base,
&poses,
[r.u0, r.v0, r.u1, r.v1],
0xFFFF_FFFF,
packed,
0,
out,
);
}
continue;
}
if let Some(stack) = a.drawn_item() {
let owner = super::super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation());
self.submit_item_world(&state, pose, packed, out);
continue;
}
// `Entity.displayFireAnimation`: a thrown item that flew through a
// fire block burns (`ThrowableProjectile.tick`'s
// `applyEffectsFromBlocks`), and ThrownItemRenderer draws the flame
// over its box as any other entity renderer does.
if a.on_fire() {
let size = a.kind.size() as f32;
flame_geometry(atlas, c, size, size, self.yaw, light, out);
}
// `ThrownItemRenderer` for a fireball: the item `Fireball.getItem`
// returns (a fire charge until `setItem` gave it another), its model
// at the renderer's scale for the kind. `packed` is already 15, the
// renderer's full bright.
if let Some(stack) = a.item.as_ref() {
let owner = super::super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation())
* glam::Mat4::from_scale(glam::Vec3::splat(a.sprite_scale()));
self.submit_item_world(&state, pose, packed, out);
continue;
}
let Some(name) = a.item_sprite() else {
continue;
};
let Some(r) = atlas.sprites.get(name).copied() else {
continue;
};
let lift = is_item_billboard(&a.kind);
let quad = thrown_item_quad(c, right, up, a.sprite_scale(), lift);
for (v, uv) in quad
.iter()
.zip([[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]])
{
out.push(Vertex {
normal: [0; 4],
pos: v.to_array(),
uv,
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
glint_uv: [0.0, 0.0],
});
}
}
if let Some((start, rgb)) = glowing {
self.mark_outline(&out[start..], rgb);
}
}
}
@@ -0,0 +1,171 @@
//! Spawn eggs: a baby from an egg used on its own kind, an egg used on a block or a fluid, and the
//! egg's entity data on the mob it spawned.
use super::*;
impl Game<'_> {
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
/// `index`'s own kind (`SpawnEggItem.spawnOffspringFromSpawnEgg`): a baby
/// at the mob's feet, taking the parent's variant as `getBreedOffspring`
/// does, and one egg used. False (PASS) when the mob has no baby form.
pub(super) fn spawn_egg_offspring(&mut self, index: usize) -> bool {
let Some(stack) = self.held().cloned() else {
return false;
};
let parent = &self.mobs[index];
if !parent.alive()
|| !horizon_client::mobs::finalize::spawn_egg_is_for(&stack.item, &parent.kind.id)
|| !horizon_client::mobs::species::can_be_a_baby(parent.species())
// WanderingTrader.getBreedOffspring returns null: no offspring, the
// click carries on to trading.
|| parent.species() == Species::WanderingTrader
{
return false;
}
let (kind, at) = (
parent.kind.id.clone(),
[parent.position.x, parent.position.y, parent.position.z],
);
let (species, variant, color) =
(parent.species(), parent.extra.variant, parent.extra.color);
let target = self.mob_entity(&self.mobs[index]);
// SpawnEggItem.spawnOffspringFromSpawnEgg: `type.create(level, SPAWN_ITEM_USE)`
// creates the baby without finalizeSpawn, and `snapTo(pos, 0, 0)` faces
// it south without a level-random draw.
let Some(id) =
self.spawn_mob_internal(&kind, at, false, false, false, false, SpawnYaw::Fixed(0.0))
else {
return false;
};
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
// `Mob.checkAndHandleImportantInteractions` then calls
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
// who used the egg (a fox egg used on a block trusts nobody).
if species == Species::Fox {
let spawner = self.acting_player_uuid();
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
}
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
true
}
/// `SpawnEggItem.use`: the SOURCE_ONLY ray must hit a liquid block.
pub(in crate::game) fn spawn_egg_fluid_hit(&self) -> Option<super::super::Hit> {
let hit = self.bucket_hit()?;
matches!(
&*self.block_name(hit.pos),
"minecraft:water" | "minecraft:lava"
)
.then_some(hit)
}
/// Shared peaceful-mode eligibility for spawn eggs.
pub(in crate::game) fn spawn_egg_allowed(&self, item: &str) -> bool {
let kind = item.trim_end_matches("_spawn_egg");
self.ui.difficulty != 0
|| self
.res
.mob_types()
.get(kind)
.is_none_or(|t| horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species))
}
/// `SpawnEggItem.useOn` for a block, or `use` for a fluid source.
pub(super) fn use_spawn_egg(&mut self, stack: &ItemStack) -> Result<bool> {
let fluid = matches!(self.guest_use, Some(super::super::GuestUse::Item(_)));
let Some(hit) = (if fluid {
self.spawn_egg_fluid_hit()
} else {
self.pick()
}) else {
return Ok(false);
};
let kind = stack.item.trim_end_matches("_spawn_egg").to_owned();
if !self.spawn_egg_allowed(&stack.item) {
self.use_failed = true;
return Ok(true);
}
if fluid
&& !self.may_build()
&& !self.can_place_on_in_adventure([
hit.pos[0] - hit.face[0],
hit.pos[1] - hit.face[1],
hit.pos[2] - hit.face[2],
])
{
self.use_failed = true;
return Ok(true);
}
// SpawnEggItem.useOn on a spawner sets its mob (and an op's egg data).
let (spawner_id, data) = horizon_client::block_entity_item::spawn_egg_on_spawner(
stack,
self.acting_player_is_op(),
);
if let Some(done) = self.use_spawn_egg_on_spawner(hit.pos, &spawner_id, data.as_ref()) {
if done {
self.swing_arm();
self.award_stat("used", &stack.item, 1);
self.consume_held();
}
return Ok(true);
}
// useOn: a clicked block without a collision shape (vines, grass)
// takes the mob itself, any other the one on the clicked face.
let p = if fluid || self.collision_empty(hit.pos) {
hit.pos
} else {
[
hit.pos[0] + hit.face[0],
hit.pos[1] + hit.face[1],
hit.pos[2] + hit.face[2],
]
};
let at = [
f64::from(p[0]) + 0.5,
f64::from(p[1]),
f64::from(p[2]) + 0.5,
];
// SpawnEggItem.spawnMob: `EntityType.spawn`.
let Some(id) = self.spawn_mob_spawned(&kind, at, SpawnYaw::Created) else {
return Ok(false);
};
// SpawnEggItem.spawnMob: `EntityType.create` finalizes the mob, then
// `PostSpawnProcessor` puts the egg's components on it.
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::entity_nbt::apply_item_components(m, stack);
}
self.apply_spawn_egg_entity_data(id, stack);
self.post_block_game_event("minecraft:entity_place", p, Some(Who::Player), None);
self.award_stat("used", &stack.item, 1);
self.swing_arm();
if !self.creative
&& let Some(s) = self.held_mut().as_mut()
{
s.count -= 1;
if s.count == 0 {
*self.held_mut() = None;
}
}
Ok(true)
}
/// `EntityType.updateCustomEntityTag`: the egg's `minecraft:entity_data` on
/// the mob it spawned, once the guards let it through, as
/// `TypedEntityData.loadInto`: the mob's own compound, the data merged over
/// it, loaded back into the same mob (`/data merge entity`'s loader).
pub(in crate::game) fn apply_spawn_egg_entity_data(&mut self, id: u32, stack: &ItemStack) {
let Some(i) = self.mobs.iter().position(|m| m.id == id) else {
return;
};
let kind = self.mobs[i].kind.id.clone();
let op = self.acting_player_is_op();
let Some(data) = horizon_client::block_entity_item::spawn_egg_entity_data(stack, &kind, op)
else {
return;
};
let own = self.mob_tree_nbt(i);
let tag = horizon_client::data_cmd::merged(&own, data);
// Nothing to report: the mob's own compound always loads back.
let _ = self.apply_entity_nbt(super::super::commands::Target::Mob(id), tag);
}
}
+36 -5
View File
@@ -471,15 +471,46 @@ impl Game<'_> {
continue;
}
self.cushions[i].check_interval = 0;
let c = self.cushions[i].clone();
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
self.break_cushion(i, false);
continue;
if !self.check_cushion(i) {
i += 1;
}
i += 1;
}
}
/// `Cushion.tickAtCheckInterval` plus the survive check; true when the cushion broke.
fn check_cushion(&mut self, i: usize) -> bool {
let c = self.cushions[i].clone();
let p = c.position;
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
// collidedWithFluid: the fluid box of the cushion's own block against its box.
if let Some((height, _)) =
self.fluid_height_of(x, y, z, Some(horizon_client::blocks::Fluid::Lava))
{
let fluid = Aabb::new(
Vec3::new(f64::from(x), f64::from(y), f64::from(z)),
Vec3::new(
f64::from(x) + 1.0,
f64::from(y) + f64::from(height),
f64::from(z) + 1.0,
),
);
if aabb(c.bounds())
.deflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7))
.intersects(fluid)
{
// LAVA_IGNITE's lavaHurt deals 4 damage, which breaks the cushion.
self.break_cushion(i, false);
self.lava_hurt_sound([p.x, p.y, p.z]);
return true;
}
}
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
self.break_cushion(i, false);
return true;
}
false
}
/// `LivingEntity.stopRiding` off a cushion: `dismountVehicle` beside it,
/// and `Cushion.removePassenger`'s get-up sound for a rider that left
/// rather than one whose cushion was killed.
+7 -7
View File
@@ -1,8 +1,8 @@
//! OptiFine custom skies (`horizon_client::custom_sky`): each frame, the layers of the world's sky
//! whose day, weather, biome and height hold at the camera, handed to the renderer, which draws
//! them over the vanilla sky. A pack without `optifine/sky` hands it none.
//! Custom skies (`horizon_client::custom_sky`, OptiFine's format): each frame, the layers of the
//! world's sky whose day, weather, biome and height hold at the camera, handed to the renderer,
//! which draws them over the vanilla sky. A pack without layers hands it none.
use super::*;
use horizon_optifine::sky::{Conditions, Now};
use horizon_client::custom_sky::{Conditions, Weather};
/// The layers' eased brightness between frames, and when the last frame was.
#[derive(Default)]
@@ -46,11 +46,11 @@ impl Game<'_> {
let mut state = std::mem::take(&mut self.custom_sky.state);
// Level.isThundering / isRaining.
let weather = if thunder > 0.9 && rain > 0.2 {
Now::Thunder
Weather::Thunder
} else if rain > 0.2 {
Now::Rain
Weather::Rain
} else {
Now::Clear
Weather::Clear
};
let conditions = Conditions {
day_time: self.view_day_time(),
+1 -1
View File
@@ -2671,7 +2671,7 @@ mod tests {
#[test]
fn a_temporarily_banned_player_is_refused_with_the_end_until_it_has_passed() {
use super::{BanEntry, BanList, Lists, NameAndId};
use horizon_chat_filter::now_millis;
use horizon_server_files::duration::now_millis;
let dir = std::env::temp_dir().join(format!("horizon-can-login-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let mut lists = Lists {
+14 -9
View File
@@ -1,23 +1,23 @@
//! Play with friends, host side: "Start Sharing" on the Play with Friends
//! screen (`friends_screen`, the pause menu's Friends button) opens the world to LAN if
//! screen (`screen`, the pause menu's Friends button) opens the world to LAN if
//! it isn't (with Open to LAN's defaults) and shares that port behind an
//! invite code (horizon-friends). The code holds this computer's addresses
//! and the key, so friends join with no server of ours; a relay the player
//! configured adds a short code and a fallback. The integrated server is the
//! LAN one, unchanged: tunnelled guests are LAN guests that connect from the
//! loopback address.
//! loopback address. The joining side is horizon-friends' (`game`), registered by
//! horizon-official.
use super::{Game, lan::LanSettings};
use horizon_client::{
Result,
friends_screen::{self, Action, FriendsView},
hud::HudBatch,
lang,
text::Text,
};
use horizon_client::{Result, hud::HudBatch, lang, text::Text};
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
use horizon_protocol::{GAME_VERSION, PROTOCOL_VERSION};
use std::time::{Duration, Instant};
mod screen;
use screen::{self as friends_screen, Action, FriendsView};
pub use screen::{TITLE, title};
pub(super) struct Sharing {
host: Host,
/// Sharing opened the world to LAN, so stopping closes it again.
@@ -240,6 +240,11 @@ impl Game<'_> {
}
}
/// Whether the pause menu offers Play with Friends (a singleplayer world).
pub(super) fn friends_shown(&self) -> bool {
self.friends_view().shown
}
/// What Play with Friends shows.
pub(super) fn friends_view(&self) -> FriendsView {
let guests = match &self.lan {
@@ -1,9 +1,9 @@
//! Play with Friends: Horizon's own sharing of the open world behind an
//! invite code (`game/friends.rs`). The pause menu's Friends button, the icon
//! invite code (`game/friends.rs`), with the rest of Play with Friends' game glue. The pause menu's Friends button, the icon
//! vanilla's PauseScreen already has, opens it. It is drawn the way a mod adds
//! a screen to vanilla: the menu background, a centred title and text,
//! vanilla's buttons and Done in the footer.
use crate::{
use horizon_client::{
hud::{HudAtlas, HudBatch, rgba},
multiplayer::wrap,
text::Text,
@@ -14,7 +14,7 @@ use crate::{
pub const TITLE: &str = "Play with Friends";
/// A string of this screen: the language table's, or the English text.
fn tr(key: &str, english: &str) -> String {
crate::lang::get_or(key, english)
horizon_client::lang::get_or(key, english)
}
/// The title in the current language.
pub fn title() -> String {
@@ -245,8 +245,8 @@ pub fn draw(
for b in buttons {
let hover = b.active && ui::hovered(cursor, b.x, b.y, b.w, 20.0);
if matches!(b.action, Action::Done) {
crate::widgets::declare_action(
crate::widgets::Action::Done,
horizon_client::widgets::declare_action(
horizon_client::widgets::Action::Done,
[b.x, b.y, b.w, 20.0],
b.active,
);
+10 -4
View File
@@ -1,11 +1,17 @@
//! Play with Friends left out of the build (the `friends` feature off, as in the server build):
//! no world is shared, the pause menu has no Friends button (`FriendsView::shown` is false, as on
//! no world is shared, the pause menu has no Friends button (`friends_shown` is false, as on
//! another server) and `horizon-ctl friends` says it isn't there. game/friends.rs is the real one.
use super::{Game, Screen};
use horizon_client::{Result, friends_screen::FriendsView, hud::HudBatch};
use horizon_client::{Result, hud::HudBatch};
pub const HELP: &[&str] = &[];
/// The pause menu's Friends icon is vanilla's, drawn inactive: its name for the widget tree.
pub const TITLE: &str = "Play with Friends";
pub fn title() -> String {
horizon_client::lang::get_or("horizon.friends.title", TITLE)
}
impl Game<'_> {
pub(super) fn friends_lan_closed(&mut self) {}
@@ -18,8 +24,8 @@ impl Game<'_> {
pub(super) fn friends_screen_click(&mut self) {}
pub(super) fn friends_view(&self) -> FriendsView {
FriendsView::default()
pub(super) fn friends_shown(&self) -> bool {
false
}
/// `horizon-ctl friends ...`.
+1 -1
View File
@@ -1820,7 +1820,7 @@ impl Game<'_> {
// from carries those, as `save_guest` gives them on leaving. The map
// stays their one owner.
let live = self.guest_attachments.get(&uuid).cloned();
let g = &mut host.hub.guests[i];
let g = playerdata::with_warden(&mut host.hub.guests[i], self.warden_tracker_tag());
let held = live.map(|a| std::mem::replace(&mut g.attachments, a));
playerdata::store(g, &mut self.level, vehicle);
if let Some(held) = held {
@@ -152,6 +152,14 @@ impl WorldOptions {
}
self.port.value.parse::<u16>().ok().filter(|p| *p >= 1024)
}
/// `portEdit.setTooltip(PORT_INFO_TEXT)`: the port box explains itself while
/// the cursor is over it. The box is drawn even with LAN off, so the tooltip
/// shows there too; a remote world has no such box.
fn port_tooltip(port: &EditBox, cursor: (f32, f32), remote: bool) -> Option<Text> {
(!remote && ui::hovered(cursor, port.x, port.y, port.width, port.height))
.then(|| Text::translate("lanServer.port", &[]))
}
fn snapshot(&self) -> Snapshot {
Snapshot {
default_mode: self.default_mode,
@@ -1003,6 +1011,10 @@ impl Game<'_> {
hover,
);
}
// The widgets are drawn; the port box's tooltip goes over them.
if let Some(tip) = WorldOptions::port_tooltip(&o.port, cursor, o.remote) {
ui::tooltip(batch, atlas, &[tip], cursor);
}
}
/// The mouse wheel over the screen (ScrollableLayout, 10 pixels a step).
@@ -1899,4 +1911,15 @@ mod tests {
assert!(o.locked);
assert!(!o.active(Button::Difficulty));
}
/// `portEdit.setTooltip(PORT_INFO_TEXT)`: hovering the port box shows its
/// label; a remote world (`o.remote`) has no port box to hover.
#[test]
fn the_port_box_explains_itself_on_hover() {
let port = EditBox::new(158.0, 100.0, 40.0, 20.0);
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), false).is_some());
assert!(WorldOptions::port_tooltip(&port, (157.0, 101.0), false).is_none());
assert!(WorldOptions::port_tooltip(&port, (159.0, 121.0), false).is_none());
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), true).is_none());
}
}
@@ -91,7 +91,7 @@ pub(super) fn build_renderer(
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
renderer.set_lines_shader(atlas.lines_shader.as_deref());
renderer.set_sky_shaders(atlas.sky_shader.as_deref(), atlas.stars_shader.as_deref());
renderer.set_custom_sky(&pack.custom_sky);
renderer.set_custom_sky(&*pack.custom_sky);
renderer.set_shared_material_maps(&atlas.material_maps);
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
renderer
@@ -899,4 +899,32 @@ impl Game<'_> {
f.input_clicked(world_recipes!(self), before);
}
}
/// `AbstractContainerScreen` keeps `isQuickCrafting` and the drag it
/// belongs to on the screen. Horizon keeps both on `Game` (`inv_clicks`,
/// `inv_drag`, `last_inv_click`), so they are dropped when the pointer
/// changes hands: a screen that opens or closes starts with its own
/// state, and a press whose release never reached the screen -- the
/// pointer left the window, or the screen closed first -- would
/// otherwise leave every later press on every later screen swallowed.
pub(super) fn reset_inventory_clicks(&mut self) {
self.inv_clicks.cancel_drag();
self.inv_drag = None;
self.last_inv_click = None;
}
/// A sound a menu click made the player play (a bundle's insert or
/// remove): `Entity.playSound` at the player.
pub(super) fn player_menu_sound(&mut self, sound: &str) {
let Some(p) = self.player.as_ref() else {
return;
};
let at = [p.position.x, p.position.y, p.position.z];
let (volume, pitch) = if sound == horizon_client::bundle::INSERT_FAIL {
(1.0, 1.0)
} else {
(0.8, 0.8 + self.world.random.next_float() * 0.4)
};
self.audio.play(sound, Some(at), volume, pitch);
}
}
+4 -267
View File
@@ -28,6 +28,10 @@ use horizon_core::{
};
use horizon_world::registry::BlockStateId;
use std::{collections::HashMap, sync::Arc};
mod index;
mod nbt;
pub(super) use index::CartIndex;
use index::cart_at;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub(super) enum CartKind {
@@ -187,102 +191,6 @@ pub(super) const TNT_PRIME_EVENT: i8 = 70;
mod display;
use display::{cart_render_bounds, display_offset, shown_unless_air};
pub(super) use display::{default_display_offset, forget_unreadable_display};
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
/// every mob of the level. Both answer in slot order, as the scans did.
///
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
/// rebuilt once the list's length changed (a cart gone or added), and dropped
/// after a command, which can move or replace any cart
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
/// unseen, so with mods loaded both scans stay.
#[derive(Default)]
pub(super) struct CartIndex {
carts: Option<(SeenGrid, Vec<u32>)>,
/// The grid, and the carts a mob rides.
mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
near: Vec<u32>,
}
impl CartIndex {
fn usable() -> bool {
horizon_client::mod_api::mods().is_empty()
}
/// The cart in slot `i` has these bounds now.
fn moved(&mut self, i: usize, bounds: Aabb) {
if let Some((grid, ids)) = &mut self.carts
&& i < ids.len()
{
grid.update(i, bounds);
}
}
fn forget_carts(&mut self) {
self.carts = None;
}
/// The slots of the carts whose box may meet `area`, for the cart in slot
/// `i` (just moved); false: scan them all.
fn carts_near(&mut self, carts: &[Minecart], i: usize, area: Aabb, out: &mut Vec<u32>) -> bool {
if !Self::usable() {
return false;
}
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
self.carts = Some((
SeenGrid::new(carts.iter().map(Minecart::bounds)),
carts.iter().map(|c| c.id).collect(),
));
}
let Some((grid, ids)) = &mut self.carts else {
return false;
};
grid.update(i, carts[i].bounds());
if !grid.candidates(area, out) {
return false;
}
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
// A cart was replaced in place: start over next time.
self.carts = None;
return false;
}
true
}
/// The slots of the mobs whose box may meet `area` into `out`, and
/// whether a mob rides cart `cart`; None: scan them all.
fn mobs_near(
&mut self,
mobs: &JoinList<Mob>,
cart: u32,
area: Aabb,
out: &mut Vec<u32>,
) -> Option<bool> {
if !Self::usable() {
return None;
}
if !self
.mobs
.as_ref()
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
{
self.mobs = Some((
mobs.stamp(),
SeenGrid::new(mobs.iter().map(Mob::bounds)),
mobs.iter().filter_map(|m| m.extra.mount).collect(),
));
}
let (_, grid, mounted) = self.mobs.as_ref()?;
grid.candidates(area, out).then(|| mounted.contains(&cart))
}
}
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
/// the usual case while carts tick in order) or found by a scan. A world
/// with hundreds of mineshaft chest carts asked this of every cart several
/// times a tick.
fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
if carts.get(hint).is_some_and(|c| c.id == id) {
return Some(hint);
}
carts.iter().position(|c| c.id == id)
}
/// `AbstractMinecart.push(entity)` for a non-cart `entity` at `feet`:
/// the push the cart loses (the entity gets a quarter of it back).
pub(super) fn cart_push_from(feet: Vec3, cart: Vec3) -> Option<(f64, f64)> {
@@ -2984,177 +2892,6 @@ impl Game<'_> {
.render_state(&scene, state.0, m, light, out);
}
}
/// Saving and loading.
impl Minecart {
pub fn to_nbt(
&self,
uuid: [i32; 4],
raw: Option<&horizon_world::nbt::Tag>,
) -> horizon_world::nbt::Tag {
use horizon_world::nbt::{JavaString, Tag};
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
self.kind.id(),
self.position,
self.velocity,
[self.yaw, self.pitch],
(self.on_ground, self.fall_distance),
uuid,
raw,
);
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
if let Tag::Compound(map) = &mut tag {
// ContainerEntity.addChestVehicleSaveData belongs only to the
// container subclasses, never to a plain or specialized cart.
if self.kind.slots() == 0 {
map.remove(&JavaString::from("Items"));
}
if self.kind == CartKind::CommandBlock {
for key in ["LastOutput", "LastExecution"] {
map.remove(&JavaString::from(key));
}
}
map.remove(&JavaString::from("DisplayOffset"));
map.remove(&JavaString::from("DisplayState"));
let mut put = |k: &str, v: Tag| {
map.insert(JavaString::from(k), v);
};
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
put("Fire", Tag::Short(0));
// addAdditionalSaveData: the offset only off its default, the state only when set.
if let Some(offset) = self.custom_offset
&& f64::from(offset) != default_display_offset(self.kind)
{
put("DisplayOffset", Tag::Int(offset));
}
if let Some(state) = &self.custom_display {
put("DisplayState", state.clone());
}
match self.kind {
CartKind::Chest | CartKind::Hopper => {
put("Items", horizon_client::save::encode_items(&self.items));
if self.kind == CartKind::Hopper {
put("Enabled", Tag::Byte(i8::from(self.enabled)));
}
}
CartKind::Furnace => {
put("PushX", Tag::Double(self.push.x.to_bits()));
put("PushZ", Tag::Double(self.push.z.to_bits()));
put("Fuel", Tag::Short(self.fuel as i16));
}
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
// BaseCommandBlock.save (its CustomName shares the entity's key).
CartKind::CommandBlock => {
for (key, value) in self.command.save() {
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
put(key, value);
}
}
}
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
CartKind::Spawner => {
if let Some(spawner) = &self.spawner {
for (key, value) in
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
{
put(key, value);
}
}
}
CartKind::Rideable => {}
}
// A loaded loot table goes back while the items are still its roll;
// otherwise the items replace it, as vanilla's unpackLootTable does.
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
{
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
}
} else {
map.remove(&JavaString::from("LootTable"));
map.remove(&JavaString::from("LootTableSeed"));
}
}
tag
}
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let mut c = Self::new(id, kind, position);
c.load(tag)?;
Some(c)
}
/// `Entity.load` on this cart: the saved fields are read over the live
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
/// `None`, with nothing changed, when the compound has no position.
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
use horizon_world::nbt::Tag;
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let kind = self.kind;
let c = self;
c.position = position;
c.previous = position;
c.velocity = velocity;
c.yaw = yaw;
c.previous_yaw = yaw;
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
c.previous_pitch = c.pitch;
// ValueInput's numeric getters take any NumericTag: getIntOr is its
// intValue, getShortOr the low 16 bits of it, getBooleanOr
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
let int = |k: &str| {
tag.get(k)
.and_then(horizon_client_core::nbt_path::int_value)
};
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
let double = |k: &str| match tag.get(k) {
Some(Tag::Byte(v)) => f64::from(*v),
Some(Tag::Short(v)) => f64::from(*v),
Some(Tag::Int(v)) => f64::from(*v),
Some(Tag::Long(v)) => *v as f64,
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
Some(Tag::Double(v)) => f64::from_bits(*v),
_ => 0.0,
};
// Entity.load: OnGround and fall_distance, as saved.
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
c.flipped = flag("FlippedRotation", false);
c.first_tick = flag("HasTicked", false);
if kind.slots() > 0 {
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
}
c.enabled = flag("Enabled", true);
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
// readAdditionalSaveData: the saved push has no y.
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
c.fuse = int("fuse").unwrap_or(-1);
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
c.custom_display = tag.get("DisplayState").cloned();
c.custom_offset = int("DisplayOffset");
if kind == CartKind::CommandBlock {
c.command = horizon_client::command_block::CommandBlock::load(tag);
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
if c.synced_command != c.command.command {
c.synced_command.clone_from(&c.command.command);
c.command.set_command(&c.synced_command);
}
c.synced_output = c.command.last_output_string();
}
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
if kind == CartKind::Spawner {
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
tag,
)));
}
Some(())
}
}
/// `VehicleEntity.hurtServer`'s destroy test: a creative player, damage past
/// 40, or (`MinecartTNT.shouldSourceDestroy`) fire or an explosion hitting a
/// TNT cart.
@@ -0,0 +1,105 @@
//! The carts' and mobs' boxes for a tick's cart pushes ([`CartIndex`]).
use super::*;
/// The carts' and mobs' boxes for one tick's cart pushes (`push_from_cart`,
/// `pickup_from_cart`), so a cart reads its neighbours instead of every cart and
/// every mob of the level. Both answer in slot order, as the scans did.
///
/// The cart grid follows each ticked cart ([`CartIndex::moved`]); it is
/// rebuilt once the list's length changed (a cart gone or added), and dropped
/// after a command, which can move or replace any cart
/// ([`CartIndex::forget_carts`]). The mob grid holds until the mob
/// list's next write ([`ListStamp`]). A native mod's hook can move either kind
/// unseen, so with mods loaded both scans stay.
#[derive(Default)]
pub(in crate::game) struct CartIndex {
pub(super) carts: Option<(SeenGrid, Vec<u32>)>,
/// The grid, and the carts a mob rides.
pub(super) mobs: Option<(ListStamp, SeenGrid, FxHashSet<u32>)>,
pub(super) near: Vec<u32>,
}
impl CartIndex {
pub(super) fn usable() -> bool {
horizon_client::mod_api::mods().is_empty()
}
/// The cart in slot `i` has these bounds now.
pub(super) fn moved(&mut self, i: usize, bounds: Aabb) {
if let Some((grid, ids)) = &mut self.carts
&& i < ids.len()
{
grid.update(i, bounds);
}
}
pub(super) fn forget_carts(&mut self) {
self.carts = None;
}
/// The slots of the carts whose box may meet `area`, for the cart in slot
/// `i` (just moved); false: scan them all.
pub(super) fn carts_near(
&mut self,
carts: &[Minecart],
i: usize,
area: Aabb,
out: &mut Vec<u32>,
) -> bool {
if !Self::usable() {
return false;
}
let fresh = |ids: &[u32]| ids.len() == carts.len() && ids[i] == carts[i].id;
if !self.carts.as_ref().is_some_and(|(_, ids)| fresh(ids)) {
self.carts = Some((
SeenGrid::new(carts.iter().map(Minecart::bounds)),
carts.iter().map(|c| c.id).collect(),
));
}
let Some((grid, ids)) = &mut self.carts else {
return false;
};
grid.update(i, carts[i].bounds());
if !grid.candidates(area, out) {
return false;
}
if out.iter().any(|&j| ids[j as usize] != carts[j as usize].id) {
// A cart was replaced in place: start over next time.
self.carts = None;
return false;
}
true
}
/// The slots of the mobs whose box may meet `area` into `out`, and
/// whether a mob rides cart `cart`; None: scan them all.
pub(super) fn mobs_near(
&mut self,
mobs: &JoinList<Mob>,
cart: u32,
area: Aabb,
out: &mut Vec<u32>,
) -> Option<bool> {
if !Self::usable() {
return None;
}
if !self
.mobs
.as_ref()
.is_some_and(|(stamp, ..)| *stamp == mobs.stamp())
{
self.mobs = Some((
mobs.stamp(),
SeenGrid::new(mobs.iter().map(Mob::bounds)),
mobs.iter().filter_map(|m| m.extra.mount).collect(),
));
}
let (_, grid, mounted) = self.mobs.as_ref()?;
grid.candidates(area, out).then(|| mounted.contains(&cart))
}
}
/// Where the cart `id` is in `carts`: at `hint` (where it was a moment ago,
/// the usual case while carts tick in order) or found by a scan. A world
/// with hundreds of mineshaft chest carts asked this of every cart several
/// times a tick.
pub(super) fn cart_at(carts: &[Minecart], id: u32, hint: usize) -> Option<usize> {
if carts.get(hint).is_some_and(|c| c.id == id) {
return Some(hint);
}
carts.iter().position(|c| c.id == id)
}
@@ -0,0 +1,173 @@
//! Saving and loading a minecart.
use super::*;
/// Saving and loading.
impl Minecart {
pub fn to_nbt(
&self,
uuid: [i32; 4],
raw: Option<&horizon_world::nbt::Tag>,
) -> horizon_world::nbt::Tag {
use horizon_world::nbt::{JavaString, Tag};
let mut tag = horizon_client::entity_nbt::vehicle_to_nbt(
self.kind.id(),
self.position,
self.velocity,
[self.yaw, self.pitch],
(self.on_ground, self.fall_distance),
uuid,
raw,
);
horizon_client::entity_nbt::write_saved_flags(&mut tag, self.on_ground, self.no_gravity);
if let Tag::Compound(map) = &mut tag {
// ContainerEntity.addChestVehicleSaveData belongs only to the
// container subclasses, never to a plain or specialized cart.
if self.kind.slots() == 0 {
map.remove(&JavaString::from("Items"));
}
if self.kind == CartKind::CommandBlock {
for key in ["LastOutput", "LastExecution"] {
map.remove(&JavaString::from(key));
}
}
map.remove(&JavaString::from("DisplayOffset"));
map.remove(&JavaString::from("DisplayState"));
let mut put = |k: &str, v: Tag| {
map.insert(JavaString::from(k), v);
};
put("FlippedRotation", Tag::Byte(i8::from(self.flipped)));
// AbstractMinecart saves firstTick as HasTicked. A cart never burns:
// Entity.move sets -getFireImmuneTicks(), 0 for all but players.
put("HasTicked", Tag::Byte(i8::from(self.first_tick)));
put("Fire", Tag::Short(0));
// addAdditionalSaveData: the offset only off its default, the state only when set.
if let Some(offset) = self.custom_offset
&& f64::from(offset) != default_display_offset(self.kind)
{
put("DisplayOffset", Tag::Int(offset));
}
if let Some(state) = &self.custom_display {
put("DisplayState", state.clone());
}
match self.kind {
CartKind::Chest | CartKind::Hopper => {
put("Items", horizon_client::save::encode_items(&self.items));
if self.kind == CartKind::Hopper {
put("Enabled", Tag::Byte(i8::from(self.enabled)));
}
}
CartKind::Furnace => {
put("PushX", Tag::Double(self.push.x.to_bits()));
put("PushZ", Tag::Double(self.push.z.to_bits()));
put("Fuel", Tag::Short(self.fuel as i16));
}
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
// BaseCommandBlock.save (its CustomName shares the entity's key).
CartKind::CommandBlock => {
for (key, value) in self.command.save() {
if !matches!(key, "CustomName" | "powered" | "conditionMet" | "auto") {
put(key, value);
}
}
}
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
CartKind::Spawner => {
if let Some(spawner) = &self.spawner {
for (key, value) in
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
{
put(key, value);
}
}
}
CartKind::Rideable => {}
}
// A loaded loot table goes back while the items are still its roll;
// otherwise the items replace it, as vanilla's unpackLootTable does.
if self.loot.as_ref().is_some_and(|l| l.restore(map)) {
if let Some(table) = horizon_client::save::text(map.get(&"LootTable".into()))
.and_then(|id| horizon_client_core::identifier::normalize_id(&id))
{
map.insert("LootTable".into(), Tag::String(table.as_str().into()));
}
} else {
map.remove(&JavaString::from("LootTable"));
map.remove(&JavaString::from("LootTableSeed"));
}
}
tag
}
pub fn from_nbt(tag: &horizon_world::nbt::Tag, id: u32, kind: CartKind) -> Option<Self> {
let (position, _, _) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let mut c = Self::new(id, kind, position);
c.load(tag)?;
Some(c)
}
/// `Entity.load` on this cart: the saved fields are read over the live
/// ones, the rest (riders, lerp, hurt state) stays; its kind never changes.
/// `None`, with nothing changed, when the compound has no position.
pub fn load(&mut self, tag: &horizon_world::nbt::Tag) -> Option<()> {
use horizon_world::nbt::Tag;
let (position, velocity, yaw) = horizon_client::entity_nbt::boat_from_nbt(tag)?;
let kind = self.kind;
let c = self;
c.position = position;
c.previous = position;
c.velocity = velocity;
c.yaw = yaw;
c.previous_yaw = yaw;
c.no_gravity = horizon_client::entity_nbt::flag(tag, "NoGravity");
c.on_ground = horizon_client::entity_nbt::flag(tag, "OnGround");
c.pitch = horizon_client::entity_nbt::rotation(tag)[1];
c.previous_pitch = c.pitch;
// ValueInput's numeric getters take any NumericTag: getIntOr is its
// intValue, getShortOr the low 16 bits of it, getBooleanOr
// `byteValue() != 0` (the low 8), getDoubleOr its doubleValue.
let int = |k: &str| {
tag.get(k)
.and_then(horizon_client_core::nbt_path::int_value)
};
let flag = |k: &str, default: bool| int(k).map_or(default, |v| v as i8 != 0);
let double = |k: &str| match tag.get(k) {
Some(Tag::Byte(v)) => f64::from(*v),
Some(Tag::Short(v)) => f64::from(*v),
Some(Tag::Int(v)) => f64::from(*v),
Some(Tag::Long(v)) => *v as f64,
Some(Tag::Float(v)) => f64::from(f32::from_bits(*v)),
Some(Tag::Double(v)) => f64::from_bits(*v),
_ => 0.0,
};
// Entity.load: OnGround and fall_distance, as saved.
(c.on_ground, c.fall_distance) = horizon_client::entity_nbt::ground_from_nbt(tag);
c.flipped = flag("FlippedRotation", false);
c.first_tick = flag("HasTicked", false);
if kind.slots() > 0 {
c.items = horizon_client::save::decode_items(tag.get("Items"), kind.slots());
}
c.enabled = flag("Enabled", true);
c.fuel = int("Fuel").map_or(0, |v| i32::from(v as i16));
// readAdditionalSaveData: the saved push has no y.
c.push = Vec3::new(double("PushX"), 0.0, double("PushZ"));
c.fuse = int("fuse").unwrap_or(-1);
// readAdditionalSaveData: DisplayState, DisplayOffset (default offset when absent).
c.custom_display = tag.get("DisplayState").cloned();
c.custom_offset = int("DisplayOffset");
if kind == CartKind::CommandBlock {
c.command = horizon_client::command_block::CommandBlock::load(tag);
// MinecartCommandBlock.readAdditionalSaveData sets the synched command.
// A changed value invokes onSyncedDataUpdated -> setCommand, resetting successes.
if c.synced_command != c.command.command {
c.synced_command.clone_from(&c.command.command);
c.command.set_command(&c.synced_command);
}
c.synced_output = c.command.last_output_string();
}
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
if kind == CartKind::Spawner {
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
tag,
)));
}
Some(())
}
}
+6 -10
View File
@@ -157,7 +157,7 @@ impl Game<'_> {
/// The numbers `Mob.compareArmor` and `compareWeapons` read off `stack`
/// in `slot` for the mob `m`.
pub(super) fn stack_facts(&self, m: &Mob, stack: &ItemStack, slot: Slot) -> Facts {
let (armor, toughness, attack) = self.attributes(m, &stack.item, slot);
let (armor, toughness, attack) = self.attributes(m, stack, slot);
Facts {
armor,
armor_toughness: toughness,
@@ -173,22 +173,18 @@ impl Game<'_> {
/// `Mob.getApproximateAttributeWith` for the item `id` in `slot`: each
/// attribute starts from the mob's own value. (armour, toughness, damage)
fn attributes(&self, m: &Mob, id: &str, slot: Slot) -> (f64, f64, f64) {
let empty: &[(String, String, f64, String, String)] = &[];
let modifiers = self
.items()
.get(id)
.map_or(empty, |i| i.modifiers.as_slice());
fn attributes(&self, m: &Mob, stack: &ItemStack, slot: Slot) -> (f64, f64, f64) {
let modifiers = self.items().attribute_modifiers(stack);
(
equip::attribute_with(modifiers, "minecraft:armor", f64::from(m.kind.armor), slot),
equip::attribute_with(&modifiers, "minecraft:armor", f64::from(m.kind.armor), slot),
equip::attribute_with(
modifiers,
&modifiers,
"minecraft:armor_toughness",
f64::from(m.kind.armor_toughness),
slot,
),
equip::attribute_with(
modifiers,
&modifiers,
"minecraft:attack_damage",
f64::from(m.attrs.attack_damage),
slot,
@@ -25,6 +25,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
self.particles.tick(&level);
}
@@ -10,11 +10,7 @@ use horizon_client::{
net::FRIEND,
};
use horizon_core::geometry::Vec3;
#[cfg(feature = "friends")]
use horizon_friends::invite::{self, Parsed};
use std::borrow::Cow;
#[cfg(feature = "friends")]
use std::net::SocketAddr;
use winit::keyboard::KeyCode;
/// HUD and first-person hand visibility. Other hand-render restrictions still apply.
@@ -109,28 +105,11 @@ impl NativeWorldId {
}
}
/// The part of a friend's invite anyone may see. A host's code is fresh each
/// time it shares, so a friend's world is known for that sharing only. The
/// relay is kept beside the addresses: two hosts on other networks can share
/// a private address and the fixed direct port.
#[cfg(feature = "friends")]
fn invite_public_part(code: &str) -> String {
match invite::parse(code) {
Some(Parsed::Short(code)) => code.nameplate,
Some(Parsed::Long(invite)) => {
let relay = invite
.relay
.map(|r| format!("{}/{}", r.address, r.code.nameplate));
let endpoints = invite.endpoints.iter().map(SocketAddr::to_string);
endpoints.chain(relay).collect::<Vec<_>>().join(",")
}
None => String::new(),
}
}
/// The part of a friend's invite anyone may see (`horizon_hooks::tunnel::Resolver::public_part`).
/// A host's code is fresh each time it shares, so a friend's world is known for that sharing only.
/// Without Play with Friends no invite is read: a `friend:` world shows no part of it.
#[cfg(not(feature = "friends"))]
fn invite_public_part(_code: &str) -> String {
String::new()
fn invite_public_part(code: &str) -> String {
horizon_hooks::engine::tunnel().map_or_else(String::new, |r| r.public_part(code))
}
/// A living entity known to the client, before interpolation. No authoritative writes.
@@ -558,8 +537,6 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "friends")]
use horizon_friends::code::Code;
#[test]
fn a_world_is_its_save_in_single_player_and_its_address_on_a_server() {
@@ -573,52 +550,6 @@ mod tests {
);
}
#[test]
#[cfg(feature = "friends")]
fn friends_worlds_are_told_apart_by_their_hosts_never_by_their_secrets() {
const PORT: u16 = 50000;
const RELAY: &str = "relay.example.com:443";
// A short code: its nameplate, then its secret.
let short = |nameplate: &str, secret: &str| Code {
nameplate: nameplate.into(),
secret: secret.into(),
};
let invite = |secret: u8, host: &str, relay: Option<Code>| {
let code = invite::Invite {
secret: [secret; invite::SECRET],
port: PORT,
endpoints: vec![format!("{host}:{PORT}").parse().unwrap()],
relay: relay.map(|code| invite::RelayRef {
address: RELAY.into(),
code,
}),
}
.encode();
NativeWorldId::of(None, &format!("{FRIEND}{code}"))
};
assert_ne!(
invite(1, "192.168.1.5", None),
invite(1, "192.168.1.6", None),
"two friends"
);
assert_ne!(
invite(1, "192.168.1.5", Some(short("ABCD", "WXYZ"))),
invite(1, "192.168.1.5", Some(short("EFGH", "WXYZ"))),
"two friends on other networks behind the same private address"
);
assert_eq!(
invite(1, "192.168.1.5", Some(short("ABCD", "WXYZ"))),
invite(2, "192.168.1.5", Some(short("ABCD", "QRST"))),
"the invite's and the relay code's secrets take no part"
);
let typed = |code: Code| NativeWorldId::of(None, &format!("{FRIEND}{code}"));
assert_eq!(typed(short("ABCD", "WXYZ")), typed(short("ABCD", "QRST")));
assert_eq!(
typed(short("ABCD", "WXYZ")),
NativeWorldId::Server(format!("{FRIEND}ABCD"))
);
}
#[test]
fn a_hud_tells_chat_and_death_from_other_screens() {
assert_eq!(
+9 -1
View File
@@ -102,8 +102,16 @@ impl Game<'_> {
if food && m.health < m.attrs.max_health {
// feed(player, hand, stack, 2, 1).
let sound = nautilus::eat_sound(m);
// TamableAnimal.feed: heals 2 x the food's nutrition, 1 for an
// item with no food component.
let nutrition = self
.res
.items()
.get(item)
.and_then(|i| i.food.as_ref())
.map(|f| f.0 as i32);
let m = &mut self.mobs[index];
m.heal(2.0);
m.heal(nautilus::feed_heal(nutrition));
self.use_nautilus_food(item);
self.nautilus_sound(index, sound);
return true;
+102 -23
View File
@@ -226,6 +226,25 @@ impl Game<'_> {
});
}
/// `Entity.collide`'s `getEntityCollisions` for a body a piston or a
/// shulker lid pushes: the entities it can collide with
/// (`canCollideWith`, which for every mover but a vehicle is
/// `canBeCollidedWith`): a boat, a living shulker, a happy ghast held
/// still. A pushed boat or minecart collides by `canVehicleCollide`
/// instead, which this doesn't cover, and the player moves itself.
fn pushed_entity_colliders(&self, target: Target) -> Vec<Aabb> {
match target {
Target::Entity(EntityHandle::Boat(_) | EntityHandle::Cart(_)) => Vec::new(),
Target::Entity(handle) => {
let own = with_entity!(self, handle, entity => Entity::bounds(entity));
let mut boxes = self.item_entity_collisions();
boxes.retain(|b| *b != own);
boxes
}
_ => Vec::new(),
}
}
/// The collision view of the moving piston at `pos` takes the progress
/// its tick gave it. One no longer moving keeps its entry until
/// `sync_moving_pistons`.
@@ -284,6 +303,7 @@ impl Game<'_> {
self.shulker_push_player(movement);
return;
}
let entities = self.pushed_entity_colliders(target);
let Ok(moved) = pushed_movement(
SceneCollisions {
world: &self.world,
@@ -298,6 +318,7 @@ impl Game<'_> {
body.step_height,
body.on_ground,
None,
&entities,
) else {
return;
};
@@ -665,6 +686,7 @@ impl Game<'_> {
self.projectile_piston_block_effects(i, at);
}
self.piston_block_triggers(before, before);
self.release_after_block_move(target);
return;
}
// `Entity.move`: a projectile a cobweb or powder snow holds stops dead
@@ -695,6 +717,7 @@ impl Game<'_> {
}
_ => None,
};
let entities = self.pushed_entity_colliders(target);
let resolved = with_piston_noclip(piston_direction, || {
pushed_movement(
SceneCollisions {
@@ -709,6 +732,7 @@ impl Game<'_> {
step_height,
on_ground,
tnt.as_ref().map(|t| t.3),
&entities,
)
.unwrap_or(Vec3::ZERO)
});
@@ -747,6 +771,9 @@ impl Game<'_> {
|| limited.length_squared() - resolved.length_squared() < 1.0e-7;
if moved {
self.apply_push(target, resolved);
} else {
// A move the collisions cut to nothing still ends in the check.
self.release_after_block_move(target);
}
if let Some((i, from, ..)) = tnt {
self.finish_tnt_piston_move(i, from, resolved);
@@ -864,26 +891,37 @@ impl Game<'_> {
}
Target::Entity(handle) => {
with_entity_mut!(self, handle, entity => entity.move_by_piston(movement));
let Some(bounds) =
with_entity!(self, handle, entity => entity.piston_release_bounds())
else {
return;
};
let no_block_collision = {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
no_collision_empty(&mut scene, bounds)
};
with_entity_mut!(self, handle, entity => {
entity.release_after_piston(no_block_collision)
});
self.release_after_block_move(target);
}
}
}
/// `AbstractArrow.move` with a mover other than the arrow itself (a
/// piston, a shulker lid): after `Entity.move`, whether or not it went
/// anywhere, a stuck arrow or trident with nothing to collide with within
/// 0.06 starts falling (`shouldFall`, `startFalling`). Its own tick only
/// checks once the block at its position changes, a tick later.
fn release_after_block_move(&mut self, target: Target) {
let Target::Entity(handle) = target else {
return;
};
let Some(bounds) = with_entity!(self, handle, entity => entity.piston_release_bounds())
else {
return;
};
let no_block_collision = {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
no_collision_empty(&mut scene, bounds)
};
with_entity_mut!(self, handle, entity => {
entity.release_after_piston(no_block_collision)
});
}
/// `causeBounce`: a slime block sets the velocity along the movement axis.
fn bounce(&mut self, target: Target, movement: Direction) {
match target {
@@ -939,19 +977,16 @@ fn pushed_movement<W>(
step_height: f32,
on_ground: bool,
context: Option<CollisionContext<'_>>,
entities: &[Aabb],
) -> horizon_client::Result<Vec3>
where
W: BlockCollisionWorld<Error = std::io::Error> + EntityMovementWorld<Error = std::io::Error>,
{
// Without the entity's own context, the one `resolve` reads at its feet.
let context = context.unwrap_or_else(|| CollisionContext::position(before.min_position().y));
EntityCollisions::with_border(blocks, border, at).resolve_with_context(
before,
movement,
step_height,
on_ground,
context,
)
let mut collisions = EntityCollisions::with_border(blocks, border, at);
collisions.entities = entities;
collisions.resolve_with_context(before, movement, step_height, on_ground, context)
}
#[cfg(test)]
@@ -980,6 +1015,7 @@ mod tests {
0.0,
false,
None,
&[],
)
.expect("the push resolves");
// The wall stops the body 0.4 blocks short of the whole push.
@@ -1003,6 +1039,7 @@ mod tests {
0.0,
false,
None,
&[],
)
.expect("the push resolves");
assert!(
@@ -1521,6 +1558,48 @@ mod h255_mob_piston_ground_tests {
&mut std::collections::HashMap::new(),
);
}
/// `AbstractArrow.move` after a push the collisions cut to nothing: a stuck
/// arrow with nothing within 0.06 of it starts falling all the same.
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn a_stuck_arrow_whose_push_a_wall_stops_falls_when_nothing_holds_it() {
with_game(|game| {
assert!(game.run_command("setblock 6 202 4 minecraft:stone"));
game.creatures.projectiles.clear();
assert!(game.run_command("summon minecraft:arrow 5.75 202.5 4.5 {NoGravity:0b}"));
let i = game
.creatures
.projectiles
.iter()
.position(|p| p.arrow.is_some())
.expect("the arrow");
{
let p = &mut game.creatures.projectiles[i];
p.velocity = Vec3::ZERO;
p.stuck = true;
p.arrow.as_mut().unwrap().in_ground = true;
}
let p = &game.creatures.projectiles[i];
let (bounds, at) = (p.bounds(), [p.position.x, p.position.z]);
let before = p.position;
game.move_pushed(
Target::Entity(EntityHandle::Projectile(i)),
bounds,
at,
0.0,
false,
Vec3::new(0.3, 0.0, 0.0),
Direction::East,
&mut std::collections::HashMap::new(),
);
let p = &game.creatures.projectiles[i];
assert_eq!(p.position, before, "the stone stops the push");
assert!(
!p.arrow.as_ref().unwrap().in_ground,
"nothing holds it, so it falls"
);
});
}
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn h255_added_no_request_and_exhausted_budget_keep_state() {
@@ -0,0 +1,334 @@
//! Registered play-Ping regression with the production sender-flush completion boundary.
use crate::game::{Game, lan::LanSettings};
use horizon_client::lan::{Joined, NetConfig, Out, PlayerSender, guest::Guest, hub::HubConfig};
use horizon_protocol::{
buf::Reader,
packets::{
configuration::ClientInformation,
play::{Clientbound as C, Serverbound as S},
status,
},
};
use std::sync::{Arc, atomic::AtomicI32, mpsc};
fn decode(packet: S) -> (S, usize) {
let bytes = packet.encode();
let mut reader = Reader::new(&bytes);
let id = reader.varint().unwrap();
(S::decode(id, reader.rest()).unwrap(), bytes.len())
}
fn output(rx: &mpsc::Receiver<Out>) -> Vec<C> {
rx.try_iter()
.flat_map(|out| match out {
Out::Packet(body) => vec![body],
Out::Packets(bodies) => bodies,
Out::Disconnect(reason) => panic!("unexpected disconnect: {reason:?}"),
})
.map(|body| {
println!("actual outbound play body={body:02x?}");
let mut reader = Reader::new(&body);
let id = reader.varint().unwrap();
C::decode(id, reader.rest()).unwrap()
})
.collect()
}
fn pass(game: &mut Game<'_>, conn: u32, packet: S) {
let (packet, bytes) = decode(packet);
if let S::PingRequest(p) = &packet {
println!(
"actual decoded play PingRequest conn={conn} value={} bits={:016x}",
p.time, p.time as u64
);
}
let hub = &game.lan.as_ref().unwrap().hub;
assert_eq!(hub.guests.len(), 2);
assert_ne!(hub.guests[0].uuid, hub.guests[1].uuid);
assert_ne!(hub.guests[0].conn, hub.guests[1].conn);
let registered = hub.guest(conn).expect("origin connection registered");
assert_eq!(registered.uuid, u128::from(conn));
assert!(!registered.fake && !registered.sender.is_fake());
assert!(registered.left_reason.is_empty());
assert_eq!(registered.dimension, "minecraft:overworld");
let guest = game.lan.as_mut().unwrap().hub.guest_mut(conn).unwrap();
guest.inbox.push_back((packet, bytes));
guest.inbox_bytes += bytes;
// Already registered connections: this is a second level pass, without polling sockets.
game.level_pass.first = false;
game.level_pass.packets_drained = false;
let clock_before = game.world.game_time;
game.tick_lan_packets();
// tick_lan_packets has ended its Holding scope; production flushes these senders later.
horizon_client::lan::flush_all(
game.lan
.as_ref()
.unwrap()
.hub
.guests
.iter()
.map(|g| &g.sender),
);
assert_eq!(
game.world.game_time, clock_before,
"sender completion adds no world tick"
);
println!("actual sender completion conn={conn} world_clock={clock_before}");
assert!(
game.level_pass.packets_drained,
"real Game packet phase completed"
);
assert!(
game.lan
.as_ref()
.unwrap()
.hub
.guest(conn)
.unwrap()
.inbox
.is_empty(),
"decoded request was drained by the actual Hub"
);
}
fn fixture() -> (Game<'static>, [mpsc::Receiver<Out>; 2]) {
let mut game = crate::game::native_machines::tests::server_game();
game.build_registries();
game.lan_host(
LanSettings {
port: 0,
guest_commands: false,
force_game_mode: false,
},
NetConfig {
announce: false,
..NetConfig::loopback(0, "play-ping-test".into(), 2)
},
HubConfig {
default_game_mode: 0,
guest_commands: false,
force_game_mode: false,
hardcore: false,
view_distance: 2,
simulation_distance: 2,
dimension: "minecraft:overworld".into(),
allow_flight: false,
seed: 0,
max_players: 2,
integrated: false,
levels: Default::default(),
},
)
.unwrap();
let receivers = [1, 2].map(|conn| {
let (tx, rx) = mpsc::channel();
let mut guest = Guest::new(
Joined {
conn,
uuid: u128::from(conn),
name: format!("PingGuest{conn}"),
properties: Vec::new(),
info: ClientInformation::new(2),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
expected: false,
configured: Default::default(),
},
conn as i32,
[0.5, 80.0, 0.5],
2,
);
guest.placed = true;
guest.loaded = true;
guest.loading = false;
guest.load_timeout = 0;
guest.last_action_tick = 7;
game.lan.as_mut().unwrap().hub.guests.push(guest);
rx
});
game.lan.as_mut().unwrap().hub.ticks = 123;
(game, receivers)
}
#[test]
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
fn registered_play_ping_echoes_exact_long_to_only_its_connection() {
let (mut game, receivers) = fixture();
for (turn, time) in [
1,
0,
-1,
i64::MIN,
i64::MAX,
0x0123_4567_89ab_cdef,
0xfedc_ba98_7654_3210_u64 as i64,
]
.into_iter()
.enumerate()
{
let index = turn % 2;
let conn = index as u32 + 1;
pass(&mut game, conn, S::PingRequest(status::Ping { time }));
let packets = output(&receivers[index]);
let echoed: Vec<_> = packets
.iter()
.filter_map(|p| match p {
C::PongResponse(p) => Some(p.time),
_ => None,
})
.collect();
assert_eq!(
echoed,
vec![time],
"missing or incorrect registered Game play-Ping reply"
);
assert_eq!(packets.len(), 1, "one request produces exactly one reply");
assert_eq!(
game.lan
.as_ref()
.unwrap()
.hub
.guest(conn)
.unwrap()
.last_action_tick,
7,
"transport ping does not count as a player action"
);
assert!(
output(&receivers[1 - index]).is_empty(),
"no reply to the other connection"
);
}
}
#[test]
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
fn dead_guest_play_ping_is_transport_not_an_action() {
let (mut game, receivers) = fixture();
game.lan.as_mut().unwrap().hub.guest_mut(1).unwrap().dead = true;
pass(
&mut game,
1,
S::PingRequest(status::Ping { time: i64::MIN }),
);
let packets = output(&receivers[0]);
assert!(matches!(packets.as_slice(), [C::PongResponse(p)] if p.time == i64::MIN));
assert!(output(&receivers[1]).is_empty());
assert_eq!(
game.lan
.as_ref()
.unwrap()
.hub
.guest(1)
.unwrap()
.last_action_tick,
7
);
}
#[test]
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
fn server_ping_client_pong_keeps_the_existing_prediction_route() {
let (mut game, receivers) = fixture();
let hub = &mut game.lan.as_mut().unwrap().hub;
let mut config = horizon_anticheat::Config::default();
config.log.clear();
config.strikes.clear();
config.prediction.enabled = true;
hub.set_anticheat(config);
hub.ticks = 123;
let holding = horizon_client::lan::Holding::start();
hub.anticheat_ping();
drop(holding);
// Ending Holding is not delivery: independently admit the existing flush boundary first.
for receiver in &receivers {
assert!(
output(receiver).is_empty(),
"actual server Ping is unavailable before flush"
);
}
println!("actual reverse Ping unavailable after Holding drop, before flush");
horizon_client::lan::flush_all(hub.guests.iter().map(|g| &g.sender));
for receiver in &receivers {
assert!(matches!(output(receiver).as_slice(), [C::Ping(p)] if p.id == 123));
}
pass(
&mut game,
1,
S::Pong(horizon_protocol::packets::configuration::Ping { id: 123 }),
);
let record = game
.lan
.as_mut()
.unwrap()
.hub
.guest_mut(1)
.unwrap()
.anticheat
.record
.as_deref_mut()
.expect("existing client Pong updates prediction");
assert_eq!(record.tick_end(0, false).seen, Some(123));
for receiver in &receivers {
assert!(output(receiver).is_empty());
}
}
#[test]
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
fn held_guest_play_ping_survives_the_acknowledgement_filter() {
let (mut game, receivers) = fixture();
let guest = game.lan.as_mut().unwrap().hub.guest_mut(1).unwrap();
guest.placed = false;
guest.held = true;
pass(&mut game, 1, S::PingRequest(status::Ping { time: -1 }));
assert!(matches!(output(&receivers[0]).as_slice(), [C::PongResponse(p)] if p.time == -1));
assert!(output(&receivers[1]).is_empty());
assert_eq!(
game.lan
.as_ref()
.unwrap()
.hub
.guest(1)
.unwrap()
.last_action_tick,
7
);
}
#[test]
#[ignore = "needs prepared resources and isolated scratch homes/cwd"]
fn handover_guest_play_ping_survives_the_acknowledgement_filter() {
let (mut game, receivers) = fixture();
let handover = horizon_client::lan::arrivals::Handover {
id: 0x1234,
since: 123,
record: serde_json::json!({"fixture": "handover-state-sentinel"}),
};
game.lan
.as_mut()
.unwrap()
.hub
.guest_mut(1)
.unwrap()
.handover = Some(handover.clone());
pass(
&mut game,
1,
S::PingRequest(status::Ping {
time: 0xfedc_ba98_7654_3210_u64 as i64,
}),
);
let packets = output(&receivers[0]);
assert!(
matches!(packets.as_slice(), [C::PongResponse(p)]
if p.time == 0xfedc_ba98_7654_3210_u64 as i64),
"missing matching handover play-Ping reply"
);
assert!(output(&receivers[1]).is_empty());
let guest = game.lan.as_ref().unwrap().hub.guest(1).unwrap();
assert_eq!(guest.last_action_tick, 7);
assert_eq!(guest.handover.as_ref(), Some(&handover));
}
@@ -143,6 +143,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
find_noxious_gas_source_block(&level, [pos.0, pos.1, pos.2])
}
@@ -198,6 +199,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
// The gas reads blocks only, so the mobs it reaches can be found
// before any gets its effect.
@@ -264,6 +266,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
can_be_reached_by_noxious_gas(&level, source, eye)
};
@@ -356,6 +359,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let height = unobstructed_block_count(&level, [pos.0, pos.1 + 1, pos.2], water_blocks);
let area = launch_area(pos, height);
@@ -419,6 +423,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
unobstructed_block_count(&level, [pos.0, pos.1 + 1, pos.2], water_blocks)
};
+15 -294
View File
@@ -60,6 +60,9 @@ use std::{
};
use winit::{event::MouseButton, keyboard::KeyCode};
#[path = "remote_poll.rs"]
mod poll;
type BuiltinCache = Vec<(std::path::PathBuf, Arc<Builtin>)>;
static BUILTIN: std::sync::Mutex<BuiltinCache> = std::sync::Mutex::new(Vec::new());
@@ -2174,288 +2177,6 @@ impl Game<'_> {
pub(super) fn remote_backlog(&self) -> usize {
self.remote.as_ref().map_or(0, |r| r.backlog.len())
}
/// A headless command oracle has a real connection but no movement tick.
/// Acknowledge loaded terrain without predicting or sending movement.
pub(super) fn poll_stationary_client(&mut self) -> Result<()> {
self.poll_remote()?;
let loaded = self.player.as_ref().is_some_and(|p| {
self.streaming.is_lit(
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
)
});
if loaded && self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
self.send(S::PlayerLoaded(Empty));
let r = self.remote.as_mut().unwrap();
r.loaded_sent = true;
r.level_load.finish();
}
Ok(())
}
/// Process what the network thread received (once per frame, like the
/// client's packet processor), in order. Chunk packets get
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
/// the next chunk packet on wait for the next frame, unless they have
/// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds
/// back what follows it.
pub(super) fn poll_remote(&mut self) -> Result<()> {
let polled = self.poll_remote_events();
// The block events and block changes of a piston clock start and end
// thousands of moves at once: the collision view and the renderer
// take them all in one sync, before the tick or frame reads them.
if std::mem::take(&mut self.moving_pistons_stale) {
self.sync_moving_pistons();
}
polled
}
fn poll_remote_events(&mut self) -> Result<()> {
let Some(r) = self.remote.as_mut() else {
return Ok(());
};
let mut events = std::mem::take(&mut r.backlog);
// Chunk packets start decoding now; the frame that reaches one in
// order inserts its column.
for event in r.session.poll() {
events.push_back(match event {
Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => {
match r.decoder.submit(r.chunk_format.clone(), p) {
None => Received::Chunk(r.chunk_format.clone()),
Some(p) => Received::Event(Event::Packet(p)),
}
}
event => Received::Event(event),
});
}
let overdue = r
.backlog_since
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
let mut chunks = Duration::ZERO;
// A run of ForgetLevelChunk (thousands when the server's view moves far: a
// teleport at 32 chunks) is forgotten at once, before whatever follows it.
let mut forgotten = horizon_client::fast_hash::FxHashSet::default();
while let Some(item) = events.pop_front() {
if let Received::Event(Event::Packet(packet)) = &item
&& let C::ForgetLevelChunk(p) = &**packet
{
self.native_client_packet(packet);
forgotten.insert((p.pos[0], p.pos[1]));
continue;
}
self.forget_remote_columns(&mut forgotten);
let chunk = matches!(item, Received::Chunk(_));
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
events.push_front(item);
break;
}
let started = chunk.then(Instant::now);
let event = match item {
Received::Event(event) => event,
Received::Chunk(format) => {
// A column still decoding waits for the next frame, with
// what came after it, overdue or not.
let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else {
break;
};
let (packet, column) = match next {
Next::Ready(decoded) => decoded,
Next::Decoding => {
events.push_front(Received::Chunk(format));
break;
}
Next::Lost => {
log::warn!("Horizon: the chunk decoder stopped; a column is missing");
continue;
}
};
self.native_client_packet(&packet);
if let C::LevelChunkWithLight(p) = *packet {
self.remote_chunk(&p, Some((&format, column)));
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
continue;
}
};
match event {
Event::Status(s) => {
if let Some(r) = self.remote.as_mut() {
r.status = s;
}
}
Event::Joined {
registries,
name,
uuid,
} => {
self.set_registries(registries);
self.extras_joined();
// handleLogin: the level loading screen replaces any dialog.
self.close_dialogs();
if let Some(r) = self.remote.as_mut() {
r.joined = true;
// handleLogin: startWaitingForNewLevel with Reason.OTHER.
r.profile = uuid;
r.profile_name = name.clone();
// LevelLoadingScreen's line: no player name.
r.status = horizon_client::lang::get_or(
"multiplayer.downloadingTerrain",
"Loading terrain...",
);
}
}
Event::Features(features) => {
if let Some(remote) = &mut self.remote {
remote.features = features;
}
}
Event::Brand(brand) => {
if let Some(remote) = &mut self.remote {
remote.brand = brand;
}
}
Event::QuietCommands => {
if let Some(remote) = &mut self.remote {
remote.quiet_commands = true;
}
}
Event::ModNet => {
if let Some(remote) = &mut self.remote {
remote.mod_net = true;
}
}
Event::FarTerrain(offer) => {
if let Some(remote) = &mut self.remote {
remote.far_terrain = Some(offer);
}
}
Event::Reconfiguring => {
// handleConfigurationStart: the held-back chat shows, then
// clearClientLevel opens ServerReconfigScreen over no level.
self.flush_chat_queue();
self.send_chat_acknowledgement();
self.close_dialogs();
// The server configuring us now (a proxy's next one)
// offers its own far terrain, or none, and announces its
// own features again (a Horizon server's quiet commands).
if let Some(remote) = &mut self.remote {
remote.far_terrain = None;
remote.quiet_commands = false;
remote.mod_net = false;
}
self.clear_lod();
if let Some(remote) = &self.remote {
remote.session.reset_far_terrain();
}
self.player = None;
self.screen = Screen::Playing;
self.capture(false);
// clearClientLevel drops the client level now, so its
// unload comes before the configuration screen, not
// after the next login's load.
if let Some(old) = self.remote.as_mut().and_then(|r| r.heard_level.take()) {
self.native_client_level_unload(&old);
}
let ticks = self.ticks;
if let Some(r) = self.remote.as_mut() {
r.reconfiguring = Some(ticks);
r.chat_tree_received = false;
}
}
Event::Reconfigured(registries) => {
// handleConfigurationStart → clearClientLevel, and the next
// ClientPacketListener starts with no players or scoreboard,
// no waypoints (its own ClientWaypointManager: a proxy's next
// server never untracks the last one's), and its new
// ClientSuggestionProvider with no custom chat completions.
// clearClientLevel clears the narrator.
self.clear_narrator();
self.hud_disconnected();
self.score.board = Default::default();
self.clear_waypoints();
if let Some(r) = self.remote.as_mut() {
r.attachments.clear();
r.players.clear();
r.tab_players.clear();
r.listed.clear();
r.signatures = Default::default();
r.custom_completions.clear();
}
self.ui.registries.custom_completions.clear();
self.set_registries(registries);
}
// handleTransfer: "Cannot transfer to server from
// singleplayer" (no ServerData), so the world just closes.
Event::Transfer { host, port } => {
if let Some(r) = self.remote.as_mut()
&& r.session.address != "local"
{
r.transfer = Some(format!("{host}:{port}"));
}
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::ServerLinks(links) => {
if let Some(r) = self.remote.as_mut() {
r.server_links = super::server_links::trusted(links);
}
}
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
Event::ClearDialog => self.clear_dialog(),
Event::CodeOfConduct(text) => self.handle_code_of_conduct(text),
Event::DecodeError(e) => {
if let Some(r) = self.remote.as_mut() {
r.decode_errors += 1;
if r.decode_errors <= 20 {
log::warn!("Horizon: skipped undecodable packet {e}");
}
}
}
Event::Disconnected {
reason,
key,
from_server,
} => {
log::info!("Horizon: disconnected: {reason}");
// ClientboundTransferPacket left a target: connect there
// instead of showing the disconnect, the stored cookies
// carried over (ClientCommonPacketListenerImpl.
// handleTransfer → ConnectScreen.startConnecting).
let transfer = self.remote.as_mut().and_then(|r| {
r.transfer.take().map(|address| {
(address, r.session.name.clone(), r.session.cookies.clone())
})
});
if let Some((address, name, cookies)) = transfer {
self.leave_server(None)?;
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
return Ok(());
}
let retry = self.reconnect_after(&reason, key.as_deref(), from_server);
self.leave_server(Some(reason))?;
self.offer_reconnect(retry);
return Ok(());
}
Event::Packet(packet) => self.handle_packet(*packet)?,
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
if self.remote.is_none() {
break;
}
}
if self.remote.is_some() {
self.forget_remote_columns(&mut forgotten);
}
if let Some(r) = self.remote.as_mut() {
r.backlog_since = if events.is_empty() {
None
} else {
Some(r.backlog_since.unwrap_or_else(Instant::now))
};
r.backlog = events;
}
self.send_suggestion_request();
Ok(())
}
/// ClientSuggestionProvider.customSuggestion: the chat's completion
/// request for an `ask_server` node goes to the server.
fn send_suggestion_request(&mut self) {
@@ -7083,24 +6804,24 @@ impl Game<'_> {
let mut push = Vec3::ZERO;
let order = |id: u32| r.ticked_before_player.contains(&id) == before_player;
for m in self.mobs.iter().filter(|m| order(m.id)) {
let listed = r
.level_players
.get(&(m.id as i32))
.and_then(|uuid| r.tab_players.get(uuid));
let spectator = listed.is_some_and(|t| t.game_mode == SPECTATOR);
let mob_team = match listed {
Some(t) => board.players_team(&t.name),
None => teams.of(Who::Mob(m.id)),
};
// Bats never push; a spectator has no physics; passengers of the
// same vehicle never push each other.
// Bats never push, nor passengers of one vehicle, nor a spectator; the
// box test comes before the lookups that far mobs never need (#14053).
if !m.alive()
|| spectator
|| m.species() == horizon_client::mobs::Species::Bat
|| self
.riding
.is_some_and(|v| v == m.id || m.extra.mount == Some(v))
|| !m.bounds().intersects(bounds)
{
continue;
}
let listed = r
.level_players
.get(&(m.id as i32))
.and_then(|uuid| r.tab_players.get(uuid));
let mob_team =
listed.map_or_else(|| teams.of(Who::Mob(m.id)), |t| board.players_team(&t.name));
if listed.is_some_and(|t| t.game_mode == SPECTATOR)
|| !horizon_client::scoreboard::pushable_by(mob_team, player_team, true)
{
continue;
@@ -0,0 +1,288 @@
//! The server connection's received events, once a frame (`poll_remote`): chunk packets
//! within their per-frame budget, then every other packet in order (`handle_packet`).
use super::*;
impl Game<'_> {
/// A headless command oracle has a real connection but no movement tick.
/// Acknowledge loaded terrain without predicting or sending movement.
pub(in crate::game) fn poll_stationary_client(&mut self) -> Result<()> {
self.poll_remote()?;
let loaded = self.player.as_ref().is_some_and(|p| {
self.streaming.is_lit(
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
)
});
if loaded && self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
self.send(S::PlayerLoaded(Empty));
let r = self.remote.as_mut().unwrap();
r.loaded_sent = true;
r.level_load.finish();
}
Ok(())
}
/// Process what the network thread received (once per frame, like the
/// client's packet processor), in order. Chunk packets get
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
/// the next chunk packet on wait for the next frame, unless they have
/// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds
/// back what follows it.
pub(in crate::game) fn poll_remote(&mut self) -> Result<()> {
let polled = self.poll_remote_events();
// The block events and block changes of a piston clock start and end
// thousands of moves at once: the collision view and the renderer
// take them all in one sync, before the tick or frame reads them.
if std::mem::take(&mut self.moving_pistons_stale) {
self.sync_moving_pistons();
}
polled
}
fn poll_remote_events(&mut self) -> Result<()> {
let Some(r) = self.remote.as_mut() else {
return Ok(());
};
let mut events = std::mem::take(&mut r.backlog);
// Chunk packets start decoding now; the frame that reaches one in
// order inserts its column.
for event in r.session.poll() {
events.push_back(match event {
Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => {
match r.decoder.submit(r.chunk_format.clone(), p) {
None => Received::Chunk(r.chunk_format.clone()),
Some(p) => Received::Event(Event::Packet(p)),
}
}
event => Received::Event(event),
});
}
let overdue = r
.backlog_since
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
let mut chunks = Duration::ZERO;
// A run of ForgetLevelChunk (thousands when the server's view moves far: a
// teleport at 32 chunks) is forgotten at once, before whatever follows it.
let mut forgotten = horizon_client::fast_hash::FxHashSet::default();
while let Some(item) = events.pop_front() {
if let Received::Event(Event::Packet(packet)) = &item
&& let C::ForgetLevelChunk(p) = &**packet
{
self.native_client_packet(packet);
forgotten.insert((p.pos[0], p.pos[1]));
continue;
}
self.forget_remote_columns(&mut forgotten);
let chunk = matches!(item, Received::Chunk(_));
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
events.push_front(item);
break;
}
let started = chunk.then(Instant::now);
let event = match item {
Received::Event(event) => event,
Received::Chunk(format) => {
// A column still decoding waits for the next frame, with
// what came after it, overdue or not.
let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else {
break;
};
let (packet, column) = match next {
Next::Ready(decoded) => decoded,
Next::Decoding => {
events.push_front(Received::Chunk(format));
break;
}
Next::Lost => {
log::warn!("Horizon: the chunk decoder stopped; a column is missing");
continue;
}
};
self.native_client_packet(&packet);
if let C::LevelChunkWithLight(p) = *packet {
self.remote_chunk(&p, Some((&format, column)));
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
continue;
}
};
match event {
Event::Status(s) => {
if let Some(r) = self.remote.as_mut() {
r.status = s;
}
}
Event::Joined {
registries,
name,
uuid,
} => {
self.set_registries(registries);
self.extras_joined();
// handleLogin: the level loading screen replaces any dialog.
self.close_dialogs();
if let Some(r) = self.remote.as_mut() {
r.joined = true;
// handleLogin: startWaitingForNewLevel with Reason.OTHER.
r.profile = uuid;
r.profile_name = name.clone();
// LevelLoadingScreen's line: no player name.
r.status = horizon_client::lang::get_or(
"multiplayer.downloadingTerrain",
"Loading terrain...",
);
}
}
Event::Features(features) => {
if let Some(remote) = &mut self.remote {
remote.features = features;
}
}
Event::Brand(brand) => {
if let Some(remote) = &mut self.remote {
remote.brand = brand;
}
}
Event::QuietCommands => {
if let Some(remote) = &mut self.remote {
remote.quiet_commands = true;
}
}
Event::ModNet => {
if let Some(remote) = &mut self.remote {
remote.mod_net = true;
}
}
Event::FarTerrain(offer) => {
if let Some(remote) = &mut self.remote {
remote.far_terrain = Some(offer);
}
}
Event::Reconfiguring => {
// handleConfigurationStart: the held-back chat shows, then
// clearClientLevel opens ServerReconfigScreen over no level.
self.flush_chat_queue();
self.send_chat_acknowledgement();
self.close_dialogs();
// The server configuring us now (a proxy's next one)
// offers its own far terrain, or none, and announces its
// own features again (a Horizon server's quiet commands).
if let Some(remote) = &mut self.remote {
remote.far_terrain = None;
remote.quiet_commands = false;
remote.mod_net = false;
}
self.clear_lod();
if let Some(remote) = &self.remote {
remote.session.reset_far_terrain();
}
self.player = None;
self.screen = Screen::Playing;
self.capture(false);
// clearClientLevel drops the client level now, so its
// unload comes before the configuration screen, not
// after the next login's load.
if let Some(old) = self.remote.as_mut().and_then(|r| r.heard_level.take()) {
self.native_client_level_unload(&old);
}
let ticks = self.ticks;
if let Some(r) = self.remote.as_mut() {
r.reconfiguring = Some(ticks);
r.chat_tree_received = false;
}
}
Event::Reconfigured(registries) => {
// handleConfigurationStart → clearClientLevel, and the next
// ClientPacketListener starts with no players or scoreboard,
// no waypoints (its own ClientWaypointManager: a proxy's next
// server never untracks the last one's), and its new
// ClientSuggestionProvider with no custom chat completions.
// clearClientLevel clears the narrator.
self.clear_narrator();
self.hud_disconnected();
self.score.board = Default::default();
self.clear_waypoints();
if let Some(r) = self.remote.as_mut() {
r.attachments.clear();
r.players.clear();
r.tab_players.clear();
r.listed.clear();
r.signatures = Default::default();
r.custom_completions.clear();
}
self.ui.registries.custom_completions.clear();
self.set_registries(registries);
}
// handleTransfer: "Cannot transfer to server from
// singleplayer" (no ServerData), so the world just closes.
Event::Transfer { host, port } => {
if let Some(r) = self.remote.as_mut()
&& r.session.address != "local"
{
r.transfer = Some(format!("{host}:{port}"));
}
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::ServerLinks(links) => {
if let Some(r) = self.remote.as_mut() {
r.server_links = super::super::server_links::trusted(links);
}
}
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
Event::ClearDialog => self.clear_dialog(),
Event::CodeOfConduct(text) => self.handle_code_of_conduct(text),
Event::DecodeError(e) => {
if let Some(r) = self.remote.as_mut() {
r.decode_errors += 1;
if r.decode_errors <= 20 {
log::warn!("Horizon: skipped undecodable packet {e}");
}
}
}
Event::Disconnected {
reason,
key,
from_server,
} => {
log::info!("Horizon: disconnected: {reason}");
// ClientboundTransferPacket left a target: connect there
// instead of showing the disconnect, the stored cookies
// carried over (ClientCommonPacketListenerImpl.
// handleTransfer → ConnectScreen.startConnecting).
let transfer = self.remote.as_mut().and_then(|r| {
r.transfer.take().map(|address| {
(address, r.session.name.clone(), r.session.cookies.clone())
})
});
if let Some((address, name, cookies)) = transfer {
self.leave_server(None)?;
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
return Ok(());
}
let retry = self.reconnect_after(&reason, key.as_deref(), from_server);
self.leave_server(Some(reason))?;
self.offer_reconnect(retry);
return Ok(());
}
Event::Packet(packet) => self.handle_packet(*packet)?,
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
if self.remote.is_none() {
break;
}
}
if self.remote.is_some() {
self.forget_remote_columns(&mut forgotten);
}
if let Some(r) = self.remote.as_mut() {
r.backlog_since = if events.is_empty() {
None
} else {
Some(r.backlog_since.unwrap_or_else(Instant::now))
};
r.backlog = events;
}
self.send_suggestion_request();
Ok(())
}
}
@@ -679,6 +679,7 @@ impl Game<'_> {
.player
.as_ref()
.map(|p| ([p.position.x, p.position.y, p.position.z], p.velocity.y)),
cached_chunk: std::cell::Cell::new(None),
};
laps.lap("tp_level");
// Resource packs' Polytone entity emitters on the player (#11486): fog, dust and
@@ -725,6 +726,37 @@ impl Game<'_> {
}
}
}
/// The ticks the frame shows (animated textures, the HUD, a shader pack's clock): the
/// client's, or under `HORIZON_STILL` (`FixedStep::still`) the world's game time, which
/// moves only while the world runs.
pub(super) fn visual_ticks(&self) -> u64 {
if self.clock.still() {
self.world.game_time.max(0) as u64
} else {
self.ticks
}
}
/// The clock of the animated textures: `Minecraft.runTick` ticks the `TextureManager`
/// only while the level runs normally. Under `HORIZON_STILL` it is the world's ticks.
pub(super) fn texture_clock(&self) -> u64 {
if self.clock.still() {
self.visual_ticks()
} else {
self.texture_ticks
}
}
/// `Util.getMillis` for what the frame shows (the world border's scroll, the hearts' blink,
/// the glint's under the switch): under `HORIZON_STILL`, the world's ticks in milliseconds.
pub(super) fn visual_millis(&self) -> i64 {
if self.clock.still() {
self.visual_ticks() as i64 * 50
} else {
self.started.elapsed().as_millis() as i64
}
}
}
impl Game<'_> {
/// What the packs' Polytone animation modes read this tick (wiki "Variant Textures"): the
@@ -487,7 +487,7 @@ pub(super) fn load_resources(
};
let resources = Arc::new(resources);
if menu {
horizon_client::telemetry::mark_core_ready();
horizon_client::play_session::core_ready();
log::info!(
"Horizon: core-ready after {} ms",
started.elapsed().as_millis()
@@ -526,7 +526,7 @@ pub(super) fn load_resources(
.set(rest)
.map_err(|_| "world resources already loaded".to_owned())?;
progress.set(9);
horizon_client::telemetry::mark_rest_ready();
horizon_client::play_session::rest_ready();
log::info!(
"Horizon: rest-ready after {} ms",
started.elapsed().as_millis()
@@ -190,7 +190,7 @@ impl PackResources {
let clouds = named_scope(scope, "pack.clouds", || load_clouds(root));
let forcefield = named_scope(scope, "pack.forcefield", || load_forcefield(root));
let custom_sky = named_scope(scope, "pack.custom_sky", || {
horizon_client::custom_sky::CustomSky::load(root)
horizon_client::custom_sky::load(root)
});
let environments =
named_scope(scope, "pack.environments", || pack_environments(root));
@@ -246,7 +246,7 @@ impl PackResources {
names: Arc::new(names),
clouds: Arc::new(clouds),
forcefield: Arc::new(forcefield),
custom_sky: Arc::new(custom_sky),
custom_sky,
environments,
polytone_particles,
polytone_post,
@@ -292,10 +292,8 @@ fn pack_particles(
}
/// The packs' custom colours; their dye and sheep colours go to the draw paths' table.
fn read_colors(root: &std::path::Path) -> horizon_client::polytone_colors::PackColors {
let colors = horizon_client::polytone_colors::PackColors::read(root);
horizon_client::pack_palette::set(&colors);
horizon_client::pack_palette::set_item_bars(root);
horizon_client::brewing::set_pack_colors(colors.effects());
fn read_colors(root: &std::path::Path) -> horizon_client::pack_palette::PackColors {
let colors = horizon_client::pack_palette::read(root);
horizon_client::brewing::set_pack_colors(colors.effects.clone());
colors
}
+91 -52
View File
@@ -141,11 +141,12 @@ fn pause_buttons(
remote: bool,
links: bool,
additions: bool,
split: bool,
rows: &[(&'static str, u8)],
) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
let row3 = links || additions;
let (x0, y0) = pause_grid(w, h, u8::from(row3) + u8::from(split));
let down = 24.0 * f32::from(u8::from(row3) + u8::from(split));
let extra = u8::from(row3) + rows.len() as u8;
let (x0, y0) = pause_grid(w, h, extra);
let down = 24.0 * f32::from(extra);
let mut v = vec![
(x0 + 4.0, y0 + 50.0, 204.0, "menu.returnToGame", 0, true),
(x0 + 4.0, y0 + 74.0, 98.0, "gui.advancements", 4, true),
@@ -159,9 +160,9 @@ fn pause_buttons(
// CommonDialogData.computeExternalTitle of Dialogs.SERVER_LINKS.
v.push((x0 + 4.0, y0 + 122.0, 204.0, "menu.server_links", 11, true));
}
if split {
let y = y0 + 122.0 + if row3 { 24.0 } else { 0.0 };
v.push((x0 + 4.0, y, 204.0, SPLIT_SETTINGS, 30, true));
for (k, &(key, id)) in rows.iter().enumerate() {
let y = y0 + 122.0 + 24.0 * (f32::from(u8::from(row3)) + k as f32);
v.push((x0 + 4.0, y, 204.0, key, id, true));
}
v.extend([
(x0 + 4.0, y0 + 122.0 + down, 98.0, "menu.options", 1, true),
@@ -228,14 +229,17 @@ fn pause_icon_focus_ids(friends: bool) -> Vec<u8> {
.collect()
}
const SPLIT_SETTINGS: &str = "horizon.pages.split_settings";
/// The pause menu's first id for the pages crates registered there: id `ADDED_PAGES + i` opens
/// registered page `i`.
const ADDED_PAGES: u8 = 40;
fn pause_icons(
w: f32,
h: f32,
links: bool,
additions: bool,
split: bool,
rows: &[(&'static str, u8)],
) -> [(f32, f32, &'static str); 4] {
let (x0, y0) = pause_grid(w, h, u8::from(links || additions) + u8::from(split));
let (x0, y0) = pause_grid(w, h, u8::from(links || additions) + rows.len() as u8);
let (x0, y) = (x0 + 60.0, y0 + 98.0);
[
(x0, y, "gui/sprites/pause_menu/bug"),
@@ -307,7 +311,7 @@ impl Game<'_> {
self.remote.is_some() && self.integrated.is_none()
}
pub(super) fn draw_pause_menu(&mut self, batch: &mut HudBatch) {
let friends = self.friends_view().shown;
let friends = self.friends_shown();
let atlas = &self.gui.atlas;
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, true);
@@ -331,7 +335,7 @@ impl Game<'_> {
self.external_server(),
links,
additions.is_some(),
self.options.split_guest || self.split_hud.menu,
&self.pause_rows(),
) {
let is_focused = self.pause_focus == Some(id);
let hover = active && (ui::hovered(self.cursor, x, y, bw, 20.0) || is_focused);
@@ -358,15 +362,9 @@ impl Game<'_> {
tooltip = Some(Text::translate("menu.custom_options.tooltip", &[]));
}
}
for (i, (x, y, sprite)) in pause_icons(
w,
h,
links,
additions.is_some(),
self.options.split_guest || self.split_hud.menu,
)
.into_iter()
.enumerate()
for (i, (x, y, sprite)) in pause_icons(w, h, links, additions.is_some(), &self.pause_rows())
.into_iter()
.enumerate()
{
let active = icon_active(i, friends);
let is_focused = (self.pause_focus == Some(10) && i == FRIENDS_ICON)
@@ -375,11 +373,7 @@ impl Game<'_> {
// The icon has no label: name it for controller focus and the
// control socket's widget tree.
if i == FRIENDS_ICON {
horizon_client::widgets::record(
horizon_client::friends_screen::TITLE,
[x, y, 20.0, 20.0],
active,
);
horizon_client::widgets::record(super::friends::TITLE, [x, y, 20.0, 20.0], active);
}
ui::button(
batch,
@@ -416,7 +410,7 @@ impl Game<'_> {
batch.sprite_scaled(atlas, sprite, x + 3.0, y + 3.0, 15.0, 15.0, u32::MAX);
if hover {
tooltip = Some(if i == FRIENDS_ICON {
Text::literal(horizon_client::friends_screen::title())
Text::literal(super::friends::title())
} else {
Text::translate(ICON_LABELS[i], &[])
});
@@ -456,6 +450,10 @@ impl Game<'_> {
// PauseScreen: `new SocialInteractionsScreen(this)`.
15 => self.open_social(true),
30 => self.open_split_settings(),
id @ ADDED_PAGES.. => {
let page = usize::from(id - ADDED_PAGES);
self.open_options_at(false, horizon_client::options_ui::Page::Added(page));
}
// addCustomDialogButtons: DialogConnectionAccess.openDialog of the
// additions dialog, from the pause menu.
14 => {
@@ -471,6 +469,23 @@ impl Game<'_> {
}
Ok(())
}
/// The pause menu's full-width rows above Options: Splitscreen's settings, then the pages
/// crates registered for the pause menu (`horizon_hooks::pages`), by their title.
fn pause_rows(&self) -> Vec<(&'static str, u8)> {
let split = self.options.split_guest || self.split_hud.menu;
let pages = horizon_hooks::pages::engine::all().iter().enumerate();
let added = pages
.filter(|(i, page)| {
page.place() == horizon_hooks::pages::Place::Pause
&& *i < usize::from(u8::MAX - ADDED_PAGES)
})
.map(|(i, page)| (page.title(), ADDED_PAGES + i as u8));
split
.then_some((SPLIT_SETTINGS, 30))
.into_iter()
.chain(added)
.collect()
}
pub(super) fn pause_click(&mut self) -> Result<()> {
let (w, h) = {
let size = self
@@ -482,13 +497,7 @@ impl Game<'_> {
};
let links = !self.server_links().is_empty();
let additions = self.pause_screen_additions();
let icons = pause_icons(
w,
h,
links,
additions.is_some(),
self.options.split_guest || self.split_hud.menu,
);
let icons = pause_icons(w, h, links, additions.is_some(), &self.pause_rows());
let friends = icons[FRIENDS_ICON];
let hit = pause_buttons(
w,
@@ -496,7 +505,7 @@ impl Game<'_> {
self.external_server(),
links,
additions.is_some(),
self.options.split_guest || self.split_hud.menu,
&self.pause_rows(),
)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
@@ -509,9 +518,8 @@ impl Game<'_> {
.map(|i| 12 + i as u8)
})
.or_else(|| {
(self.friends_view().shown
&& ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
.then_some(10)
(self.friends_shown() && ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
.then_some(10)
});
if let Some(id) = hit {
self.pause_focus = Some(id);
@@ -536,10 +544,10 @@ impl Game<'_> {
self.external_server(),
links,
self.pause_screen_additions().is_some(),
self.options.split_guest || self.split_hud.menu,
&self.pause_rows(),
);
let mut active_ids: Vec<u8> = buttons.into_iter().filter(|b| b.5).map(|b| b.4).collect();
let friends = self.friends_view().shown;
let friends = self.friends_shown();
active_ids.extend(pause_icon_focus_ids(friends));
if active_ids.is_empty() {
return Ok(());
@@ -584,20 +592,20 @@ impl Game<'_> {
self.external_server(),
links,
self.pause_screen_additions().is_some(),
self.options.split_guest || self.split_hud.menu,
&self.pause_rows(),
);
let mut rects: Vec<(u8, f32, f32, f32)> = buttons
.into_iter()
.filter(|b| b.5)
.map(|b| (b.4, b.0, b.1, b.2))
.collect();
let friends = self.friends_view().shown;
let friends = self.friends_shown();
for (i, (x, y, _)) in pause_icons(
w,
h,
links,
self.pause_screen_additions().is_some(),
self.options.split_guest || self.split_hud.menu,
&self.pause_rows(),
)
.into_iter()
.enumerate()
@@ -915,7 +923,10 @@ impl Game<'_> {
#[cfg(test)]
mod tests {
use super::{Screen, death_buttons, pause_buttons, pause_icon_focus_ids, pause_icons};
use super::{
ADDED_PAGES, SPLIT_SETTINGS, Screen, death_buttons, pause_buttons, pause_icon_focus_ids,
pause_icons,
};
#[test]
fn death_buttons_sit_on_whole_gui_pixels() {
@@ -998,7 +1009,7 @@ mod tests {
#[test]
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
let buttons = pause_buttons(427.0, 240.0, remote, false, false, false);
let buttons = pause_buttons(427.0, 240.0, remote, false, false, &[]);
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
assert_eq!(quit.3, label);
assert!(quit.5);
@@ -1023,14 +1034,14 @@ mod tests {
#[test]
fn server_links_adds_a_full_width_row_above_options() {
let with = pause_buttons(427.0, 240.0, true, true, false, false);
let without = pause_buttons(427.0, 240.0, true, false, false, false);
let with = pause_buttons(427.0, 240.0, true, true, false, &[]);
let without = pause_buttons(427.0, 240.0, true, false, false, &[]);
let links = with.iter().find(|b| b.4 == 11).unwrap();
assert_eq!(links.2, 204.0);
assert_eq!(links.3, "menu.server_links");
assert!(links.5);
// The icon row sits above it; Options and Disconnect move down 24.
let icons = pause_icons(427.0, 240.0, true, false, false);
let icons = pause_icons(427.0, 240.0, true, false, &[]);
let y0_icons = ((240.0f32 - 190.0) * 0.25).floor() + 98.0;
assert_eq!(icons[0].1, y0_icons);
assert!(links.1 > icons[0].1);
@@ -1046,8 +1057,8 @@ mod tests {
fn pause_screen_additions_takes_the_server_links_row() {
// getCustomAdditions: one additions button in row 3, never next to the
// links button, so the grid keeps one extra row either way.
let with = pause_buttons(427.0, 240.0, true, true, true, false);
let links = pause_buttons(427.0, 240.0, true, true, false, false);
let with = pause_buttons(427.0, 240.0, true, true, true, &[]);
let links = pause_buttons(427.0, 240.0, true, true, false, &[]);
assert!(with.iter().all(|b| b.4 != 11));
let button = with.iter().find(|b| b.4 == 14).unwrap();
assert_eq!(
@@ -1060,14 +1071,42 @@ mod tests {
assert_eq!(add.1, link.1);
}
assert_eq!(
pause_icons(427.0, 240.0, true, true, false)[0].1,
pause_icons(427.0, 240.0, true, false, false)[0].1
pause_icons(427.0, 240.0, true, true, &[])[0].1,
pause_icons(427.0, 240.0, true, false, &[])[0].1
);
}
#[test]
fn registered_pages_stack_above_options_in_the_pause_menu() {
let rows = [
(SPLIT_SETTINGS, 30),
("test.page.a", ADDED_PAGES),
("test.page.b", ADDED_PAGES + 2),
];
let buttons = pause_buttons(427.0, 300.0, true, false, false, &rows);
let y = |id| buttons.iter().find(|b| b.4 == id).unwrap().1;
assert_eq!(y(ADDED_PAGES), y(30) + 24.0);
assert_eq!(y(ADDED_PAGES + 2), y(ADDED_PAGES) + 24.0);
assert_eq!(
y(1),
y(ADDED_PAGES + 2) + 24.0,
"Options under the last one"
);
// The grid stays centred: three more rows start it higher.
let without = pause_buttons(427.0, 300.0, true, false, false, &[]);
let back = |b: &[(f32, f32, f32, &str, u8, bool)]| b.iter().find(|b| b.4 == 0).unwrap().1;
assert!(back(&buttons) < back(&without));
}
#[test]
fn split_settings_preserves_dialog_rows_and_icon_actions() {
for (links, additions) in [(false, false), (true, false), (false, true), (true, true)] {
let buttons = pause_buttons(427.0, 300.0, true, links, additions, true);
let buttons = pause_buttons(
427.0,
300.0,
true,
links,
additions,
&[(SPLIT_SETTINGS, 30)],
);
let split = buttons.iter().find(|b| b.4 == 30).unwrap();
let options = buttons.iter().find(|b| b.4 == 1).unwrap();
assert_eq!(options.1, split.1 + 24.0);
@@ -1080,7 +1119,7 @@ mod tests {
}
assert!(buttons.iter().all(|b| b.4 != 12 && b.4 != 13));
assert!(
!pause_buttons(427.0, 300.0, true, links, additions, false)
!pause_buttons(427.0, 300.0, true, links, additions, &[])
.iter()
.any(|b| b.4 == 30)
);
+11 -4
View File
@@ -359,12 +359,19 @@ impl Game<'_> {
self.sculk.tracker = WardenSpawnTracker::from_nbt(tag);
}
}
/// Write the tracker into the player data being saved.
/// The tracker as saved, once it has been loaded.
pub(super) fn warden_tracker_tag(&self) -> Option<horizon_world::nbt::Tag> {
self.sculk
.tracker_loaded
.then(|| self.sculk.tracker.to_nbt())
}
/// Write the tracker into the player data being saved. With a LAN owner,
/// `store_owner` rewrites that from the owner's guest, so it puts the
/// tracker there as well (`playerdata::with_warden`).
pub(super) fn save_warden_tracker(&mut self) {
if !self.sculk.tracker_loaded {
let Some(tag) = self.warden_tracker_tag() else {
return;
}
let tag = self.sculk.tracker.to_nbt();
};
let extra = self
.level
.player_extra
+5
View File
@@ -24,6 +24,8 @@ pub(super) struct Social {
names: HashMap<u128, String>,
page: Page,
pub(super) search: EditBox,
/// `PlayerEntry.TOOLTIP_DELAY`: the row buttons' tooltips wait 500 ms.
tooltip: social_screen::TooltipDelay,
pub(super) scroll: f32,
/// Opened from the pause menu: Done goes back there.
from_pause: bool,
@@ -38,6 +40,7 @@ impl Default for Social {
names: HashMap::new(),
page: Page::All,
search,
tooltip: Default::default(),
scroll: 0.0,
from_pause: false,
}
@@ -77,6 +80,7 @@ impl Game<'_> {
self.hide_social_tutorial();
self.social.page = Page::All;
self.social.search.set_value("");
self.social.tooltip = Default::default();
self.social.scroll = 0.0;
self.social.from_pause = from_pause;
self.screen = Screen::Social;
@@ -168,6 +172,7 @@ impl Game<'_> {
self.cursor,
&view,
&mut self.social.search,
&mut self.social.tooltip,
true,
);
}
@@ -53,7 +53,7 @@ pub(super) struct StartupMenu {
pub reload_packs: bool,
/// Browse mods' worker (`mod_browser`).
mod_jobs: super::mod_browser::Jobs,
pub perf: horizon_client::telemetry::Session,
pub perf: horizon_client::play_session::Session,
}
impl StartupMenu {
+1 -1
View File
@@ -6,10 +6,10 @@
//! the time left. Vanilla has no such commands: they reach operators' command trees (completions included)
//! as a native mod's commands do (`moderation_commands`), and the game runs them itself.
use super::{Game, chat_filter::Reply, dedicated, lan::LanHost};
use horizon_chat_filter::{duration_text, now_millis, parse_duration};
use horizon_client::{mod_api::NativeCommand, text::Text};
use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_protocol::text::translatable;
use horizon_server_files::duration::{duration_text, now_millis, parse_duration};
use horizon_server_files::user_lists::{BanEntry, BanList, NameAndId, format_date};
/// The operator level `/tempban` needs, as `/ban`'s.
+14 -18
View File
@@ -169,33 +169,29 @@ impl Game<'_> {
/// Leaving the game with a world open (window closed, Quit, `quit`):
/// menu items go back (Player.closeContainer), then the world is saved,
/// once.
/// Keeps this play session's performance summary for the launcher to send, if the player
/// allows performance statistics (`horizon_client::telemetry`), and starts the next one.
/// Ends this play session for the hooks (`horizon_client::play_session`): opt-in telemetry
/// keeps its performance summary for the launcher to send, if the player allows it.
pub(super) fn finish_perf(&mut self) {
// One session per computer: P1's (a splitscreen player's would count it again).
if self.options.split_guest {
return;
}
let s = &self.settings;
let settings = horizon_telemetry::payload::Settings {
render_distance: s.render_distance.clamp(0, 255) as u8,
simulation_distance: s.simulation_distance.clamp(0, 255) as u8,
// 260 is the slider's "unlimited".
max_fps: if s.max_fps >= 260 {
0
} else {
s.max_fps.min(u32::from(u16::MAX)) as u16
},
vsync: s.vsync,
fullscreen: s.fullscreen,
graphics: s.graphics_preset.clone(),
};
// Again now: on Wayland the window is on a screen only once it has been shown.
if let Some(window) = &self.window {
horizon_client::telemetry::note_display(window);
}
self.perf
.finish(&horizon_client::logging::game_home(), settings);
let s = &self.settings;
let home = horizon_client::logging::game_home();
self.perf.finish(&horizon_hooks::WorldLeft {
home: &home,
render_distance: s.render_distance,
simulation_distance: s.simulation_distance,
// 260 is the slider's "unlimited".
max_fps: (s.max_fps < 260).then_some(s.max_fps),
vsync: s.vsync,
fullscreen: s.fullscreen,
graphics: &s.graphics_preset,
});
}
pub(super) fn save_for_exit(&mut self) -> Result<()> {
Loaded 100 of 188 files, more files were not shown because too many files have changed in this diff. Show more