Compare commits

..
Author SHA1 Message Date
Ludovic Marie c1f308fe62 Merge pull request #6700 from some-du6e/feature/launcher-import-client
🤖 Agent: queued at 73047c1810. The import refuses anything that isn't a native executable carrying the mod API mark (`read_mark`). It builds the version in `versions/.importing/<tag>-<pid>-<n>` and renames it into place in one step, so a version is never half made. Deletions stay inside the launcher's own `versions` folder. The game files come from an installed version, so the owner gate still applies.
2026-11-02 08:51:25 +01:00
Ludovic Marie 95a34c7b70 Merge pull request #6587 from JustKarkat/fix/mcp-protocol-2026-07-28
🤖 Agent: queued at 857b399103. The three MCP servers now share one `horizon-mcp` loop: `2026-07-28` stateless (version on every request, `resultType`, `ttlMs`/`cacheScope` on lists) and `2025-06-18` for clients that still open with `initialize`. An unknown version gets `-32022` instead of an echo. Each server keeps only its name, its instructions and its two tool methods.
2026-11-02 08:51:21 +01:00
Ludovic Marie 8153241fe3 Merge pull request #6784 from abit-foggy/feat/shader-lighting-separate-ao
🤖 Agent: queued at 3ce797f378. `separateAo` keeps the tint without AO in RGB and puts AO in the vertex alpha, only while the pack asks for it. Off, it costs one branch per vertex. `depthtex2` now leaves the hand out, and packs without PBR maps get flat normals and black specular. I dropped the textual end-of-function return in favour of #6821's `ZeroValue`, which also covers struct results.
2026-11-02 08:51:17 +01:00
Ludovic Marie 843bd3a05e Merge pull request #6866 from MSedgeMC/chore/ledger-rest-011
🤖 Agent: queued at 09199b88ab. I checked every row's target on main: all 3 files and functions exist. For example, `ChatTypeDecoration.decorate` points to `crates/horizon-client/src/game/remote.rs::chat_type_decorate`.
2026-11-02 08:49:41 +01:00
Ludovic MarieandClaude Opus 5.5 3ce797f378 Leave function ends to #6821's zero return
The text rewrite appended `return T(0);` to every value-returning
function, which doesn't compile for a struct type (`S(0)`). Main's #6821
gives naga's added end returns the result type's ZeroValue, structs
included; this PR's early-return test passes on it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:49:18 +01:00
Ludovic Marie dc3fc4c8c4 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-02 08:48:09 +01:00
Ludovic Marie 78383948d2 Merge pull request #6864 from MSedgeMC/chore/ledger-rest-002
🤖 Agent: queued at 55e1564629. I checked every row's target on main: all 2 files and functions exist. For example, `InputPredicate.matches` points to `crates/horizon-client/src/advancements.rs::input`.
2026-11-02 08:47:38 +01:00
Ludovic Marie f68d02653b Merge pull request #6822 from arslanarm/shaderpacks/repeated-prototypes
🤖 Agent: requeued at e7226b798 after merging main. #6821's zero-return test and this PR's two prototype tests now sit side by side; the horizon-shaders translate tests (64) and clippy pass.
2026-11-02 08:47:34 +01:00
Ludovic Marie aded556168 Merge pull request #6712 from JustKarkat/feat/agent-input-provenance-repro
🤖 Agent: queued at 7ba83315a8. Agent-made window events are left out of the trace, and the socket line is recorded instead, so an agent's input is never counted as a person's. A person's intrusion is announced once per quiet window. I added a gate so the trace runs only while the control socket, the repro keys or a recording is active. I also merged main, keeping both the `reproKeys` and the splitscreen `extras` keys.
2026-11-02 08:47:30 +01:00
Ludovic Marie 4910da9220 Merge pull request #6859 from DUzzL/fix/6733-datapack-craft-result
🤖 Agent: queued at 4cc1135dc1. The client now shows the server's result slot (container slot 0 of the open menu) instead of computing it from its own recipes, so a world data pack's recipe shows. A result the click engine predicts is kept, because `click_agreed` sends none back. The new `datapack-recipe` lan_e2e check covers both the host window and a guest.
2026-11-02 08:46:37 +01:00
Ludovic Marie b15ddb5d1d Merge pull request #6854 from MSedgeMC/fix/server-list-enter
🤖 Agent: queued at f487821eff. Enter on the server list now goes through `entry_address`, like a click, so a selected LAN game joins with `lan: true` too. With nothing selected it does nothing. The test steps with the arrows and joins each server.
2026-11-02 08:46:27 +01:00
Ludovic Marie 68811eeca0 Merge pull request #6849 from MSedgeMC/fix/cat-ocelot-despawn
🤖 Agent: queued at e77404a1f0. The test reads `tickCount` (`m.age`), not `AgeableMob.age`, which stays at 0 for an adult, so the 2400 rule now applies to adults as in vanilla. The cat joins the ocelot with `!isTame()`, and other animals keep `Animal.removeWhenFarAway`'s false.
2026-11-02 08:46:17 +01:00
Ludovic Marie c46dc592ef Merge pull request #6865 from MSedgeMC/chore/ledger-rest-006
🤖 Agent: queued at b61060e387. I checked every row's target on main: all 1 files and functions exist. For example, `EntitySelectorParser.createRotationPredicate` points to `crates/horizon-client/src/game/commands.rs::rotation_in`.
2026-11-02 08:46:02 +01:00
Ludovic Marie c1fdc42aa7 Merge pull request #6862 from MSedgeMC/chore/ledger-game-008
🤖 Agent: queued at 5cecb571ac. I checked every row's target on main: all 11 files and functions exist. For example, `InteractWithDoor.create` points to `crates/horizon-client/src/mobs/animal_brain.rs::B::trigger,`.
2026-11-02 08:45:56 +01:00
Ludovic Marie a38a515452 Merge pull request #6861 from MSedgeMC/chore/ledger-rest-013
🤖 Agent: queued at 7aefc5f447. I checked every row's target on main: all 1 files and functions exist. For example, `PlayerAdvancements.reload` points to `crates/horizon-client/src/lan/advancements.rs::ServerAdvancements::reload`.
2026-11-02 08:45:50 +01:00
Ludovic Marie 3d736183fe Merge pull request #6860 from MSedgeMC/chore/ledger-rest-021
🤖 Agent: queued at 4e673a629e. I checked every row's target on main: all 2 files and functions exist. For example, `CompilableString.CommandParserHelper.apply` points to `crates/horizon-client/src/game/commands/text_commands.rs::invalid_selector`.
2026-11-02 08:45:44 +01:00
Ludovic MarieandClaude Opus 5.5 e7226b798f Merge main into #6822
Main's #6821 added its own test beside this PR's; both are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:45:39 +01:00
Ludovic Marie bd07c6d3ab Merge pull request #6857 from MSedgeMC/fix/ledger-game2023-playerbackup
🤖 Agent: queued at 80f5297499. I checked every row's target on main: all 1 files and functions exist. For example, `PlayerDataStorage.backup` points to `crates/horizon-client/src/save.rs::back_up_unreadable_player`.
2026-11-02 08:45:39 +01:00
Ludovic Marie b5af94b764 Merge pull request #6856 from MSedgeMC/chore/ledger-rest-018
🤖 Agent: queued at 17a6c9527d. I checked every row's target on main: all 8 files and functions exist. For example, `ServerGamePacketListenerImpl.getMaximumFlyingTicks` points to `crates/horizon-client/src/lan/hub.rs::maximum_flying_ticks`.
2026-11-02 08:45:34 +01:00
Ludovic Marie afc6a73c34 Merge pull request #6855 from MSedgeMC/fix/ledger-game2016-author
🤖 Agent: queued at f0649477b6. I checked every row's target on main: all 1 files and functions exist. For example, `StructureBlock.setPlacedBy` points to `crates/horizon-client/src/game/structure_blocks.rs::structure_block_placed`.
2026-11-02 08:45:26 +01:00
Ludovic Marie 88f0b51ea7 Merge pull request #6853 from MSedgeMC/fix/ledger-game2015-blocks
🤖 Agent: queued at 5ec019ddeb. I checked every row's target on main: all 3 files and functions exist. For example, `PotentSulfurBlock.triggerEvent` points to `crates/horizon-world/src/level/pistons.rs::Sim::trigger_event`.
2026-11-02 08:45:20 +01:00
Ludovic Marie 8841f664a2 Merge pull request #6852 from MSedgeMC/chore/ledger-game-011
🤖 Agent: queued at df471629cf. I checked every row's target on main: all 17 files and functions exist. For example, `AbstractHorse.causeFallDamage` points to `crates/horizon-client/src/mobs/living.rs::Mob::cause_fall_damage_at`.
2026-11-02 08:45:14 +01:00
Ludovic Marie 524a17bc47 Merge pull request #6850 from MSedgeMC/fix/ledger-classes04-sulfur
🤖 Agent: queued at 1c70bc70b8. I checked every row's target on main: all 9 files and functions exist. For example, `PotentSulfurBlockEntity.findNoxiousGasSourceBlock` points to `crates/horizon-client-render/src/potent_sulfur.rs::find_noxious_gas_source_block`.
2026-11-02 08:45:09 +01:00
Ludovic Marie 058d554402 Merge pull request #6848 from MSedgeMC/fix/ledger-classes08-level
🤖 Agent: queued at c6c858aadb. I checked every row's target on main: all 5 files and functions exist. For example, `MapBanner.getId` points to `crates/horizon-client/src/game/maps.rs::toggle_banner`.
2026-11-02 08:45:03 +01:00
Ludovic MarieandClaude Opus 5.5 7ba83315a8 Merge main into #6712
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:43:00 +01:00
Ludovic MarieandClaude Opus 5.5 1d938d5d06 Trace input only while something can read it
note_input ran on every key and every grabbed mouse motion, agent control
off included. It now returns at once unless the control socket is up, the
repro shortcuts are on, or a recording runs, so players who use none of
them pay nothing per input event.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:42:01 +01:00
Ludovic Marie db813a1c38 Merge pull request #6397 from martipops/split/fresh-mouse-controls
🤖 Agent: queued at ab8be4adc3. The two layout choices and Names are P1's settings, saved in its options file during its own turn (`self.turn(0)`), and loading clamps out-of-range values to 0..=2. Every label goes through `horizon.splitscreen.setup.*` keys. One leftover: `extras::SPLIT_TWO` and `SPLIT_THREE` are English arrays that nothing reads, so they can go in a follow-up.
2026-11-02 08:40:46 +01:00
Ludovic Marie 64fb9a5207 Merge pull request #6813 from sdraeger/feat/manual-mutes
🤖 Agent: queued at 4d0c0fb6d1. Manual mutes reuse the filter's store, with optional `by` and `reason`, and `until: null` means permanent, so old `chat-filter-mutes.json` files still load. A filter mute never shortens an operator's. One point for a follow-up: the replies (`Muted {name} …`, `There are N mute(s)`) are English literals, as `/unmute`'s were. The game's rule is translation keys, so a later pass should move all four commands to `lang` keys.
2026-11-02 08:39:10 +01:00
Ludovic Marie a270729d98 Merge pull request #6783 from some-du6e/fix/bucket-double-slab
🤖 Agent: queued at 955a5059e9. This now follows `BucketItem.emptyContents`: on a refusal it retries `pos.relative(direction)` with no hit result, where Shift no longer counts. `bucket_target` also asks `canPlaceLiquid`, so kelp, seagrass, a double slab and a survival barrier aim beside the block from the start, and a fish never spawns inside a double slab. The client's dry run now judges "server-side only" on where the water actually went.
2026-11-02 08:39:07 +01:00
Ludovic Marie e6cd27639f Merge pull request #6670 from wxundup/fix/launcher-clippy-feature
🤖 Agent: queued at 3c9f09475e. The fallback now names `gpui_platform/runtime_shaders`, the feature of `launcher/Cargo.toml`'s `gpui_platform` rename (`gpui-fast` has none). The test runs `pr_ready`'s real shell snippet with fake `uname`, `xcrun` and `cargo`, so it checks what cargo receives, not the string.
2026-11-02 08:39:02 +01:00
Ludovic Marie 6eea3303f6 Merge pull request #6723 from 4404c/secure-chat/confirm-command-return
🤖 Agent: queued at 2b5e91b348. Answering no now returns to the book or dialog the click left, as `openSendConfirmationWindow`'s `currentScreen` does. A lectern's book isn't kept, because it goes with its menu. The prompt frees the pointer and takes Tab, the arrows, Enter and Space. The capture test covers the release.
2026-11-02 08:37:21 +01:00
Ludovic Marie 285421238a Merge pull request #6717 from MSedgeMC/test/fluid-current-rules
🤖 Agent: queued at 81569ba550. These tests pin `CurrentAccumulator.applyTo`: a mob takes the normalised direction (two samples push as hard as one), a player the average, and the 0.0045 lift applies only to a body nearly still on x and z. Each expected value is worked out by hand from vanilla's formula.
2026-11-02 08:37:17 +01:00
Ludovic Marie 1ba6449412 Merge pull request #6839 from Vaspyyy/perf/early-launch-preload
🤖 Agent: queued at 06582aea2b. The launch world's preload now starts once the block registry and the biomes are in, about 250 ms earlier. It runs only when `root == vanilla_root`, so a failed pack that restarts core from the game's own resources doesn't preload twice. `biomes` becomes an `Arc` so both share it.
2026-11-02 08:37:13 +01:00
Ludovic Marie b371c1a92c Merge pull request #6863 from MSedgeMC/test/baby-mob-shadows
🤖 Agent: queued at 5299166ad0. The two expected radii come from the renderers, not from the code: `StriderRenderer` halves 0.5 × 0.5 again (0.125), and `TurtleRenderer` takes 0.83 of 0.7 × 0.3.
2026-11-02 08:37:09 +01:00
Ludovic Marie 0fdc59a90f Merge pull request #6838 from MSedgeMC/fix/map-client-008
🤖 Agent: queued at 8324aabac2. I checked every row's target on main: all 11 files and functions exist. For example, `BlockStateModelWrapper.updateTints` points to `crates/horizon-client/src/game/minecarts.rs::Game::block_model_vertices`.
2026-11-02 08:36:13 +01:00
Ludovic Marie 76eac0e950 Merge pull request #6835 from MSedgeMC/chore/ledger-game-002
🤖 Agent: queued at d1a5b12246. I checked every row's target on main: all 6 files and functions exist. For example, `ServerPlayer.saveParentVehicle` points to `crates/horizon-client/src/save.rs::Level::player_tag`.
2026-11-02 08:36:10 +01:00
Ludovic Marie 52c31f5846 Merge pull request #6833 from MSedgeMC/fix/map-client2-017
🤖 Agent: queued at be131063a0. I checked every row's target on main: all 9 files and functions exist. For example, `ClientBundleTooltip.extractSelectedItemTooltip` points to `crates/horizon-client/src/game/stations.rs::Game::draw_bundle_selected_name`.
2026-11-02 08:36:06 +01:00
Ludovic Marie 73f0230867 Merge pull request #6531 from senjinthedragon/hdr-glow-more
🤖 Agent: queued at 40c20bf016. LOD tiles now carry the section mesher's HDR tier (`hdr_tier`), so a light source keeps shining past the LOD boundary. The conduit's shine skips its crumbling overlay, and the warden's layers shine by their faded alpha (`* in.color.a`). All of it is per-vertex data set at mesh time, with no extra pass.
2026-11-02 08:36:02 +01:00
Ludovic Marie 11a0e71d2a Merge pull request #6870 from senjinthedragon/hdr-water
🤖 Agent: queued at a7125b5ace. Water gets HDR lighting from a second, alpha-only pass (`ALPHA_ONLY`: colour kept, alpha replaced) over the translucent draws. `fs_translucent_mark` discards texels below 0.6 alpha, so clear glass doesn't hide light behind it. It costs one more translucent draw list per frame, and only while HDR lighting is on (off by default). `mark_translucent_terrain` returns before any draw when it is off.
2026-11-02 08:35:56 +01:00
Ludovic Marie 146534a94b Merge pull request #6837 from MSedgeMC/fix/dragon-breath-host-puffs
🤖 Agent: queued at 997f9477e3. The fan is now one function, `breath_puffs`, used by `remote_dragon_phase_tick`, and the `flameTicks` advance stays in the client copy. I removed the host-side call: it drew from the server's `world.random` on the server thread. Single player already breathes through the remote path.
2026-11-02 08:35:52 +01:00
Ludovic Marie ed12f5c29e Merge pull request #6832 from MSedgeMC/fix/sound-broadcast-range
🤖 Agent: queued at 99deb65138. `PlayerList.broadcast` leaves out a player exactly on the radius (`d < r²`), and the test pins 16.0 vs 15.99. It only changes `<=` to `<` on a path that runs once per sound per guest.
2026-11-02 08:35:49 +01:00
Ludovic Marie e99cdb3c42 Merge pull request #6830 from Joansitoh/fix/6771-dragon-breath
🤖 Agent: queued at 4a5a130c2a. `harm_amount` is `(int)(0.5 * (6 << amp) + 0.5)`, the cloud's scale passed to `HealOrHarmMobEffect`: 3 from the flame cloud and 6 from a fireball's, where Horizon dealt 6 and 12. The 0.5-tall box, with the radius check on the position, replaces the `|dy| < 1` test. The fireball's cloud now snaps to a player within 4 blocks. Mobs are not snap targets yet, as the comment says.
2026-11-02 08:35:45 +01:00
Ludovic Marie dcc9ad1d4a Merge pull request #6828 from ClickingMaster/fix/zombified-piglin-creative-target
🤖 Agent: queued at 36a4682121. A creative or spectator player is dropped as a target at the start of the tick, and an angry zombified piglin or iron golem loses its anger too. The test ticks 40 times to check the piglin doesn't pick the player back up through its anger.
2026-11-02 08:35:41 +01:00
Ludovic Marie 55c89bab62 Merge pull request #6824 from Vaspyyy/perf/rotation-table
🤖 Agent: queued at cbaf3287f6. States with neither the axis nor the facing family skip all six clones. Within a family, a rotation that sets the value the state already has skips its clone too. The test pins the observer's six facings with `powered` kept, and the hopper's five-way facing left alone. Load time only.
2026-11-02 08:35:37 +01:00
Ludovic Marie 35d34cd818 Merge pull request #6821 from arslanarm/shaderpacks/implicit-return-value
🤖 Agent: queued at 3df1f09d35. Only a `Return { value: None }` that naga's `ensure_block_returns` put at a reachable end is rewritten to `ZeroValue`, in functions with a result type. A function's own returns are untouched, and the test checks that with `own`.
2026-11-02 08:35:33 +01:00
Ludovic Marie 9daa3c4070 Merge pull request #6815 from abit-foggy/fix/credits-vignette-flicker
🤖 Agent: queued at cb38e971c6. The credits vignette now samples `credits_vignette.png` bilinearly, through its own `fs_vignette` and a linear sampler at binding 5, which honours the `.mcmeta`'s `blur: true`. The other HUD pipelines keep their nearest sampler. The extra binding costs nothing per frame.
2026-11-02 08:35:29 +01:00
Ludovic Marie 4944ebfafa Merge pull request #6769 from MSedgeMC/fix/end-portal-faces
🤖 Agent: queued at 228889f046. The two Y faces now carry `CULL_BACK`, so each side draws only from its front, as `rendertype_end_portal` does. The test checks the winding of both faces, not only the flag.
2026-11-02 08:35:25 +01:00
Ludovic Marie 06ed286b67 Merge pull request #6753 from ClickingMaster/fix/fishing-hook-primed-tnt
🤖 Agent: queued at 9460d096a8. A primed TNT is now pickable by the hook (`PrimedTnt.isPickable`), followed at 0.8 of its height, and pulled. The `t.id != 0` guards keep unassigned TNTs out. The `fishing-tnt` lan_e2e check reads the pull through `/data`.
2026-11-02 08:35:21 +01:00
Ludovic MarieandClaude Opus 5.5 997f9477e3 Keep dragon breath off the server thread's random
tick_dragon runs on the server thread (single player's default since #1514,
LAN, dedicated): drawing the puffs there took nextGaussian()s from the
server's world random, which vanilla's server never does, and spawned
particles nobody sees. Every client, single player's included, already
breathes the fan in remote_dragon_phase_tick, now through breath_puffs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:32:35 +01:00
Ludovic Marie b3d0f38e6c Merge pull request #6820 from DUzzL/fix/6776-mobs-push-minecart
🤖 Agent: queued at 57627e6fce. `cart_push_from` follows `AbstractMinecart.push`: the 1e-4 floor, `pow` capped at 1, 0.1f × 0.5, and a quarter back to the mob. It runs once per tick after `push_carts`, which returns at once with no carts, so worlds without carts pay nothing. With carts it costs mobs × carts box tests.
2026-11-02 08:31:23 +01:00
Ludovic Marie 9411798807 Merge pull request #6805 from DUzzL/fix/6791-camel-husk-seats
🤖 Agent: queued at 191cc8cdbc. `passenger_attachment_at` follows `Camel.getPassengerAttachmentPoint`: the driver sits 0.5 ahead, the second seat 0.7 behind, and an `Animal` sits 0.2 further ahead. The seat index scans `mobs` for each camel rider each tick, which is negligible since only camel riders take that path.
2026-11-02 08:30:31 +01:00
Ludovic Marie 924254a050 Merge pull request #6799 from ClickingMaster/fix/golem-anger-stays-on-attacker
🤖 Agent: queued at 6330a192d6. An angry iron golem (`extra.anger > 0`) now counts as targeting, so it keeps the guest it is angry at after the 100-tick `getLastHurtByMob` window. Only golems take that branch. The test puts a bystander nearer to prove the angry target wins.
2026-11-02 08:30:26 +01:00
Ludovic Marie a05a2b03d1 Merge pull request #6798 from MSedgeMC/fix/leash-offsets
🤖 Agent: queued at f8741e7eee. The four `getLeashOffset` overrides match vanilla: iron golem 0.875, snow golem 0.75, copper golem 0.75 on the centre (no forward shift), fox 0.55. The `0.875_f32 * eye as f32` keeps vanilla's float maths.
2026-11-02 08:30:23 +01:00
Ludovic Marie 4b14288cc9 Merge pull request #6789 from MSedgeMC/fix/fox-frog-variant-component
🤖 Agent: queued at 0fa27d49b7. `Fox.applyImplicitComponent` and `Frog.applyImplicitComponent` now set the variant from the egg's component. `variant_index` gains the fox's `["red", "snow"]`, so the frog's list no longer serves foxes by accident.
2026-11-02 08:30:19 +01:00
Ludovic Marie 8390d94f4a Merge pull request #6787 from ErikKalikas/fix/block-loot-world-sequence
🤖 Agent: queued at cc99f96033. Both sites now read loot through `world_items!`, so a world data pack overriding a block or shearing table is honoured when the table has no random sequence. Before, they read the built-in tables from `res.items()`.
2026-11-02 08:30:16 +01:00
Ludovic Marie 329b6a05d1 Merge pull request #6778 from MSedgeMC/fix/options-wheel-scroll
🤖 Agent: queued at 0d15238cc3. A click now focuses its widget only for the highlight (`focus_from_keyboard = false`). The wheel scrolls the list, as `AbstractScrollArea.mouseScrolled` does, instead of toggling the clicked option from anywhere. Tab, the arrows and a pad keep the documented Horizon divergence, and the test covers both cases.
2026-11-02 08:30:12 +01:00
Ludovic Marie 57825427e0 Merge pull request #6827 from horizon-rust/perf/share-sections
🤖 Agent: verify green; pr_ready --quick plus a 3-round A/B on playhorizon (−118 MB RSS at RD 32; 97 of 116 MB of the window's sections shared with the server thread).
2026-11-02 08:30:09 +01:00
Ludovic Marie 3f46d31b90 Merge pull request #6826 from horizon-rust/perf/light-compact
🤖 Agent: verify green; pr_ready --quick plus a 3-round A/B on playhorizon (−102 MB RSS at RD 32, light per section −49 %); the rebase's mesher change is checked, clippy-clean and tested on the Mac.
2026-11-02 08:29:48 +01:00
Ludovic Marie 0889387a14 Merge pull request #6825 from horizon-rust/perf/memory-census
🤖 Agent: tooling only (horizon-ctl memory census); verify green, pr_ready --quick on playhorizon (env-only Python failures explained in the body).
2026-11-02 08:29:27 +01:00
Ludovic Marie 799d48623b Merge pull request #6749 from DUzzL/fix/6706-spectator-water-splash
🤖 Agent: queued at 513cd005e5. A spectator returns before `water_splash` makes any random draw, as `Player.doWaterSplashEffect` does, so a spectator player and a normal player now consume the RNG differently. The test checks both modes.
2026-11-02 08:29:23 +01:00
Ludovic Marie 9d2b0de3ca Merge pull request #6737 from 4404c/auth/join-retry
🤖 Agent: queued at 247ff24c46. Only `JoinError::Unavailable` (no answer) is retried once after 500 ms; a refusal from the service (invalid session, banned, 429) is not. The tests cover both. Logging the transport error makes the "service unavailable" reports diagnosable.
2026-11-02 08:29:19 +01:00
Ludovic Marie 4d97e7fa30 Merge pull request #6728 from HrafnaValkyrja/fix/tnt-minecarts
🤖 Agent: queued at a8ec40548d. The fall now accumulates in the move, `(float)` cast included, the way `Entity.checkFallDamage` does. A landing of 3 or more blocks explodes with `(fall / 10)²` as the speed squared (`MinecartTNT.causeFallDamage`). Before, the code primed a zero fuse and threw the cart up instead.
2026-11-02 08:29:15 +01:00
Ludovic Marie f766dfe397 Merge pull request #6715 from HrafnaValkyrja/refactor/stable-ids-rest
🤖 Agent: queued at 1e1c6d5699. Rockets, eyes and NBT-loaded tridents now take ids through `take_entity_id`. The trident used to go through `assign_entity_id`, which only assigns when the id is 0, and `from_nbt` leaves it at 0, so the result is the same.
2026-11-02 08:29:10 +01:00
Ludovic Marie 7e5b3861ef Merge pull request #6779 from MSedgeMC/fix/no-frame-no-spin
🤖 Agent: queued at d89499c0ae. `frame_drawn` is only false after `render` returns `Ok(false)` (the surface was occluded, lost or timed out). While frames reach the screen the loop still polls, so pacing is unchanged. A hidden Wayland window now waits 16 ms instead of pinning a core.
2026-11-02 08:29:06 +01:00
Ludovic Marie f9e0156da5 Merge pull request #6729 from Vaspyyy/perf/sync-preload-parallel
🤖 Agent: queued at 189e128d00. Its own thread now reads `sync_data` beside the worldgen and loot preload, and the preload joins it, so it still ends when its thread does. If the thread cannot start, the data is read inline as before. The launch-world path does not call `behind_server`, which is right, since the opening waits on it.
2026-11-02 08:29:02 +01:00
Ludovic Marie 7810a64ff8 Merge pull request #6750 from 4404c/tests/windows-checkout
🤖 Agent: queued at 8fb8106da9. Both fixes are for Windows checkouts. The `include_str!` test normalizes CRLF. The capture fixture expects one warp, which matches `capture` on main since #6463 removed the warp after the grab.
2026-11-02 08:27:12 +01:00
Ludovic Marie 8cca352d62 Merge pull request #6748 from Vaspyyy/perf/upload-budget-arriving
🤖 Agent: queued at a357026d64. While the view is still arriving, frames upload meshes for 6 ms instead of 2. `arriving` already holds the far terrain back in the same function, so play and edits keep the 2 ms slice. The table shows −75 ms to `world.view_full`, over 6 alternating runs on an RTX 3070.
2026-11-02 08:27:08 +01:00
Ludovic Marie aef543ade2 Merge pull request #6869 from MSedgeMC/chore/ledger-game2-006
🤖 Agent: queued at ce08e86518. I checked every row's target on main: all 1 files and functions exist. For example, `Raider.readAdditionalSaveData` points to `crates/horizon-client/src/mobs/raider.rs::read_nbt`.
2026-11-02 08:27:03 +01:00
Ludovic Marie 5b0c702e05 Merge pull request #6868 from MSedgeMC/chore/ledger-rest-024
🤖 Agent: queued at 2aaac835bb. I checked every row's target on main: all 1 files and functions exist. For example, `WorldUpgrader.verifyChunkPosAndEraseCache` points to `crates/horizon-world/src/datafix/world_upgrader.rs::verify_chunk_pos_and_erase_cache`.
2026-11-02 08:27:00 +01:00
Ludovic Marie a00f402445 Merge pull request #6867 from MSedgeMC/chore/ledger-game-004
🤖 Agent: queued at 631afe689c. I checked every row's target on main: all 6 files and functions exist. For example, `Bucketable.loadDefaultDataFromBucketTag` points to `crates/horizon-client/src/entity_nbt.rs::apply_item_components`.
2026-11-02 08:26:56 +01:00
Ludovic Marie 37b255770d Merge pull request #6847 from MSedgeMC/chore/ledger-rest-017
🤖 Agent: queued at b31fcfad5d. I checked every row's target on main: all 3 files and functions exist. For example, `DedicatedServer.readCodeOfConducts` points to `crates/horizon-client/src/lan/code_of_conduct.rs::read`.
2026-11-02 08:26:50 +01:00
Ludovic Marie 7dd84f5e2d Merge pull request #6846 from MSedgeMC/fix/map-client2-012
🤖 Agent: queued at 41d82fca71. I checked every row's target on main: all 8 files and functions exist. For example, `NowPlayingToast.extractToast` points to `crates/horizon-client/src/toasts.rs::draw_now_playing`.
2026-11-02 08:26:45 +01:00
Ludovic Marie 882045d9dd Merge pull request #6845 from MSedgeMC/chore/ledger-rest-015
🤖 Agent: queued at 7676138acf. I checked every row's target on main: all 7 files and functions exist. For example, `FetchProfileCommand.resolveName` points to `crates/horizon-client/src/game/commands/extra.rs::Game::cmd_fetchprofile`.
2026-11-02 08:26:40 +01:00
Ludovic Marie 1adaad28d7 Merge pull request #6844 from MSedgeMC/fix/map-client2-011
🤖 Agent: queued at 83c0f67e9e. I checked every row's target on main: all 27 files and functions exist. For example, `MultilineTextField.selectWordAtCursor` points to `crates/horizon-client-render/src/ui.rs::EditBox::select_word_at_cursor`.
2026-11-02 08:26:36 +01:00
Ludovic Marie 9e7d48794a Merge pull request #6842 from MSedgeMC/fix/ledger-levelgen-flat-debug
🤖 Agent: queued at 80cc16e412. I checked every row's target on main: all 5 files and functions exist. For example, `DebugLevelSource` points to `crates/horizon-client/src/debugworld.rs`.
2026-11-02 08:26:32 +01:00
Ludovic Marie c01af7b3e8 Merge pull request #6841 from MSedgeMC/chore/ledger-rest-014
🤖 Agent: queued at 1166472c8e. I checked every row's target on main: all 6 files and functions exist. For example, `BanIpCommands.register` points to `crates/horizon-client/src/game/commands.rs::Game::execute_command`.
2026-11-02 08:26:27 +01:00
Ludovic Marie d62917c6b2 Merge pull request #6840 from MSedgeMC/chore/ledger-rest-012-lookat
🤖 Agent: queued at 30d5b97529. I checked every row's target on main: all 7 files and functions exist. For example, `ClientboundBossEventPacket.createUpdateProgressPacket` points to `crates/horizon-client/src/game/bossbars.rs::Game::diff_boss_events`.
2026-11-02 08:24:57 +01:00
Ludovic Marie 1f136b6768 Merge pull request #6836 from MSedgeMC/fix/map-client-003
🤖 Agent: queued at fc27217c05. I checked every row's target on main: all 12 files and functions exist. For example, `ServerStatusPinger.pingLegacyServer` points to `crates/horizon-client/src/net.rs::ping_legacy`.
2026-11-02 08:24:53 +01:00
Ludovic Marie 9ab32f056a Merge pull request #6831 from MSedgeMC/chore/ledger-client-004
🤖 Agent: queued at 001ed6987e. I checked every row's target on main: all 8 files and functions exist. For example, `IronGolemRenderer.setupRotations` points to `crates/horizon-client/src/mobs/render.rs::base_transform_at`.
2026-11-02 08:24:50 +01:00
Ludovic Marie 78c68bdf69 Merge pull request #6829 from MSedgeMC/chore/ledger-rest-003
🤖 Agent: queued at eefd16a005. I checked every row's target on main: all 5 files and functions exist. For example, `KeyboardHandler.textEditing` points to `crates/horizon-client/src/game.rs::ime_preedit`.
2026-11-02 08:24:45 +01:00
Ludovic Marie 07d6321fdb Merge pull request #6796 from MSedgeMC/chore/ledger-client2-019
🤖 Agent: queued at e038e8d568. I checked every row's target on main: all 8 files and functions exist. For example, `CreateWorldScreen.getOrCreateTempDataPackDir` points to `crates/horizon-client/src/title.rs:971`. I moved `copyBetweenDirs` to `horizon-client-render/src/datapack.rs`, where `copy_pack` lives.
2026-11-02 08:24:40 +01:00
Ludovic Marie 0718eb1776 Merge pull request #6793 from MSedgeMC/fix/ledger-game-012
🤖 Agent: queued at 6e2790907c. I checked every row's target on main: all 8 files and functions exist. For example, `Fox.canHoldItem` points to `crates/horizon-client/src/mobs/fox.rs::can_hold_item`.
2026-11-02 08:24:37 +01:00
Ludovic Marie ac12a556ed Merge pull request #6785 from MSedgeMC/chore/ledger-game2-002
🤖 Agent: queued at 35409ecb35. I checked every row's target on main: all 6 files and functions exist. For example, `ItemFrame.canHurtWhenFixed` points to `crates/horizon-client/src/game/frames.rs::attack_frame`.
2026-11-02 08:24:33 +01:00
Ludovic Marie ff98e4c10e Merge pull request #6760 from arismoko/v2/ses_01M6BECNQ499TYMMBQBDF00VJ5-native-profiles-e
🤖 Agent: queued. Keeping `performance` and `dev` in one `native/target/fast/` and passing the opt-level through `--config` rather than `[profile.fast]` is right: the workspace's own fast builds stay untouched. One point to watch: the build record is keyed by folder, so `performance` and `dev` share it, and only the build key keeps a dev binary from passing as a performance one after a switch. A test that builds dev, switches to performance and expects a rebuild would pin that.
2026-11-02 08:24:29 +01:00
Ludovic Marie c836ff1ee7 Merge pull request #6773 from MSedgeMC/chore/ledger-rest-012
🤖 Agent: queued at db02e3d8d3. I checked every row's target on main: all 4 files and functions exist. For example, `ClientboundAddTransientBlockPacket.write` points to `crates/horizon-client/src/game/remote.rs:`.
2026-11-02 08:24:25 +01:00
Ludovic Marie 5bb6637a2a Merge pull request #6767 from MSedgeMC/chore/ledger-client2-012
🤖 Agent: queued at 815e81e027. I checked every row's target on main: all 7 files and functions exist. For example, `ContainerEventHandler.preeditUpdated` points to `crates/horizon-client/src/game.rs::ime_preedit`.
2026-11-02 08:24:21 +01:00
Ludovic Marie b32d66bd44 Merge pull request #6766 from MSedgeMC/chore/ledger-classes-01
🤖 Agent: queued at 7bc5a58464. I checked every row's target on main: all 6 files and functions exist. For example, `MinecartCommandBlock.defineSynchedData` points to `crates/horizon-client/src/game/minecarts.rs::command_due`. I moved `setRecipeUsed` to `horizon-client-render/src/containers.rs`, where `recipe_used_key` lives.
2026-11-02 08:24:17 +01:00
Ludovic Marie d0060040fd Merge pull request #6765 from MSedgeMC/chore/ledger-rest-005
🤖 Agent: queued at ab9cdd096e. I checked every row's target on main: all 5 files and functions exist. For example, `RemotePlayer.shouldRenderAtSqrDistance` points to `crates/horizon-client/src/game/entity_culling.rs::entity_view_scale`.
2026-11-02 08:24:13 +01:00
Ludovic Marie 5669d6b3c3 Merge pull request #6764 from MSedgeMC/chore/ledger-client2-001
🤖 Agent: queued at a3afe6b586. I checked every row's target on main: all 10 files and functions exist. For example, `CloudRenderer.renderOit` points to `crates/horizon-client-render/src/gfx/oit.rs`. I moved `performTransientBlockRemovals` to `game/transient_blocks.rs`, where `add_transient_block` lives.
2026-11-02 08:24:09 +01:00
Ludovic Marie b4a5b23a3a Merge pull request #6762 from MSedgeMC/chore/ledger-bonemeal
🤖 Agent: queued at 752aad9385. I checked every row's target on main: all 8 files and functions exist. For example, `UseBonemeal.checkExtraStartConditions` points to `crates/horizon-client/src/mobs/villager_brain.rs::B::try_start`.
2026-11-02 08:24:05 +01:00
Ludovic Marie ebe5ecd5a6 Merge pull request #6752 from MSedgeMC/chore/ledger-rest-004
🤖 Agent: queued at 263efa9370. I checked every row's target on main: all 10 files and functions exist. For example, `OptionInstance.SliderableEnum.next` points to `crates/horizon-client-render/src/options_ui.rs`.
2026-11-02 08:22:26 +01:00
Ludovic MarieandClaude Opus 5.5 e038e8d568 Point the ledger rows at the files that hold the code
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:21:05 +01:00
Ludovic MarieandClaude Opus 5.5 7bc5a58464 Point the ledger rows at the files that hold the code
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:20:31 +01:00
Ludovic MarieandClaude Opus 5.5 a3afe6b586 Point the ledger rows at the files that hold the code
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:19:43 +01:00
Ludovic Marie b2e71aca04 Merge pull request #6801 from Detimes/all-hooks
🤖 Agent: queued at 2e63a668e2: 152 hooks with their mod API contracts, the furnace tick without stack clones, and Dyna's 8 findings fixed.
2026-11-02 08:18:55 +01:00
MSedgeMC ce08e86518 Clear a game2-006 ledger row: a raider's wave and raid link are read back
Raider.readAdditionalSaveData still said missing, but mobs/raider.rs::read_nbt
reads PatrolLeader, Patrolling, Wave, CanJoinRaid and RaidId, the same fields
write_nbt saves. map-check on the file is clean.
2026-11-02 07:16:36 +00:00
Ludovic MarieandClaude Opus 5.5 2e63a668e2 Fix the eight hook issues from Dyna's review of #6801
- A cancelled book edit resends the server's book to the client.
- Snow filling a cauldron reports level 1, the level it places.
- fluid_level_change also sees (and can cancel) a flow drying up to 0.
- A cost changed by prepare_item_enchant shows that cost's clue.
- raid_spawn_wave's doc says it fires after the wave, without mounts.
- A cancelled item cooldown ends the guest's predicted cooldown.
- Anvil, grindstone and smithing hooks run only when a click changed
  the inputs, the result or the cost.
- A crafting result set by prepare_item_craft is sent to the client.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:16:29 +01:00
MSedgeMC 2aaac835bb Clear the rest-024 row for the world upgrader's chunk cache erase
The per-chunk upgrade already checks the chunk's position and drops its
heightmaps, light flag and stored light. The row said the erase was not in
Horizon's datafix.
2026-11-02 07:15:20 +00:00
MSedgeMC 631afe689c Clear six game-004 ledger rows: four main has, two class rows and a dangling bucket citation
ConversionTracker.readAdditionalSaveData is in entity_nbt.rs::mob_from_nbt
(the affliction time and a conversion started from the saved time), and both
DropChances rows too: mob_to_nbt writes only the chances that differ from
0.085, mob_from_nbt reads them back by slot.

Bucketable.loadDefaultDataFromBucketTag cited entity_nbt.rs::load_bucket_data,
which is apply_item_components now, and the two Display inner classes were
down as a fn verdict a class row cannot take; they are home in
game/text_displays.rs. map-check is clean afterwards.
2026-11-02 07:15:19 +00:00
Ludovic Marie 401bc1605d Merge pull request #6843 from horizon-rust/perf/atlas-cpu-copy
🤖 Agent: verified by pr_ready --quick on playhorizon (env-only failures explained in the body; render tests 985/985 single-threaded) and a 3-round A/B with byte-identical screenshots: the GUI atlas's 80 MB raw copy is kept lz4-compressed once uploaded (−47 MB RSS at the title).
2026-11-02 08:14:59 +01:00
Ludovic Marie 61db5653a4 Merge pull request #6743 from horizon-rust/perf/gen-light-bench
Bench generation and light throughput per worker count
2026-11-02 08:14:43 +01:00
MSedgeMC 09199b88ab Clear the rest-011 row for the entity hover tooltip
A show_entity hover builds its lines in chat.rs: the name when it has one, the
entity type, then the uuid. The row said show_entity hovers are dropped. The
two rows pointing at a renamed function are corrected too.
2026-11-02 07:14:38 +00:00
MSedgeMC b61060e387 Clear the rest-006 row for the rotation selector predicate
x_rotation and y_rotation are applied through rotation_in, which wraps both the
angles and the range and runs through 180 when the minimum is above the maximum.
2026-11-02 07:14:31 +00:00
MSedgeMC 55e1564629 Clear two rest-002 rows: the input predicate and the durability trigger
The player predicate's sneak input is read, and item_durability_changed fires
from an enchantment's loss and a mount's riding wear. The row that pinned the
input gap now says what is still missing instead.
2026-11-02 07:14:26 +00:00
MSedgeMC 5299166ad0 Baby striders and turtles: pin their shadow radius
The age-scaled radius was implemented but untested; a regression in the age
table or the renderer's extra factor would go unnoticed.
2026-11-02 07:12:35 +00:00
MSedgeMC 5cecb571ac Clear eleven game-008 ledger rows: the tag game, the trade display, three dangling citations
Eight rows still said missing for behaviour main has: the four
ShowTradesToPlayer methods (the villager holds up the offer the player's item
pays for and cycles every 40 ticks), the three PlayTagWithOtherKids methods
(a kid joins a chase of one with 1 to 5 chasers, and a chased kid runs inside
the village), all in villager_brain.rs.

Three rows cited functions that are gone or were not functions at all
(B::InteractWithDoor, LongJump::start's parenthetical, LaySpawn), so
map-check is clean afterwards.
2026-11-02 07:11:56 +00:00
MSedgeMC 81569ba550 Merge remote-tracking branch 'upstream/main' into fluid 2026-11-02 07:11:42 +00:00
MSedgeMC 7aefc5f447 Clear the rest-013 row for PlayerAdvancements.reload
/reload re-reads every loaded guest's advancement progress against the new
tree and resets its visible set before the tick advancements and reward recipes
are added. The row said the tree was kept for the session; it now names the
function and the half that is still not covered.
2026-11-02 07:11:19 +00:00
MSedgeMC 4e673a629e Clear two rest-021 rows: the selector codec and the session lock
A selector or nbt component is parsed when the component is decoded
(game/commands/text_commands.rs), and a world another copy has open is flagged
through its session.lock (save.rs::SessionLock::is_locked, used by title.rs).
2026-11-02 07:11:10 +00:00
MSedgeMC 80f5297499 port map: an unreadable player file is backed up before the save overwrites it
game2-023.tsv said a player file that fails to load is left in place, but
save.rs copies it to <uuid>_corrupted_<time>.dat first, as
PlayerDataStorage.backup does.
2026-11-02 07:09:05 +00:00
DUzzL 4cc1135dc1 Server thread: a world data pack's crafting recipe shows its result in the crafting table
The window showed the result of its own recipe table, which has no world data pack. It shows the result slot the server sets (CraftingMenu.slotChangedCraftingGrid, ClientboundContainerSetSlotPacket slot 0) now.
2026-11-02 08:08:22 +01:00
MSedgeMC 17a6c9527d Clear eight rest-018 rows: the server packet listener's handlers exist
SET_COMMAND_MINECART, SET_STRUCTURE_BLOCK, TEST_INSTANCE_BLOCK_ACTION and
SPECTATOR_ACTION have handlers, chat spam is throttled, the flying kick's tick
limit is computed, and the build-limit trigger and its air test are there. The
eight rows that said they were missing now name them.
2026-11-02 07:08:04 +00:00
MSedgeMC f0649477b6 port map: a structure block keeps its author, as setPlacedBy writes it
game2-016.tsv said the placing player is not stored as the structure
block's author, but structure_block_placed does exactly that.
2026-11-02 07:07:56 +00:00
Ludovic MarieandClaude Opus 5.5 3552e9d1a4 Merge main into #6801
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:07:46 +01:00
MSedgeMC be131063a0 Port map: the options pages and the skin layers are ported 2026-11-02 07:07:27 +00:00
Ludovic MarieandClaude Opus 5.5 6990936686 Name the tab-complete hook's Suggestion through mod_api
Generated mod API code is also compiled by xtask's test, which cannot see
horizon_command; mod_api now re-exports Suggestion and the test stubs it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:07:15 +01:00
MSedgeMC f487821eff Server list: Enter joins the selected server
Vanilla's ServerSelectionList.OnlineServerEntry.keyPressed joins the selected
entry on a selection key; Horizon's confirm only handled the direct-connect
address box, so Enter on the list did nothing.
2026-11-02 07:06:32 +00:00
Ludovic Marie 7e6cbea1a3 Merge pull request #6851 from horizon-rust/perf/entities-summon-stall
🤖 Agent: a call timeout now logs the server's state and no longer reads as a stop; the stall didn't reproduce (27k summons, slowest 0.32 s); fmt and clippy clean.
2026-11-02 08:06:28 +01:00
MSedgeMC 5ec019ddeb port map: scaffolding, snow shade and the sulfur trigger are ported
game2-015.tsv said the scaffolding block has no tick arm, that snow layers
have no shade brightness lookup, and that geysers never erupt. All three are
in the tree.
2026-11-02 07:06:22 +00:00
t d1a5b12246 Leave tellNeutralMobsThatIDied to the open PR that already has it
That row is in an open pull request of its own, and two edits to the same
line keep both branches from merging; the other six rows here are unique.
2026-11-02 07:06:13 +00:00
MSedgeMC df471629cf Clear seventeen game-011 ledger rows: ten main has, seven dangling
Ten rows still said missing for behaviour main has: the skeleton horse's
water jump sound (game/mounts.rs), the two ocelot goals (mobs/tame.rs), the
five schooling-fish methods and the fish tick (mobs/aquatic.rs), and the
pufferfish's PuffState read (entity_nbt.rs).

Seven rows pointed at entity_nbt.rs::load_bucket_data and
Mob::cause_fall_damage, neither of which exists: the bucket components are
apply_item_components and bucket_components now, the horse and llama fall
damage is Mob::cause_fall_damage_at. map-check is clean afterwards.
2026-11-02 07:05:50 +00:00
Ludovic MarieandClaude Opus 5.5 5b0fd8d4f5 Tell a busy server thread from a stopped one
IntegratedServer::call waits 10 s for the server thread's answer, and both
a stopped server and a slow one gave "the integrated server stopped". A
perf_bench run failed with it in the middle of 2000 summons and the log
could not say which. A timeout now logs a warning with the server's state
(running, autosaving, smoothed tick time), and the callers' error says the
server stopped or did not answer within 10 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:04:30 +01:00
MSedgeMC 1c70bc70b8 port map: the geyser and the container loot components are ported
classes-04.tsv called seven PotentSulfurBlockEntity helpers missing ("helper
of the geyser gas, not ported", "the eruption timer is not loaded") and said
a chest item neither hands its loot table to the block nor takes it back.
The tree ports all nine.
2026-11-02 07:04:21 +00:00
MSedgeMC e77404a1f0 Despawn an untamed cat or ocelot once it is 2400 ticks old
Cat.removeWhenFarAway is `!isTame() && tickCount > 2400` and
Ocelot.removeWhenFarAway the same on isTrusting. Horizon kept every animal
(Animal.removeWhenFarAway returns false) and read the ocelot's rule off
AgeableMob.age, which an adult keeps at 0, so neither feline ever went for
distance.

feline_removed_when_far holds the two overrides and check_despawn reads it on
Entity.tickCount.
2026-11-02 07:01:18 +00:00
Ludovic MarieandClaude Opus 5.5 fd73f00940 Single player: the window shares the server thread's sections
The integrated server and its window each held every column both see:
the server its level, the window what the chunk packets decoded to, with
the same blocks, biomes and light. Sending a column on its memory
connection, the server offers the column's sections as Weak references
(lan/handoff.rs); the window's decoder takes each one equal to what it
decoded and drops its own copy. Edits on either side copy the section for
that side (Arc::make_mut). PalettedContainer equality compares the layout
first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:00:53 +01:00
Ludovic MarieandClaude Opus 5.5 714644fac0 Sections keep only the kind of light that varies
A section's packed light was one value or 4,096 bytes. Most lit sections
have no block light (above ground) or no sky light (below), so Light gets
Sky and Block forms that keep that layer's nibbles (2,048 bytes, as a
vanilla DataLayer) and one value of the other; compact() picks the form.
The server's chunk encoder writes its DataLayers straight from a
section's light instead of caching two 2,048-byte layers per section.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 08:00:53 +01:00
Ludovic Marie b2258cbf68 Merge pull request #6745 from horizon-rust/perf/recipes-read-once-2
🤖 Agent: verify and launcher green on this exact head; recipe/loot harnesses 8/8 on playhorizon. One risk checked: loot::load_dir now reads a whole folder before parsing; json_folder takes no lock during the read, and server.open_world did not slow in interleaved runs.
2026-11-02 08:00:30 +01:00
MSedgeMC c6c858aadb port map: five classes-08 rows main already ports
Vanilla files a map's banner marker under `MapBanner.getId()`'s key, reads a
world's `DataPacks` and `enabled_features` back with the level, and builds a
world-list row's status text, playback gate and backup status from the
level summary. classes-08.tsv called all five missing or pointed two of them
at the wrong function.
2026-11-02 06:59:41 +00:00
MSedgeMC b31fcfad5d Clear three rest-017 rows: the code of conduct, the disk warning and the slider label
The server reads the codeofconduct folder and tells operators about low disk
space, and a dialog slider's text is its label_format with the value. The three
rows that said these were missing now name them.
2026-11-02 06:59:31 +00:00
MSedgeMC 001ed6987e Correct five more client-004 rows, all implemented in the mob draw path
The golem's walk tilt, the pufferfish bob and the salmon, tropical fish and squid body rotations
are in mobs/render.rs::base_transform_at and mobs/aquatic.rs::rotations.
2026-11-02 06:59:23 +00:00
Ludovic MarieandClaude Opus 5.5 9c3dc6bdbd Mod API entries and dispatch calls for #6801's hooks
Main requires a mod_api entry for every pending fragment since #6253;
cargo run -p xtask -- mod-api filled the dispatch calls. The two world
border hooks take the placeholder body the generator fills.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:58:23 +01:00
MSedgeMC 41d82fca71 Port map: client2-012 rows the tree already ports
Eight rows still said missing: the Now Playing toast, the Music Toast setting,
and the chunk load and save failure toasts.
2026-11-02 06:58:08 +00:00
Ludovic MarieandClaude Opus 5.5 99f043fd64 horizon-ctl commands reference: the memory command
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:57:45 +01:00
Ludovic MarieandClaude Opus 5.5 7861eb20b2 horizon-ctl memory: the game's memory by category
The resident set and mimalloc's committed bytes, the client's and the
integrated server's columns by part (blocks, biomes, light, heightmaps,
records; shared sections once), the renderer's per-section bookkeeping and
the GPU allocator's resources by label. A system-allocator feature routes
Rust's allocations to malloc for heaptrack and massif.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:57:39 +01:00
MSedgeMC 7676138acf Clear seven rest-015 rows: /op, /pardon, /pardon-ip and /fetchprofile exist
The op and pardon arms live in game/commands/profiles.rs and /fetchprofile in
game/commands/extra.rs, all reached from the dispatcher in game/commands.rs.
The seven rows that said they were missing now name them.
2026-11-02 06:57:29 +00:00
MSedgeMC 83c0f67e9e Port map: client2-011 rows the tree already ports
Twenty-seven rows still said missing: word selection on a book page, list
arrow keys, the title's splash and notice buttons, and the F3 biome, chunk,
light, heightmap, looking-at, speed, effects, sounds and spawn lines.
2026-11-02 06:57:21 +00:00
MSedgeMC 80cc16e412 port map: the flat and debug generators are ported, not missing
levelgen-1.tsv called DebugLevelSource, FlatLevelSource.createState,
getBaseHeight and getBaseColumn missing, and said specialWorldProperty had
no debug world to report. The tree ports all of them: debugworld.rs is the
debug grid, flatgen.rs reads a flat world's layers and structure sets,
generator.rs answers the base height and column from those layers, and
save.rs reads the preset back.
2026-11-02 06:57:03 +00:00
MSedgeMC 1166472c8e Clear six rest-014 rows: the ban, deop and datapack commands exist
/ban, /ban-ip, /banlist, /pardon, /deop, /op and /pardon-ip are dispatched to
game/commands/profiles.rs on a dedicated server, as vanilla's DEDICATED
selection does, and /datapack create writes pack.mcmeta, data/ and the pack
folder. The six rows that said they were missing now name them.
2026-11-02 06:56:56 +00:00
t 30d5b97529 wip: rest-012 rows 2026-11-02 06:56:45 +00:00
MSedgeMC 8324aabac2 Port map: client-008 rows the tree already ports
Eleven rows still said missing: block-model tints, a special block model,
bounding-box and invisible-block markers, the brushable block's item, the shelf
items, and a test instance's error marker.
2026-11-02 06:56:38 +00:00
MSedgeMC d4ed651eda Breathe the dragon-breath fans for the fight's own client
DragonLandingPhase.doClientTick and DragonSittingFlamingPhase.doClientTick
drew their fans only on a server's client (remote_dragon_phase_tick). The
host's own fight never ran the phase's client tick, so a single player saw
no breath at all while the dragon came down or flamed: the port map still
had both rows down as missing.

breath_puffs is the fan both paths now share: eight puffs a tick at the head
(getY(0.5)) jittered by nextGaussian() / 2 during the landing, and on flame
ticks 2, 4, 6 and 8 eight starts of six puffs down the look line, the
vertical speed -look.y * 0.6F once. Vanilla turns the look vector by a
quarter and throws the result away, so every puff leaves along the same line.
The host draws its jitter from the world's random: the dragon's own stream is
the fight's, and vanilla's client copy never draws on the server copy's.
2026-11-02 06:55:39 +00:00
Ludovic Marie aa60eb76ea Merge pull request #6528 from Detimes/feat-player-totem-hook
🤖 Agent: queued at 821b9c45e. I stacked Detimes' hook PRs in landing order (this branch holds the ones before it), added the mod_api entries main needs since #6253 and ran `cargo run -p xtask -- mod-api`. On the Mac: clippy -D warnings, xtask mod_api tests, native_hooks tests and Python tests pass.
2026-11-02 07:55:15 +01:00
MSedgeMC fc27217c05 Port map: client-003 rows the tree already ports
Twelve rows still said missing: legacy server pings, the chat trust levels and
their tags and icon, the cod's swim and flop rotations, and block and item
display states and geometry.
2026-11-02 06:55:11 +00:00
Ludovic Marie a261703d72 Merge pull request #6653 from Detimes/feat-tnt-hook
🤖 Agent: queued at 42b2cd1d3. I stacked Detimes' hook PRs in landing order (this branch holds the ones before it), added the mod_api entries main needs since #6253 and ran `cargo run -p xtask -- mod-api`. On the Mac: clippy -D warnings, xtask mod_api tests, native_hooks tests and Python tests pass.
2026-11-02 07:55:10 +01:00
Ludovic Marie 448899aa04 Merge pull request #6635 from Detimes/feat-protection-hooks
🤖 Agent: queued at 7b0c5e3bf. I stacked Detimes' hook PRs in landing order (this branch holds the ones before it), added the mod_api entries main needs since #6253 and ran `cargo run -p xtask -- mod-api`. On the Mac: clippy -D warnings, xtask mod_api tests, native_hooks tests and Python tests pass.
2026-11-02 07:55:07 +01:00
Ludovic Marie 12ce2752a0 Merge pull request #6624 from Detimes/batch-misc
🤖 Agent: queued at 91acd7626. I stacked Detimes' hook PRs in landing order (this branch holds the ones before it), added the mod_api entries main needs since #6253 and ran `cargo run -p xtask -- mod-api`. On the Mac: clippy -D warnings, xtask mod_api tests, native_hooks tests and Python tests pass.
2026-11-02 07:55:03 +01:00
MSedgeMC 3078860812 Clear seven game-002 ledger rows main already implements
Each of these `tools/port_map/game-002.tsv` rows said a `ServerPlayer` method
was missing while current main has it; the row now names the function and what
it does.
2026-11-02 06:54:54 +00:00
Ludovic Marie 922ddb2551 Merge #6801x into the hook stack
# Conflicts:
#	crates/horizon-client/src/game/lan_actions.rs
#	docs/native-mods.md
2026-11-02 07:53:54 +01:00
Ludovic Marie bbdffc6584 Merge pull request #6623 from Detimes/feat-players-connection-hooks
🤖 Agent: queued at 532339112. I stacked Detimes' hook PRs in landing order (this branch holds the ones before it), added the mod_api entries main needs since #6253 and ran `cargo run -p xtask -- mod-api`. On the Mac: clippy -D warnings, xtask mod_api tests, native_hooks tests and Python tests pass.
2026-11-02 07:53:46 +01:00
Ludovic Marie 29aebd155c Merge pull request #6547 from Detimes/feat-player-teleported-hook
🤖 Agent: queued at 59823928d. I stacked Detimes' hook PRs in landing order (this branch holds the ones before it), added the mod_api entries main needs since #6253 and ran `cargo run -p xtask -- mod-api`. On the Mac: clippy -D warnings, xtask mod_api tests, native_hooks tests and Python tests pass.
2026-11-02 07:53:42 +01:00
Ludovic Marie 3c37ecbaba Merge pull request #6521 from Detimes/feat-food-level-hook
🤖 Agent: queued at 6d9f9189a. I stacked Detimes' hook PRs in landing order (this branch holds the ones before it), added the mod_api entries main needs since #6253 and ran `cargo run -p xtask -- mod-api`. On the Mac: clippy -D warnings, xtask mod_api tests, native_hooks tests and Python tests pass.
2026-11-02 07:53:38 +01:00
Ludovic Marie 3354e5a9d5 Merge pull request #6520 from Detimes/feat-player-swap-hands-hook
🤖 Agent: queued at 509a5e82c. I stacked Detimes' hook PRs in landing order (this branch holds the ones before it), added the mod_api entries main needs since #6253 and ran `cargo run -p xtask -- mod-api`. On the Mac: clippy -D warnings, xtask mod_api tests, native_hooks tests and Python tests pass.
2026-11-02 07:53:34 +01:00
MSedgeMC 6b2cf80911 Port map: client2-017 rows the tree already ports
Six rows still said missing: the bundle tooltip's selected-item name box, the
server list's F5 refresh, an entry's narration, Ctrl+wheel on the Video page,
the data pack folder watcher and a dropped pack file.
2026-11-02 06:52:42 +00:00
MSedgeMC 99deb65138 Sound broadcasts leave out a guest exactly on the radius
PlayerList.broadcast compares the squared distances with `<`, so vanilla's sound
packet does not reach a player standing exactly at SoundEvent.getRange(volume).
Hub::broadcast_near used `<=`; the boundary is now the same strict test, in a
helper the unit test can call.

The rest-020 row for SoundEvent.getRange said missing: it is folded into that
broadcast, and no 26.3 sound event has a fixed range, so 16 * max(volume, 1) is
the whole formula.
2026-11-02 06:52:05 +00:00
MSedgeMC 2f7bdf7b23 Clear three renderer rows client-004 calls missing
The phantom's size scale and the baby strider and turtle shadow radii are all in Horizon;
the rows said missing.
2026-11-02 06:51:32 +00:00
Ludovic MarieandClaude Opus 5.5 821b9c45e5 Mod API entries and dispatch calls for #6528's hooks
Main requires a mod_api entry for every pending fragment since #6253;
cargo run -p xtask -- mod-api filled the dispatch calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:50:04 +01:00
Ludovic Marie ba923b393d Merge #6528 into the hook stack
# Conflicts:
#	crates/horizon-client/src/mod_api/generated.rs
2026-11-02 07:49:54 +01:00
MSedgeMC eefd16a005 Clear five ledger rows rest-003 calls missing
IME preedit, arrow-key slider editing and file drops are all in Horizon; the rows still said
missing.
2026-11-02 06:42:36 +00:00
VaspyyyandClaude Opus 5.5 06582aea2b Launch into a world: start its data preload once the biomes are loaded
preload_launch_world needs the block registry and the biomes, but it
started after the whole core stage: the block models and sprites and the
block facts, ~250 ms later. The world's open then waited for the preload
(the worldgen bundle and the decoration). With the game's own resources
(no resource pack, which a failure would reload core without), the
preload now starts on core's biomes worker as soon as they are read.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-11-02 07:42:20 +01:00
Ludovic MarieandClaude Opus 5.5 42b2cd1d3f Mod API entries and dispatch calls for #6653's hooks
Main requires a mod_api entry for every pending fragment since #6253;
cargo run -p xtask -- mod-api filled the dispatch calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:42:13 +01:00
Ludovic Marie e78c83330a Merge #6653 into the hook stack
# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/mod_api/generated.rs
#	docs/native-mods.md
2026-11-02 07:42:02 +01:00
Ludovic MarieandClaude Opus 5.5 7b0c5e3bf8 Mod API entries and dispatch calls for #6635's hooks
Main requires a mod_api entry for every pending fragment since #6253;
cargo run -p xtask -- mod-api filled the dispatch calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:42:01 +01:00
Ludovic Marie cd73a264c2 Merge #6635 into the hook stack
# Conflicts:
#	docs/native-mods.md
2026-11-02 07:41:55 +01:00
Ludovic MarieandClaude Opus 5.5 91acd76265 Mod API entries and dispatch calls for #6624's hooks
Main requires a mod_api entry for every pending fragment since #6253;
cargo run -p xtask -- mod-api filled the dispatch calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:41:54 +01:00
Ludovic MarieandClaude Opus 5.5 532339112f Mod API entries and dispatch calls for #6623's hooks
Main requires a mod_api entry for every pending fragment since #6253;
cargo run -p xtask -- mod-api filled the dispatch calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:41:46 +01:00
Ludovic Marie 2bc228cfd0 Merge #6624 into the hook stack 2026-11-02 07:41:46 +01:00
Ludovic Marie ccda6631b3 Merge #6623 into the hook stack
# Conflicts:
#	docs/native-mods.md
2026-11-02 07:41:37 +01:00
Ludovic MarieandClaude Opus 5.5 59823928da Mod API entries and dispatch calls for #6547's hooks
Main requires a mod_api entry for every pending fragment since #6253;
cargo run -p xtask -- mod-api filled the dispatch calls.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:41:36 +01:00
Ludovic Marie e3274bed46 Merge #6547 into the hook stack 2026-11-02 07:41:30 +01:00
ClickingMasterandClaude Sonnet 5.5 36a4682121 A creative or spectator player is never a mob's target
Mob.getTarget/setTarget run the target through asValidTarget, so a creative
or spectator player reads as no target however it was set (an alert from
another zombified piglin included). Horizon read ai.target directly and
kept chasing. Drop it at the start of the mob's tick; a zombified piglin or
iron golem angry at that player stops being angry (NeutralMob).

Part of #6790

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 22:41:29 -08:00
Ludovic Marie 4c908814b1 Merge pull request #6818 from horizon-rust/fix/windres-space-path
Native mod builds: no space in the Windows build folder
2026-11-02 07:41:28 +01:00
Ludovic Marie 6d9f9189a7 Merge #6521 into the hook stack 2026-11-02 07:41:23 +01:00
Ludovic Marie 509a5e82c5 Merge #6520 into the hook stack 2026-11-02 07:41:16 +01:00
DetimesandClaude Sonnet 5.5 63eaabce49 Mod API: mod_api contracts for this PR's hooks, regenerated (upstream #6253 requires them)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 06:40:05 +00:00
martipopsandClaude Opus 5.5 ab8be4adc3 Merge upstream main (Setup PR merged) into mouse controls
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:39:57 -05:00
Detimes da0403f293 Merge remote-tracking branch 'upstream/main' into feat-player-totem-hook 2026-11-02 06:39:39 +00:00
Ludovic Marie c9343d6d72 Merge pull request #6395 from martipops/split/fresh-basic-setup
🤖 Agent: queued at afe39ff13 (Mac: clippy on main + 9 PRs, Python tests).
2026-11-02 07:39:09 +01:00
DetimesandClaude Sonnet 5.5 13539c30a7 Mod API: mod_api contracts for this PR's hooks, regenerated (upstream #6253 requires them)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 06:35:05 +00:00
Detimes 4c88a971c4 Merge remote-tracking branch 'upstream/main' into feat-food-level-hook 2026-11-02 06:34:39 +00:00
VaspyyyandClaude Opus 5.5 cbaf3287f6 Worldgen: build the rotation table without cloning every block state six times
StateProviderBindings::rotations' table (a coral feature's decode builds
it, while the world's decoration is prepared) cloned each of the
registry's ~30k states once per direction and looked each clone up by
hash, under a BTreeMap keyed by cloned block names: ~230 ms of the world
data preload, on the path to the world's open. A state with no full axis
or facing family now rotates to itself without a clone, as does a
rotation that sets the value the state already has; the map borrows its
keys. The table is the same.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-11-02 07:34:03 +01:00
Ludovic MarieandClaude Opus 5.5 ceb57a75f3 Furnace hooks: read ids before the tick instead of cloning three stacks
tick_furnace cloned the fuel, input and result stacks of every furnace on
every tick, with no mod loaded (their components' Box included). It now keeps
the fuel's id only while the furnace is out, the input's id and the result
count, and hands the hooks the ids as they are.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:33:20 +01:00
JoansitohandClaude Sonnet 5.5 4a5a130c2a Fix dragon breath damage: cloud scale, box and origin
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 07:31:51 +01:00
Ludovic Marie 5197790e58 Merge pull request #6802 from horizon-rust/perf/mesh-review-nits
🤖 Agent: queued at 6ce752baa8: verify green, pr_ready green (render tests single-threaded on llvmpipe). One point worth a look: Chunk::light now goes through section_at, whose Err carries the out-of-column light (0 below, 0xF0 above), and the new padded_fill test checks every padded cell against Chunk::get/light for those edges.
2026-11-02 07:31:16 +01:00
DetimesandClaude Sonnet 5.5 838324f811 Mod API: mod_api contracts for this PR's hooks, regenerated (upstream #6253 requires them)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 06:30:15 +00:00
Detimes 18dba0563a Merge remote-tracking branch 'upstream/main' into feat-player-swap-hands-hook 2026-11-02 06:29:50 +00:00
DUzzL 57627e6fce Mobs walking into a minecart push it 2026-11-02 07:28:42 +01:00
SenjinandClaude Opus 5.5 a7125b5ace HDR: water gets the lighting
The translucent pass blends its alpha as coverage, which diluted a lake's light beside the
lit shore. A second pass over translucent terrain writes it for mostly opaque faces; clear
glass leaves what's behind it alone. Only with HDR lighting on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:27:27 +01:00
Ludovic Marie 848a87b731 Merge pull request #6817 from horizon-rust/perf/bench-gpu-passes
🤖 Agent: ready. The benchmark's 2 s wait before each flight now times the render passes, and the timing is off again before the flight, so FPS and gpu_p50 stay comparable with older reports. Metal is skipped because its passes overlap. pr_ready green on playhorizon (1377 client-lib tests); e2e report checked on an M4 Pro.
2026-11-02 07:27:02 +01:00
DetimesandClaude Sonnet 5.5 8b895aca0f Mod API: mod_api contracts for this PR's hooks, regenerated (upstream #6253 requires them)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 06:26:14 +00:00
Ludovic MarieandClaude Opus 5.5 4ed42694bc Native mod builds: no space in the Windows build folder
A Windows user name with a space (C:\Users\Jane Doe\AppData\Roaming)
broke every native mod build: windres passes its include folders to the
preprocessor unquoted, which splits them at the space, and
horizon-client's build script panicked on the failed resource compile.

- launcher: build_dir moves a path with a space to the ProgramData build
  folder, as it already does for a non-ASCII one (#5189, #6009).
- horizon-client build.rs: outside CI, a failed icon embed is a warning,
  so players on a launcher without the fix can still build. Release
  builds run in CI, where the icon stays required.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 07:25:40 +01:00
Ludovic Marie d8845075d2 Benchmark report: GPU time per render pass before each flight 2026-11-02 07:24:48 +01:00
Arslan ArmanulyandClaude Opus 5.5 8c924cd4f2 Shader packs: a function prototype declared again translates
GLSL lets a function be declared any number of times (GLSL 4.60 6.1); naga's
frontend refuses a second prototype with "Prototype already defined", which
skipped Bloop's deferred10 and its entity and water programs (the same
helper's prototype comes in from two included files). The prototype naga
refuses turns to spaces, lines kept, and the source is parsed again, like the
other refusals the translator rewrites.

Fixes #6810

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:24:37 +05:00
Detimes 03df981326 Merge remote-tracking branch 'upstream/main' into feat-tnt-hook 2026-11-02 06:24:29 +00:00
levzzz 676746bce5 Merge pull request #6253 from arismoko/v2/ses_01M6C18WAJKS3HWAKR2EKX5REX-u9b
Mod API: trait entries for pending hook fragments, item-use and menu slot-rule hooks
2026-11-02 11:22:57 +05:00
Arslan ArmanulyandClaude Opus 5.5 3df1f09d35 Shader packs: a function reaching its end without a return returns zero
GLSL lets a function that returns a value reach its closing brace without a
`return` (the value is undefined, GLSL 4.60 6.1). naga's frontend ends such a
body with a `return` of no value, which its validator refuses, so the program
was skipped (Lux's composite5, RedHat's composite). Those returns now give the
result type's zero.

Fixes #6809

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 11:20:37 +05:00
abit-foggy da1fca6201 Renderer: depthtex2 before hand and default flat normals/specular 2026-11-01 20:18:47 -10:00
Ludovic Marie a7df9ec019 Merge pull request #6808 from horizon-rust/perf/entities-stress
🤖 Agent: stress scenes for perf_bench (Horizon and vanilla); Horizon beats vanilla x2.2 (armor stands) to x10.8 (signs) on the M4 Pro; Python tests 607 ok, the bench mod compiles against 26.3.
2026-11-02 07:15:49 +01:00
Ludovic Marie 76aa871c1e Merge remote-tracking branch 'origin/main' into perf/entities-stress
# Conflicts:
#	mods/horizon/src/bench/java/dev/horizonlab/bench/VanillaBench.java
#	tools/perf_bench.py
2026-11-02 07:10:57 +01:00
abit-foggyandAntigravity <gemini-2.5-pro> cb38e971c6 fix(hud): linearly sample credits vignette to prevent flickering
The credits vignette (misc/credits_vignette) specifies blur in its mcmeta
and receives a wrapped border when packed into HudAtlas. Previously, the
HUD vignette pipeline reused generic fs_main with the nearest-neighbor
atlas sampler, causing subpixel rasterization jitter and flickering
during credits and attribution screens.

Add linear_sampler to the HUD bind group and a dedicated fs_vignette
fragment shader entry point to bilinearly sample the vignette texture.

Co-Authored-By: Antigravity <gemini-2.5-pro> <[email protected]>
2026-11-01 20:09:53 -10:00
Simon Dräger 4d0c0fb6d1 Server: /mute, /tempmute and /mutelist for ops 2026-11-01 22:07:56 -08:00
Ludovic MarieandClaude Opus 5.5 f89a39479e Bench generation and light throughput per worker count
examples/gen_light_bench.rs runs the columns around a point through the
whole streaming pipeline (proto, decoration, final column, light) once per
worker count, and prints columns finished and lit per second, CPU per
column, the coordinator's CPU, and the workers' CPU by job kind
(streaming::job_stats, off unless enabled). The shared worker pool is sized
once per process, so each worker count runs in a child process with
HORIZON_JOB_THREADS set. A checksum of blocks and light shows that builds
and worker counts make the same world.
--simulate PROTO,DECORATE,FINISH swaps in a generator whose jobs sleep:
what is left is the pipeline itself, which shows its scaling to many
workers even on a busy machine.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 06:59:28 +01:00
DetimesandClaude Opus 5.5 1e3059a184 Fragments: drop mod_api (it comes with the pending-fragment PR #6253)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:56:39 +00:00
DetimesandClaude Opus 5.5 1adabb388f Fragments: drop mod_api (it comes with the pending-fragment PR #6253)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:53:42 +00:00
DetimesandClaude Opus 5.5 b9829e9cfa Fragments: drop mod_api (it comes with the pending-fragment PR #6253)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:50:44 +00:00
DetimesandClaude Opus 5.5 5a50067056 Fragment: the totem hook only listens, so no NeoForge counterpart
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:49:44 +00:00
DetimesandClaude Opus 5.5 f567c765e9 Fragments: drop mod_api (it comes with the pending-fragment PR #6253)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:45:26 +00:00
Detimes 2dc81db24b Merge remote-tracking branch 'upstream/main' into feat-tnt-hook 2026-11-02 05:44:37 +00:00
Detimes 2e037e4cb6 Merge remote-tracking branch 'upstream/main' into feat-protection-hooks 2026-11-02 05:44:36 +00:00
Detimes 2d6f2722d8 Merge remote-tracking branch 'upstream/main' into batch-misc 2026-11-02 05:44:36 +00:00
Detimes 005824bd0d Merge remote-tracking branch 'upstream/main' into feat-players-connection-hooks 2026-11-02 05:44:35 +00:00
Ludovic Marie 6ce752baa8 Merge remote-tracking branch 'origin/main' into perf/mesh-review-nits 2026-11-02 06:43:21 +01:00
Ludovic Marie 3a264943c7 Merge pull request #6672 from horizon-rust/perf/mesh-tint-uniform
Mesher: one-biome sections skip the tint blend
2026-11-02 06:43:12 +01:00
Detimes 6e3d4215c7 Merge remote-tracking branch 'upstream/main' into feat-player-teleported-hook 2026-11-02 05:40:58 +00:00
Detimes 0e873b88ff Merge remote-tracking branch 'upstream/main' into feat-food-level-hook 2026-11-02 05:40:57 +00:00
Detimes 8f7abd9070 Merge remote-tracking branch 'upstream/main' into feat-player-totem-hook 2026-11-02 05:40:57 +00:00
Detimes 519ce906cf Merge remote-tracking branch 'upstream/main' into feat-player-swap-hands-hook 2026-11-02 05:40:56 +00:00
DetimesandClaude Sonnet 5.5 5c096a19f1 Native mods: clippy fixes (collapsed if, redundant cast)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 05:35:41 +00:00
DUzzL 191cc8cdbc Seat the second camel passenger behind the first 2026-11-02 06:33:28 +01:00
DetimesandClaude Sonnet 5.5 04ad641de1 Native mods: fragment and docs for native_world_border_center_changed
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 05:27:33 +00:00
Detimes 46ccb75b31 Merge remote-tracking branch 'upstream/main' into paper-parity 2026-11-02 05:24:47 +00:00
Ludovic Marie 5afa1de5f7 Merge pull request #6744 from horizon-rust/perf/gen-coordinator
🤖 Agent: perf-gen-light verified this head: pr_ready --quick green, chunk_generation/decoration/server_world_gen/worldgen pass on playhorizon, generation order unchanged (differential test); coordinator CPU -30% at RD 32.
2026-11-02 06:21:15 +01:00
Ludovic Marie 40e044da77 Merge pull request #6757 from horizon-rust/perf/entity-gpu-skin
🤖 Agent: mobs posed on the GPU, byte-identical to the CPU path for every mob type; stress_mobs frame CPU 4.52 → 3.26 ms; verify passed.
2026-11-02 06:19:04 +01:00
ClickingMasterandClaude Sonnet 5.5 6330a192d6 Iron golem stays on the guest it is angry at, not the nearest player
player_for kept a mob on its bound guest only while it targeted them or
was hurt by them in the last 100 ticks. A golem's anger lasts 400-780
ticks, so once the attacker ran off, the golem fell back to the nearest
player and its anger goal attacked that bystander.

Fixes #6781

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 21:15:40 -08:00
MSedgeMC f8741e7eee A golem's or a fox's lead hangs where vanilla puts it
`IronGolem`, `SnowGolem`, `CopperGolem` and `Fox.getLeashOffset` use their
own height, not the default: 0.875, 0.75, 0.75 and 0.55 of the eye height,
and the copper golem's rope hangs on the centre rather than `bbWidth / 2`
in front. `leash::leash_offset` knew the llama, wolf, allay, camel and bee
and gave these four the default, so their leads were drawn too high.

Corrects the four ledger rows in game-012.tsv.
2026-11-02 05:10:44 +00:00
Ludovic Marie e7275ddee3 Merge pull request #6756 from horizon-rust/perf/entity-skin-pass
🤖 Agent: renderer half of GPU posing, unused until #6757; shader output byte-identical to the CPU reference on a real adapter; verify passed.
2026-11-02 06:09:56 +01:00
Ludovic Marie f7b73ed5f6 Merge pull request #6755 from horizon-rust/perf/remote-mob-index
🤖 Agent: mob lookups by id go through a validated id → slot map; 3000 mobs: 5.5 ms → 0.0125 ms per tick of searching; verify passed.
2026-11-02 06:09:52 +01:00
MSedgeMC 5059bab94d Clear eight ledger rows: the create-world data pack picker exists
The Data Packs screen, its temp folder, the chosen packs reaching worldgen and Re-Create
reopening them are all in Horizon; the rows said the button was disabled and there was no picker.
2026-11-02 05:06:25 +00:00
DetimesandClaude Sonnet 5.5 fd0b82d307 Native mods: player_portal cancel takes effect
A canceled Nether portal trip kept its finished process and asked the hook again every tick; the process is cleared now.

Written by Codex (cancel audit), reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 05:06:15 +00:00
DetimesandClaude Sonnet 5.5 63f21e9a10 Native mods: hopper pickup hook fires on the item-entity path
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 05:05:45 +00:00
some-du6eandClaude Opus 5.5 955a5059e9 Buckets: say where Shift stops counting
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:05:19 -06:00
Ludovic Marie 1c2ed6b613 Merge pull request #6535 from horizon-rust/perf/mesh-faces
Mesher: 15% less CPU per section again (face shortcut, bit-row VisGraph), identical meshes
2026-11-02 06:02:47 +01:00
MSedgeMC 6e2790907c port map: game-012.tsv rows whose fixes already landed
The fox pickup (fox.rs::can_hold_item, game/foxes.rs::fox_pick_up), the
frog's long jump and frogspawn goals (frog.rs::register,
frog.rs::acceptable_landing) and the tadpole's bucket load
(entity_nbt.rs::apply_item_components) are in the tree, but their rows
still said missing; the tadpole row also named a function that does not
exist, which `map-check` reported as a dangling row.
2026-11-02 05:01:33 +00:00
abit-foggy 2672ae6e99 Shaders: test early returns without trailing returns 2026-11-01 19:00:21 -10:00
abit-foggy 7400263e00 Shaders: append typed return for functions returning early 2026-11-01 19:00:21 -10:00
abit-foggy 1d0bd74d20 Shader packs: support separateAo in shaders.properties and mesher
- Add separate_ao detection from shaders.properties in shader_packs.rs
- Wire separate_ao through MeshSettings, Streaming, and Mesher
- When separate_ao is enabled, pass un-occluded pure tint in vertex RGB
  and pack ambient occlusion factor into vertex color alpha
- Pass vertex alpha through to hz_Color.a in PackAtlas when separate_ao is enabled
- Add unit tests verifying separate_ao vertex color packing and atlas inputs
2026-11-01 19:00:21 -10:00
some-du6eandClaude Opus 5.5 cbd7a3bc9b Buckets: aim past a block that refuses the water, as canBlockContainFluid does
`BucketItem.use` targets the clicked block only when it is a liquid
container that `canPlaceLiquid`. A double slab, kelp, seagrass or a
survival barrier send the bucket to the block beside from the start, so
a fish bucket's mob spawns in the poured water and kelp no longer
swallows the click. `canPlaceLiquid` moves to `Behaviours` so the game
can ask it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:00:16 -06:00
some-du6eandClaude Opus 5.5 74e6d76034 Buckets: one emptying entry point, predict water poured beside into containers
Review round 1 on #6783:
- `Behaviours::bucket_empty` takes `beside: Option<BlockPos>` and returns
  where the contents went; Shift only counts with a hit result, as in
  vanilla's `!shiftKeyDown || hitResult == null`.
- The client leaves waterlogging to the server wherever the water lands,
  not only on the clicked block.
- `Hit::beside` replaces three copies of `pos + face`.
- The click cases get their own test, with a case where both blocks refuse.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:56:37 -06:00
ClickingMasterandClaude Sonnet 5.5 9460d096a8 lan_e2e fishing-tnt: assert the pull by sign, not size
Ground friction decays the pull by the time the server answers, so its size
varies run to run; an unhooked TNT reads exactly 0.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 20:55:09 -08:00
MSedgeMC 0fa27d49b7 A fox's or frog's variant component on a spawn egg picks its variant
`Fox.applyImplicitComponent` and `Frog.applyImplicitComponent` set the
variant from `minecraft:fox/variant` and `minecraft:frog/variant`.
`entity_nbt::apply_item_components` knew the variant components of
chickens, cows, pigs, zombie nautiluses, mooshrooms, sheep, axolotls,
salmon and tropical fish, but not those two, so the component was dropped
and the mob kept the variant its spawn picked.

`finalize::variant_index` also had no fox entry, so `minecraft:snow` fell
through the frog name list and read as red.

Corrects the two ledger rows these gaps sat on (game-012.tsv).
2026-11-02 04:50:48 +00:00
Ludovic Marie c2d352e0a3 Merge pull request #6619 from horizon-rust/perf/bench-server-join
🤖 Agent: verified by perf-net-client: main merged in, pr_ready --quick on playhorizon all green, CI verify green, and the Mac runs in the body used this branch end to end (Horizon, vanilla and vanilla-mods clients on a local Horizon server and on a vanilla 26.3 server.jar, with and without 100 bots). Tooling only (skip-release). Merging.
2026-11-02 05:48:50 +01:00
MSedgeMC 35409ecb35 Clear six ledger rows game2-002 calls missing
The fixed item frame's hit rule, the enderman's carried block, primed TNT's fuse, the creeper's
fall distance and the monster idle timer are all in Horizon; the rows still said missing.
2026-11-02 04:47:58 +00:00
Ludovic Marie 64c0291da2 Merge pull request #6436 from horizon-rust/perf/net-replay-bench
🤖 Agent: verified by perf-net-client: main merged in, pr_ready --quick on playhorizon all green (horizon-bots + horizon-protocol 173 tests), CI verify and parity green. Tooling only (skip-release). Merging.
2026-11-02 05:46:49 +01:00
Ludovic Marie 4f1ebf7deb Merge pull request #6754 from horizon-rust/fix/item-entity-render-distance
🤖 Agent: dropped items now use vanilla's 64 × 0.25-box × view-scale range (32 blocks at RD 16), not 64 blocks; verify passed.
2026-11-02 05:46:46 +01:00
Ludovic Marie f4ee603baf Merge remote-tracking branch 'origin/main' into perf/entities-stress 2026-11-02 05:46:29 +01:00
Ludovic Marie daf749bbcd Merge branch 'perf/entity-skin-pass' into perf/entity-gpu-skin 2026-11-02 05:45:12 +01:00
Ludovic MarieandClaude Opus 5.5 27877c639e Renderer: the pack uniforms test frame sends no posed entities
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:44:55 +01:00
Ludovic Marie dbb78b8a4c Merge remote-tracking branch 'origin/main' into perf/entity-skin-pass 2026-11-02 05:44:40 +01:00
Ludovic MarieandClaude Opus 5.5 c4f9b6fc7f tests: the remote motion fixture looks mobs up through MobIndex
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:44:22 +01:00
DetimesandClaude Sonnet 5.5 0886502417 Fragments: honest NeoForge counterparts, no mod_api; cargo fmt
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:44:17 +00:00
DetimesandClaude Sonnet 5.5 0545587b50 Native mods: entity enters block (native_entity_enter_block)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:56 +00:00
DetimesandClaude Sonnet 5.5 1a6f366090 Native mods: entity changes block (native_entity_change_block)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:56 +00:00
DetimesandClaude Sonnet 5.5 25389ab32a Native mods: entity breaks door (native_entity_break_door)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:56 +00:00
DetimesandClaude Sonnet 5.5 050a08fdcb Native mods: creeper power (native_creeper_power)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:56 +00:00
DetimesandClaude Sonnet 5.5 ca57ebfb8c Native mods: creeper ignite (native_creeper_ignite)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:55 +00:00
DetimesandClaude Sonnet 5.5 173f800a32 Native mods: experience bottle (native_exp_bottle)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:55 +00:00
DetimesandClaude Sonnet 5.5 0978d4284e Native mods: firework explosion (native_firework_explode)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:55 +00:00
DetimesandClaude Sonnet 5.5 98cb14831b Native mods: potion splash (native_potion_splash)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:55 +00:00
DetimesandClaude Sonnet 5.5 b7b8074072 Native mods: mob transform (native_entity_transform)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:55 +00:00
DetimesandClaude Sonnet 5.5 5ed0951b78 Native mods: mob enters love mode (native_entity_enter_love_mode)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:55 +00:00
DetimesandClaude Sonnet 5.5 b3e9d948cc Native mods: report entity portal after the transfer is accepted
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:55 +00:00
DetimesandClaude Sonnet 5.5 7e5cfcccfb Native mods: whitelist state update (native_whitelist_state_update)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:41 +00:00
DetimesandClaude Sonnet 5.5 38a921ea88 Native mods: broadcast message (native_broadcast_message)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:41 +00:00
DetimesandClaude Sonnet 5.5 7e5469a938 Native mods: tab completion (native_tab_complete)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:41 +00:00
DetimesandClaude Sonnet 5.5 16283e1e12 Native mods: generic game event (native_game_event)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:41 +00:00
DetimesandClaude Sonnet 5.5 6713dd846c Native mods: entities unload (native_entities_unload)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:41 +00:00
DetimesandClaude Sonnet 5.5 6e6e7a69ab Native mods: entities load (native_entities_load)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:41 +00:00
DetimesandClaude Sonnet 5.5 fa0d5ca8e3 Native mods: world saved (native_world_saved)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:41 +00:00
DetimesandClaude Sonnet 5.5 4796ff9fa2 Native mods: raid state change (native_raid_state_change)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:41 +00:00
DetimesandClaude Sonnet 5.5 567ae450a2 Native mods: raid wave spawn (native_raid_spawn_wave)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:41 +00:00
DetimesandClaude Sonnet 5.5 7d50817baf Native mods: portal creation (native_portal_create)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:40 +00:00
DetimesandClaude Sonnet 5.5 37ac44efaa Native mods: world spawn change (native_spawn_change)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:40 +00:00
DetimesandClaude Sonnet 5.5 1775f325ac Native mods: world game rule change (native_world_game_rule_change)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:40 +00:00
DetimesandClaude Sonnet 5.5 bcad23fdd8 Native mods: world border bounds change (native_world_border_bounds_changed)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:40 +00:00
DetimesandClaude Sonnet 5.5 43a6fad78a Native mods: server clock jump (native_time_skip)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:40 +00:00
DetimesandClaude Sonnet 5.5 decf5e1722 Native mods: lightning strike (native_lightning_strike)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:40 +00:00
DetimesandClaude Sonnet 5.5 0d07776c8a Native mods: thunder change (native_thunder_change)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:40 +00:00
DetimesandClaude Sonnet 5.5 5d8c823e82 Native mods: weather change (native_weather_change)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:40 +00:00
DetimesandClaude Sonnet 5.5 b751a37267 Native mods: hopper pickup item (native_hopper_pickup_item)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:40 +00:00
DetimesandClaude Sonnet 5.5 fb4eadd5ee Native mods: player selects trade (native_trade_select)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:40 +00:00
DetimesandClaude Sonnet 5.5 65f5c3b1f9 Native mods: prepare crafting result (native_prepare_item_craft)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:40 +00:00
DetimesandClaude Sonnet 5.5 22ef43d818 Native mods: prepare smithing result (native_prepare_smithing)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:40 +00:00
DetimesandClaude Sonnet 5.5 6774851af8 Native mods: prepare grindstone result (native_prepare_grindstone)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:40 +00:00
DetimesandClaude Sonnet 5.5 1ca6eb0d7f Native mods: prepare anvil result (native_prepare_anvil)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:40 +00:00
DetimesandClaude Sonnet 5.5 27d73bc63c Native mods: prepare enchanting offers (native_prepare_item_enchant)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:40 +00:00
DetimesandClaude Sonnet 5.5 4d96682a49 Native mods: player applies enchantment (native_enchant_item)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:39 +00:00
DetimesandClaude Sonnet 5.5 24279c1097 Native mods: brewing stand consumes fuel (native_brewing_fuel)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:39 +00:00
DetimesandClaude Sonnet 5.5 63a8d2dfef Native mods: brewing completes (native_brewing_complete)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:39 +00:00
DetimesandClaude Sonnet 5.5 ffd2095933 Native mods: furnace starts cooking (native_furnace_start_smelt)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:39 +00:00
DetimesandClaude Sonnet 5.5 c5ad735dc2 Native mods: furnace output taken (native_furnace_extract)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:39 +00:00
DetimesandClaude Sonnet 5.5 22df768e19 Native mods: furnace finishes smelting (native_furnace_smelt)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:39 +00:00
DetimesandClaude Sonnet 5.5 c6204b37bd Native mods: furnace fuel starts burning (native_furnace_burn)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:39 +00:00
DetimesandClaude Sonnet 5.5 3acfbe35a5 Native mods: entity breed (native_entity_breed)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:39 +00:00
DetimesandClaude Sonnet 5.5 c31617646e Native mods: entity tame (native_entity_tame)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:39 +00:00
DetimesandClaude Sonnet 5.5 56b9a35982 Native mods: item spawn (native_item_spawn)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:39 +00:00
DetimesandClaude Sonnet 5.5 24d89bed2d Native mods: entity pose change (native_entity_pose_change)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:39 +00:00
DetimesandClaude Sonnet 5.5 d7a08e1b9e Native mods: glide toggle (native_entity_toggle_glide)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:39 +00:00
DetimesandClaude Sonnet 5.5 527557cd82 Native mods: bow shot (native_entity_shoot_bow)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:39 +00:00
DetimesandClaude Sonnet 5.5 7b7f5c3153 Native mods: projectile launch (native_projectile_launch)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:39 +00:00
DetimesandClaude Sonnet 5.5 56b0baa1a9 Native mods: entity portal transfer (native_entity_portal)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:38 +00:00
DetimesandClaude Sonnet 5.5 defda875cb Native mods: entity teleport (native_entity_teleport)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 04:43:38 +00:00
MSedgeMC 0d15238cc3 Options: a clicked option no longer takes the wheel
AbstractScrollArea.mouseScrolled scrolls the options list; a hovered cycle button
is the only widget that keeps the wheel (CycleButton.mouseScrolled), and vanilla
never routes the wheel through the focused widget. A click focuses a button for its
highlight only.

OptionsUi kept one focus for both jobs, and the click handler set it. The wheel
treated any Slider or Cycle focus as keyboard focus and stepped it wherever the
cursor was, returning before scroll_at: after clicking one option, every later
wheel event toggled it, so a trackpad's stream of small events made it flip
repeatedly and the list never scrolled.

focus_from_keyboard now records how the focus was taken: Tab, the arrow keys and
the pad's focus steps set it, a mouse click clears it. The wheel's focus branch
requires it, so the written keyboard stepping stays and a click no longer hands the
wheel a widget to toggle. The hovered cycle branch is unchanged: it is vanilla's
CycleButton.mouseScrolled.
2026-11-02 04:38:55 +00:00
MSedgeMC d89499c0ae Wait instead of polling when a frame does not reach the screen
A window the compositor stops showing gets no RedrawRequested, and the event loop kept
polling and asking for another redraw every pass, pinning a core with nothing to draw.
Once a frame fails to reach the swap chain the loop now waits for the next event.
2026-11-02 04:38:55 +00:00
ErikKalikasandClaude Opus 5.5 cc99f96033 Data packs: a broken block and a sequence-less shear roll the world's loot tables
Game::block_loot_at rolled the game's own tables when the block's table has a
random_sequence, so a world data pack replacing blocks/stone was ignored.
Game::sequence_loot did the same for a table without a sequence (dispenser and
player shearing, brushing). Both now roll world_items!, as vanilla's
reloadableRegistries().getLootTable does.

Fixes #6777

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 06:38:52 +02:00
ClickingMasterandClaude Sonnet 5.5 7d6a6c19c9 lan_e2e: fishing-tnt check, a hook catches and pulls a primed TNT
Casts a rod at a lit TNT and reels in: the TNT's x motion must turn toward
the player (0.0 without the fix).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 20:32:40 -08:00
arismoko 121c848cff Docs: native mod trait hooks
The trait covers every hook in the catalog and the pending fragments, and "Adding a hook" says how a new one gets its trait method: its mod_api entry, the placeholder the generator fills, and the free-function hooks wired through installed callbacks.
2026-11-02 04:30:39 +00:00
arismoko c73dbb7b7d xtask mod-api fills new hooks' dispatch placeholders
A new `Game` hook can start with the placeholder body
`let _ = (every, parameter);`. Given its mod_api entry,
`cargo run -p xtask -- mod-api` replaces the placeholder with the
`mod_api::dispatch_<hook>` call, laid out as `cargo fmt` would; `--check`
reports it instead. A body that is neither the placeholder nor already
calls the dispatch is an error, named and never changed. Free-function
hooks stay wired by hand. Generated code is formatted for the
workspace's edition.
2026-11-02 04:30:29 +00:00
arismoko 0cc0fd1510 Mod API: client-library hooks through NativeClientHooks
`menu_slot_rules`, `living_breathe` and `ingredient` are free functions in
horizon-client-lib, which can't see the mod list. One `NativeClientHooks`
struct of callbacks, installed once from the executable's entry point as
horizon-world's `NativeLoadHooks` is, carries the three to their dispatch.
The generator resolves `horizon_client_lib::` targets (and the older
`horizon_client::menu` and `breathing` names) to the client source.

With these, every hook has a mod_api entry, and the check now fails
naming any hook without one.
2026-11-02 04:29:44 +00:00
arismoko 8d9a75e8ef Mod API: trait methods for every hook
Every hook in native-hooks.json and the pending fragments gets a mod_api
contract, so trait mods can answer each through `Hooks`. The generator
reads the fragments too, rejects duplicate names once, and the game's
hook bodies call their dispatch in one short form. Event types the
generated trait names are re-exported from `mod_api`, and horizon-world's
`NativeLoadHooks` gains loot functions and conditions.
2026-11-02 04:29:19 +00:00
Ludovic MarieandClaude Opus 5.5 4c877c4c49 Launcher lock: horizon-world's new log dependency
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:27:27 +01:00
Ludovic MarieandClaude Opus 5.5 03d815532e Keep mobs.rs's modules in order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:26:30 +01:00
MSedgeMC db02e3d8d3 Clear four packet ledger rows, and name the host-side gaps that remain
Block entity updates, team changes and transient blocks do reach the client or the guests;
the LookAt and boss event senders really are missing from the LAN host.
2026-11-02 04:25:59 +00:00
MSedgeMC 228889f046 End portals: the cube's two faces are one-sided, like vanilla's culled pipeline
AbstractEndPortalRenderer.submitCube draws the unit cube's faces that
TheEndPortalBlockEntity.shouldRenderFace keeps, which is the Y axis: the top
and the bottom face. RenderPipelines.END_PORTAL never asks for withCull(false)
(only the *_no_cull pipelines do), so the render type culls back faces, and
each face is wound outward: from above only the top face is drawn, from below
only the bottom one.

portal_cube emitted those same two faces with normal [0; 4], which entity.wgsl
reads as "do not cull", so both faces drew from both sides and the portal
showed two stacked surfaces at the top and the bottom of the block. Every
portal vertex now carries CULL_BACK, the marker an entitySolid render type
already uses (the decorated pot), which drops back-facing pixels without
changing the baked colour.
2026-11-02 04:19:46 +00:00
MSedgeMC 815e81e027 Clear seven focus-navigation ledger rows
ContainerEventHandler's focus path, tab order and arrow navigation are in
game/key_focus.rs, and IME preedit reaches the focused field; the rows still said missing.
2026-11-02 04:15:40 +00:00
Ludovic Marie e12185820a Merge pull request #6613 from horizon-rust/perf/mob-poses-no-alloc
🤖 Agent: poses keyed by Fx hash, vertex hash identical; fmt, clippy and mobs:: (339) pass on the main-merged head.
2026-11-02 05:14:28 +01:00
Ludovic MarieandClaude Opus 5.5 8f91c1534e Format the item render range
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:12:04 +01:00
MSedgeMC 05bc28580b Clear six ledger rows classes-01 calls missing
Command minecarts, the recipe a crafting block last used, and the adventure-mode placement
predicate are all in Horizon; the rows still said missing.
2026-11-02 04:08:49 +00:00
Ludovic MarieandClaude Opus 5.5 916cca3b77 Recipes: the --world launch preload holds the recipe folder too
Its loot data and its server data now share one read, as the title's
world-screen preload does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:06:53 +01:00
MSedgeMC ab9cdd096e Clear five ledger rows rest-005 calls missing
The creative hotbar hint, the entity draw-distance scale, the singleplayer owner and the
low-disk-space reports are all in Horizon; the rows still said missing.
2026-11-02 04:02:50 +00:00
some-du6e 0d934d8c12 Merge remote-tracking branch 'upstream/main' into fix/bucket-double-slab 2026-11-01 22:02:41 -06:00
MSedgeMC c8fef47f35 Clear ten renderer ledger rows main already implements
The OIT pass, its water mask, the vanilla post chains and transient blocks all exist in
Horizon; the rows still said missing.
2026-11-02 04:02:18 +00:00
MSedgeMC 752aad9385 Port map: the villager bone meal behaviour is ported
The seven UseBonemeal rows and the getWorkPackage note still said the farmer's
bone meal behaviour never starts. It is in villager_brain.rs: B::try_start
starts it every 10 ticks (160 after a session) with bone meal and a growing crop
in the 3x3x3, B::tick_or_stop grows the crop with level event 1505 and picks the
next one 40 ticks later for up to 80 cycles, and the work package registers it at
vanilla's farmer priorities. Point each row at the function that ports it.
2026-11-02 04:02:02 +00:00
ClickingMasterandClaude Sonnet 5.5 250bf3e108 A fishing hook catches, follows and pulls a primed TNT
FishingHook only checked mobs, players and dropped items, so a lit TNT could
never be hooked. Add self.tnts to the hit, hooked-follow and reel-in pull.

Fixes #6601

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:56:39 -08:00
MSedgeMC 263efa9370 Clear ten ledger rows rest-004 calls missing
The jeb_ sheep's colour cycle, the quit shortcut option, anisotropic filtering, the IME preedit,
the skin layer toggles and the graphics preset stepping are all in Horizon; the graphics and
input rows still said missing.
2026-11-02 03:56:15 +00:00
Ludovic MarieandClaude Opus 5.5 00e0ecbdaf Pose mob models on the GPU
Every frame the CPU posed each mob's model parts and pushed 44-byte vertices
for every face (mobs::render::draw_normals): with 1000 mobs in view that was
most of the frame's CPU time. mobs/skin.rs now flattens each model once for
the renderer's store (gfx/entity_skin.rs, the previous commit) and, per
frame, walks the poses like draw_normals but sends one matrix pair per drawn
part and one job per contiguous run; the compute pass writes the vertices.
Culling uses the posed parts' bounds; a culled mob's jobs are taken back.

The vertices are bit for bit the CPU path's: a test poses every mob type at
several ages and yaws both ways and compares them. A mob takes the GPU path
only when all its draws can, so the draw order stays: no custom (CEM)
models, translucency, full-bright layers, energy swirl, armour extras,
outlines, and not players. HORIZON_NO_ENTITY_SKIN=1 turns it off.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:56:02 +01:00
Ludovic MarieandClaude Opus 5.5 3e642d0acd Renderer: a compute pass that poses entity models
The first half of posing mobs on the GPU (the game side follows): the
renderer keeps a store of model-space vertices (position / 16, UV, axis
normal, part index) that grows as models are handed over, and each frame
runs gfx/entity_skin.wgsl over the frame's jobs (one run of a model's
vertices with its parts' matrices, texture rect, scroll, colour, light and
overlay). The shader writes mesher::Vertex words into the entity buffer
ahead of the CPU-built entities, so every pass that reads the buffer (OIT,
shadows, HDR marks) sees the same vertices as before.

`expand` is the CPU reference: glam's transform order, axis normals taken as
exact normal-matrix columns and `as i8` normal packing, so the shader's
output is bit for bit the CPU path's (test on a real device). The pass needs
compute and four storage buffers, and stays off with a shader pack, a native
pack, frame generation or HORIZON_NO_ENTITY_SKIN=1. Nothing sends frames yet
(`Frame::skin` is None).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:55:54 +01:00
Ludovic Marie f31198cbf7 Merge pull request #6721 from horizon-rust/perf-jobs/streaming
Streaming: run on the shared worker pool
2026-11-02 04:55:16 +01:00
DetimesandClaude Sonnet 5.5 40e27f6f62 Native mods: locale change (native_player_locale_change)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:26 +00:00
DetimesandClaude Sonnet 5.5 3e8d6e6222 Native mods: XP cooldown change (native_player_exp_cooldown_change)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 9ae14ba1d7 Native mods: recipe book click (native_player_recipe_book_click)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 6af5aa493b Native mods: End gateway teleport (native_player_teleport_end_gateway)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 e936c6315a Native mods: stop spectating entity (native_player_stop_spectating_entity)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 7b34f85f12 Native mods: player velocity (native_player_velocity)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 f585790e5f Native mods: unleash entity (native_player_unleash_entity)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 bd0f0ab56b Native mods: toggle entity age lock (native_player_toggle_entity_age_lock)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 68a16afcc2 Native mods: equipment slot swap (native_player_swap_with_equipment_slot)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 1ed9e1ee55 Native mods: map filled (native_player_map_filled)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 1e6b626c9d Native mods: stonecutter recipe select (native_player_stonecutter_recipe_select)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 1b4e9e4826 Native mods: pickup arrow (native_player_pickup_arrow)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 4c7c2ece2b Native mods: open sign (native_player_open_sign)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 32c3288cb2 Native mods: name entity (native_player_name_entity)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 bbdeb434f4 Native mods: loom pattern select (native_player_loom_pattern_select)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 a1b41ff16a Native mods: shield disable (native_player_shield_disable)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 fdd11327b0 Native mods: player trade (native_player_trade)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 32e3d4e012 Native mods: player item mend (native_player_item_mend)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:25 +00:00
DetimesandClaude Sonnet 5.5 cacdaa5044 Native mods: item frame change (native_player_item_frame_change)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:24 +00:00
DetimesandClaude Sonnet 5.5 3fcd336408 Native mods: lectern page change (native_player_lectern_page_change)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:24 +00:00
DetimesandClaude Sonnet 5.5 8ecd70f64a Native mods: take lectern book (native_player_take_lectern_book)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:24 +00:00
DetimesandClaude Sonnet 5.5 25068b54a9 Native mods: insert lectern book (native_player_insert_lectern_book)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:24 +00:00
DetimesandClaude Sonnet 5.5 4386a59c97 Native mods: flower pot manipulation (native_player_flower_pot_manipulate)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:24 +00:00
DetimesandClaude Sonnet 5.5 a00cc98cc8 Native mods: elytra boost (native_player_elytra_boost)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:24 +00:00
DetimesandClaude Sonnet 5.5 75fd319be1 Native mods: egg throw (native_player_egg_throw)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:24 +00:00
DetimesandClaude Sonnet 5.5 9b99779852 Native mods: main hand change (native_player_change_main_hand)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:24 +00:00
DetimesandClaude Sonnet 5.5 c7ccfbb1a1 Native mods: deep sleep (native_player_deep_sleep)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:24 +00:00
DetimesandClaude Sonnet 5.5 0cec1470d6 Native mods: beacon effect (native_player_change_beacon_effect)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:24 +00:00
DetimesandClaude Sonnet 5.5 74b8565806 Native mods: bucket entity (native_player_bucket_entity)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:24 +00:00
DetimesandClaude Sonnet 5.5 5688215ec7 Native mods: attack cooldown reset (native_player_attack_cooldown_reset)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:52:24 +00:00
NazaandClaude Opus 5.5 8fb8106da9 Tests: two that failed on a Windows checkout
native_ingredient_stays_hookable normalises CRLF as the tests beside it
do. test_capture_lifecycle expected two warps on a Windows grab, from
before #6463 made it one.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:50:14 -03:00
Ludovic MarieandClaude Opus 5.5 abaabf5cb0 Mesher: the fill test's column is 32 sections tall
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:49:07 +01:00
Senjin 40c20bf016 Merge remote-tracking branch 'origin/main' into hdr-glow-more
# Conflicts:
#	crates/horizon-client-render/src/entity.wgsl
2026-11-02 04:43:27 +01:00
Ludovic Marie b82e2a4b35 Merge pull request #6719 from horizon-rust/perf-jobs/pool
🤖 Agent: verified: verify green; pr_ready --quick green (horizon-threads 16 tests, 602 Python tests); the pool has no user yet (skip-release). First of the job-system stack (#6721, #6722).
2026-11-02 04:42:43 +01:00
Ludovic Marie c9434646de Merge pull request #6725 from horizon-rust/perf/low-end-menu
🤖 Agent: verified: pr_ready green (990 render tests, clippy, rustfmt), new tests pass on llvmpipe, blurred menus 2.6 → 7.5 FPS on llvmpipe, GPUs unchanged. Merging.
2026-11-02 04:41:06 +01:00
Ludovic MarieandClaude Opus 5.5 4a4cfcd68f Recipes: the loot data and the server's sync share the recipe folder's read
json_folder moves to horizon-world so loot's load_dir reads through it; the
LAN recipe sync reads through it too. The server-data preload and
SyncData::load hold the recipe folder, so the loot item tables, the recipe
sync and the recipe book's index read each recipe file once, not three times.

Part of #1961.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:41:04 +01:00
VaspyyyandClaude Opus 5.5 a357026d64 Terrain arriving: frames upload chunk meshes for 6 ms instead of 2
Without the player's Chunk Updates or Upload Budget, a frame uploads
meshes for AUTO_UPLOAD_TIME (2 ms). While a view's terrain is still
arriving (ViewArrival, a world's open, a teleport) the meshes waiting are
that view, and up to ~1300 of them queued at the end of a world's open,
holding the full view back. Those frames now give them 6 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-11-02 04:40:11 +01:00
arismoko 086a3c7c2a Native builds: dev in the Mods page picker only
The Mods page's picker already offers the dev build in the window, so the
hidden developer-options switch goes. The picker's dev caption and the
docs say how much faster and slower it is relative to performance, and
the docs describe `fast`'s LTO as Cargo's thin-local one.
2026-11-02 03:40:09 +00:00
arismoko 785f269a40 Native builds: drop a repeated build key check 2026-11-02 03:38:04 +00:00
JustKarkatandJustKarkat's AI agent c2fb2e7d78 Merge main into the repro branch; the AI Agent page's lines moved to status_lines
origin/main refactored agent_status_lines into status_lines and translated
its keys; the branch's recording line is appended after it. The language
file keeps both sides' keys. #6202 (the collision scan) came in too.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-11-01 21:36:59 -06:00
arismoko 823829a781 Native builds: point mod developers at the dev profile
AGENTS.md, the native mods docs and the CLI's "Build it next" hint say
how to opt in to `dev` while developing a mod, and the launcher's hidden
developer options get a "Faster mod builds" switch bound to the same
setting. Players and releases keep `performance`; nothing is guessed.
2026-11-02 03:36:00 +00:00
Ludovic Marie 9784cf63fd Merge remote-tracking branch 'origin/main' into perf/net-replay-bench 2026-11-02 04:35:39 +01:00
Ludovic Marie 84ad92068f Merge remote-tracking branch 'origin/main' into perf/bench-server-join 2026-11-02 04:35:22 +01:00
Ludovic Marie 79a60dea88 Merge remote-tracking branch 'origin/main' into perf/remote-mob-index 2026-11-02 04:33:50 +01:00
Ludovic Marie 38ce16338c Merge remote-tracking branch 'origin/main' into fix/item-entity-render-distance 2026-11-02 04:33:30 +01:00
Ludovic Marie 2eb2bc1538 Merge remote-tracking branch 'origin/main' into perf/mob-poses-no-alloc 2026-11-02 04:33:12 +01:00
JustKarkatandJustKarkat's AI agent 39f2aa3e50 Review: scrub repro bundles, gate the recording keys, replay only input
Address the review of #6712:

- The two debug keys leave MAPPINGS (restoring the vanilla key table and
  options parity); F3+R/F3+E are fixed Horizon keys, off by default behind a
  new "Recording shortcuts" extras switch on the AI Agent page. The page's
  buttons use translation keys and say start/stop; the page shows the live
  recording/replay line.
- Bundles no longer carry secrets: typed key text is dropped, chat/command/
  type/signedit/connect/book/publish lines keep only "<redacted>", the zip's
  text goes through horizon-telemetry's Scrubber (user folder, IPs, UUIDs,
  world/server names, player names), status.json loses server/name tags/
  sidebar/clipboard, only a crash from this recording is included, and the
  keyframes lose the absolute recording path.
- A replay only runs an allow-list of input and movement commands, never
  world, network or account ones; it stops when the world closes, releases
  every key/button it held, times out from the command rather than the world
  load, and removes its saves/ copy unless --keep-world.
- record-start saves and copies with the integrated server paused (no torn
  regions, no 60 s stop block); read-only polls and session commands are not
  traced; a deferred ui command is traced once; events.jsonl works without a
  socket; an intrusion counts while an agent is connected or waiting.
- Smaller fixes: end_tick is optional and a torn input line is tolerated,
  --no-world skips the world directory, exports are written to a temporary
  name and renamed (unique when taken), the replay timeout is an integer,
  input-log --origin is an enum, repro-export paths resolve from ctl's
  directory, recording is refused in the deprecated in-frame mode, the
  bundle extractor caps entries and bytes, and old recordings/bundles are
  pruned (the newest ten are kept).
- The e2e scenario now asserts the replay's done/skipped counts, a
  deterministic turn (not just terrain-dependent walking), a bounded input
  log, and keyframes the tick wrote.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-11-01 21:31:00 -06:00
arismoko a61d9ae237 Native builds: performance and dev profiles
Two profiles for playing and developing with native mods, plus the
release's own. `performance`, the default, is the workspace's `fast`, the
launcher's build until now, with the same key, record and folder, so warm
builds and installed versions stay valid. `dev` builds in the same folder
with only `horizon-client` at opt-level 0, so every other crate is shared
and switching between the two recompiles only the game crate.

The LTO-off default and the experimental profiles are removed.
2026-11-02 03:30:07 +00:00
Detimes e0302f4ee6 Merge upstream/main 2026-11-02 03:27:23 +00:00
Detimes 94ac2fb634 Merge upstream/main 2026-11-02 03:26:58 +00:00
Detimes db63176599 Merge upstream/main 2026-11-02 03:26:44 +00:00
Detimes bdee42e267 Merge upstream/main 2026-11-02 03:21:47 +00:00
Detimes 86989096e5 Merge upstream/main 2026-11-02 03:21:09 +00:00
Detimes 185ca1b2af Merge upstream/main 2026-11-02 03:20:51 +00:00
Detimes 78690099a2 Merge upstream/main 2026-11-02 03:20:34 +00:00
Ludovic MarieandClaude Opus 5.5 961618fd6a Mesher: review follow-ups from #6503
- Padded::fill finds a column's section, or the light outside it, through
  the new Chunk::section_at, which Chunk::light now uses too, so the two
  can't disagree.
- MeshScratch::opaque and opaque_rows become solid_rows: they hold
  isSolidRender, not the opaque render layer.
- Docs of sides::sort and SortScratch describe the integer keys.
- A fill test with every neighbour loaded, per-block light, and sections
  at both ends of the column; get_run is checked against the stored values.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:20:23 +01:00
Detimes 1fcfcc3029 Merge upstream/main 2026-11-02 03:19:56 +00:00
NazaandClaude Opus 5.5 247ff24c46 Online join: try once more when the session service gives no answer
Joining sometimes stops with "authentication servers not reachable" and
works when the player joins again. The cause was dropped, so log it, and
repeat the join once when there was no answer. An answer from the service
stands. Vanilla leaves the retry to the player.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:19:42 -03:00
DetimesandClaude Sonnet 5.5 ff7bcd21ef Native mods: block formation (native_block_form)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:17:12 +00:00
DetimesandClaude Sonnet 5.5 e6641065b9 Native mods: block fading (native_block_fade)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:17:12 +00:00
DetimesandClaude Sonnet 5.5 9d48339b8b Native mods: block lock checks (native_block_lock_check)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:17:12 +00:00
DetimesandClaude Sonnet 5.5 003b271624 Native mods: block break progress updates (native_block_break_progress_update)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:17:12 +00:00
DetimesandClaude Sonnet 5.5 1274e727c9 Native mods: anvil damage (native_anvil_damaged)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:17:12 +00:00
DetimesandClaude Sonnet 5.5 f37d78ee1b Native mods: beacon activation changes (native_beacon_state_change)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:17:12 +00:00
DetimesandClaude Sonnet 5.5 f59e8ff940 Native mods: failed dispenser actions (native_block_failed_dispense)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:17:12 +00:00
DetimesandClaude Sonnet 5.5 9b8bfcbde0 Native mods: vault state changes (native_vault_change_state)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:17:11 +00:00
DetimesandClaude Sonnet 5.5 46d6501ce0 Native mods: target hits (native_target_hit)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:17:11 +00:00
DetimesandClaude Sonnet 5.5 9303e19805 Native mods: sponge absorption (native_sponge_absorb)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:17:11 +00:00
DetimesandClaude Sonnet 5.5 7cb3144193 Native mods: sculk blooms (native_sculk_bloom)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:17:11 +00:00
DetimesandClaude Sonnet 5.5 097c431c63 Native mods: farmland moisture changes (native_moisture_change)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:17:11 +00:00
DetimesandClaude Sonnet 5.5 0f235bbb8d Native mods: block inventory starts (native_inventory_block_start)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:17:11 +00:00
DetimesandClaude Sonnet 5.5 8a96f668ef Native mods: leaves decay (native_leaves_decay)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:17:11 +00:00
DetimesandClaude Sonnet 5.5 8b5be599e3 Native mods: fluid level changes (native_fluid_level_change)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:17:11 +00:00
DetimesandClaude Sonnet 5.5 e89c40cff8 Native mods: dragon egg forms (native_dragon_egg_form)
Written by Codex, reviewed by Claude.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 03:17:11 +00:00
Detimes 6e0cc695ac Native mods: crafter completes recipe (native_crafter_craft) 2026-11-02 03:16:30 +00:00
Detimes 050d7e9266 Native mods: composter accepts item (native_compost_item) 2026-11-02 03:16:30 +00:00
Detimes a2d7e670cc Native mods: block cooking completes (native_block_cook) 2026-11-02 03:16:30 +00:00
Detimes 5714b64ca0 Native mods: campfire starts cooking (native_campfire_start) 2026-11-02 03:16:30 +00:00
Detimes fec2b55691 Native mods: brewing starts (native_brewing_start) 2026-11-02 03:16:30 +00:00
Detimes 18b8aa2b3f Native mods: sculk receives game event (native_block_receive_game) 2026-11-02 03:16:30 +00:00
Detimes f27589cf55 Native mods: dispenser shearing (native_block_shear_entity) 2026-11-02 03:16:30 +00:00
Detimes 82e55b75c1 Native mods: block experience drop (native_block_exp) 2026-11-02 03:16:30 +00:00
Detimes 953a8b354d Native mods: dispenser armor equip (native_block_dispense_armor) 2026-11-02 03:16:30 +00:00
martipops ce36387b6b Merge branch 'split/fresh-basic-setup' into split/fresh-mouse-controls 2026-11-01 22:14:04 -05:00
martipopsandClaude Opus 5.5 afe39ff137 Merge upstream main into the Setup PR
Re-export list conflict in client_lib.rs: main's list (json_folder) plus split_map.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:13:41 -05:00
Ludovic MarieandClaude Opus 5.5 d5be28d945 Streaming: run on the shared worker pool
Every streamer (the client's, and one per level on the server thread)
started N-1 worker threads of its own: a single player world ran two to
four such pools at once. They now queue their jobs on the process's
pool (horizon_threads::jobs), sized from the workers setting before any
world opens. Saves and freed meshes go first, then jobs by column
distance, as before; per-worker generator state, face cache and mesh
scratch move into the streamer's per-worker slots.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:12:25 +01:00
Ludovic MarieandClaude Opus 5.5 8a6024f8e1 horizon-threads: one shared worker pool for the process (jobs)
A pool of worker threads sized once for the machine, which every source
of background work can share instead of starting its own threads: each
source keeps its queue (so it can rank its jobs again and drop the ones
that left the view) and a free worker runs the most urgent queued job
of any source, by class (urgent, near, far) then the source's key. Far
work never holds every worker. A notify is two atomic operations while
the workers are busy, so the thread queuing work (a frame, a tick) never
waits behind a worker's choice. Per-worker scratch belongs to the
source; dropping a registration waits for its running jobs; idle
workers free their buffers. No user yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:12:25 +01:00
Ludovic MarieandClaude Opus 5.5 2b874aea1c Menu blur: half size on a software adapter
On WARP or llvmpipe the menu blur took ~250 ms of CPU per frame at 1440x900:
every blurred menu (options, world list, first-launch screen) ran at 3 FPS.
On a software adapter it now runs its six passes at half the width and height
with half the radius, the same blur over a quarter of the pixels. GPUs blur as
before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:12:08 +01:00
Ludovic MarieandClaude Opus 5.5 3011a79600 Renderer: a chosen graphics API with no GPU tries the others before software
An Intel HD 2500 on Windows has DirectX 12 only through WARP and its GPU through
OpenGL. With DirectX 12 chosen (or set by the launcher), Horizon rendered the
title and every menu on the CPU: 90% CPU and minutes to load (#5185). Auto
already tried every backend on a GPU first (#3985); a chosen API now does too
before it falls back to a software adapter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:12:08 +01:00
arismoko 42c9bd6d95 Merge main into native build profiles 2026-11-02 03:10:47 +00:00
martipopsandClaude Opus 5.5 a8432f5ada Merge the Setup PR (main merged) into mouse controls
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:09:59 -05:00
martipopsandClaude Opus 5.5 dbce14ff72 Merge upstream main into the Setup PR
Lang files: both sides' new strings kept; for keys main also added to
fr_fr.json (the Splitscreen Setup page), this branch's wording stays.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:09:29 -05:00
HrafnaValkyrja 1e1c6d5699 Merge remote-tracking branch 'origin/main' into refactor/stable-ids-rest
# Conflicts:
#	crates/horizon-client/src/game/persist.rs
2026-11-01 22:04:36 -05:00
NazaandClaude Opus 5.5 2b5e91b348 Merge main: the mod API's generated output is current there
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:00:39 -03:00
VaspyyyandClaude Opus 5.5 189e128d00 World open: read the server's sync data beside the worldgen preload
The world data preload read the registries, tags, recipes and advancements
the server sends (lan::sync_data) after the worldgen bundle and the loot
tables. A launch into a world reached the server thread's own preload
before that, so the server started a second read of the same data and
then waited on SYNC's lock while the preload read it again: ~100 ms on
the server thread before settle_spawn (server.sync_adopt). The preload
now reads it on a thread of its own from the start.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-11-02 03:58:00 +01:00
some-du6e 73047c1810 Merge remote-tracking branch 'upstream/main' into feature/launcher-import-client
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/ja.json
#	launcher/lang/pl.json
#	launcher/lang/pt-br.json
#	launcher/lang/ru.json
#	launcher/lang/tr.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-11-01 20:57:16 -06:00
DetimesandClaude Sonnet 5.5 5b81a84c79 Merge upstream/main: keep both hooks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 02:55:27 +00:00
DetimesandClaude Sonnet 5.5 d05fd623e1 Fragments: honest NeoForge counterparts, no mod_api
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 02:54:04 +00:00
DetimesandClaude Sonnet 5.5 8128a5937d Fragment: drop mod_api again (it comes with the pending-fragment PR #6253; keeping it made the generated mod API stale)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 02:52:36 +00:00
DetimesandClaude Sonnet 5.5 55331c9ece Fragment: drop mod_api again (it comes with the pending-fragment PR #6253; keeping it made the generated mod API stale)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 02:52:33 +00:00
DetimesandClaude Sonnet 5.5 d8f75a8dd5 Fragment: drop mod_api again (it comes with the pending-fragment PR #6253; keeping it made the generated mod API stale)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 02:52:29 +00:00
DetimesandClaude Sonnet 5.5 8dce381d48 Fragment: drop mod_api again (it comes with the pending-fragment PR #6253; keeping it made the generated mod API stale)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 02:52:26 +00:00
HrafnaValkyrjaandClaude Opus 5.5 a8ec40548d Regenerate the mod API output after the merge with main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 21:49:14 -05:00
HrafnaValkyrja 9f8a5e144e Merge remote-tracking branch 'origin/main' into fix/tnt-minecarts 2026-11-01 21:49:07 -05:00
HrafnaValkyrjaandClaude Opus 5.5 7983bb743f Regenerate the mod API output after the merge with main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 21:48:03 -05:00
HrafnaValkyrja cbe2ef58b7 Merge remote-tracking branch 'origin/main' into refactor/stable-ids-rest 2026-11-01 21:47:32 -05:00
HrafnaValkyrja f602ae73f6 Merge remote-tracking branch 'origin/main' into fix/tnt-minecarts 2026-11-01 21:41:27 -05:00
Ludovic MarieandClaude Opus 5.5 857b399103 Launcher CI and releases follow crates/horizon-mcp
The launcher now builds horizon-mcp in: a change only there needs the
launcher job and ships a launcher release (some-du6e's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:34:29 +01:00
Detimes 02ce566d05 Native mods: player riptide (native_player_riptide) 2026-11-02 02:32:31 +00:00
some-du6eandClaude Opus 5.5 f37728687d Pour a refused water bucket beside the clicked block
BucketItem.emptyContents retries at the block beside the clicked face
when the clicked block refuses the fluid. Without it, a water bucket on
a double slab did nothing, and a sneak-click on a waterloggable block
failed instead of pouring next to it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:30:53 -06:00
DetimesandClaude Sonnet 5.5 a235c61c01 Creative slot hook: a cancelled slot is sent back to the creative client
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 02:30:30 +00:00
MSedgeMC c85564e262 Pin the fluid current's push rules
EntityFluidInteraction.CurrentAccumulator.applyTo gives a body that is not a player the
direction of the current it stands in and a player the average, lifts a weak flow to 0.0045
for a body that is still on x and z, and does nothing under a squared length of 1e-5. None of
that had a test.
2026-11-02 02:30:10 +00:00
DetimesandClaude Sonnet 5.5 dd19d7e959 TNT hook: a refused prime plays no fuse sound and emits no game event
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 02:29:22 +00:00
Ludovic MarieandClaude Opus 5.5 9ec95a7045 Merge main into #6531
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:28:00 +01:00
DetimesandClaude Sonnet 5.5 bd1485f3fb Totem hook: listen only (the health state machine decides before a mod is asked)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 02:27:22 +00:00
Ludovic MarieandClaude Opus 5.5 ed85828ead Merge main into #6653
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:27:13 +01:00
some-du6eandClaude Opus 5.5 97a91ce8f7 Launcher import: wait for removals, say when --version can't apply
- No import starts while a removal runs: it could take the folder of the
  same program imported again.
- `import-build --version` on a program imported before on other game files
  says so, instead of selecting the old version as if it were made anew.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:26:52 -06:00
Ludovic MarieandClaude Opus 5.5 5112fc836c Merge main into #6635
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:26:51 +01:00
DUzzL 513cd005e5 Spectators make no splash when entering water 2026-11-02 03:26:27 +01:00
Ludovic MarieandClaude Opus 5.5 b75e56f185 Merge main into #6623
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:26:04 +01:00
Detimes dbb5f55fb6 Native mods: player portal travel (native_player_portal) 2026-11-02 02:25:01 +00:00
Detimes a3786152e2 Native mods: player spawn point (native_player_spawn_point_set) 2026-11-02 02:25:01 +00:00
Detimes 2fb430e767 Native mods: player recipe discovery (native_player_recipe_discovered) 2026-11-02 02:25:01 +00:00
Detimes 24db1ed39a Native mods: player item cooldown (native_player_item_cooldown) 2026-11-02 02:25:01 +00:00
Detimes 9dd5824c35 Native mods: player orb XP change (native_player_exp_change) 2026-11-02 02:25:01 +00:00
Detimes 1795bf290c Merge branch 'feat-player-teleported-hook' of https://github.com/Detimes/horizon into feat-player-teleported-hook 2026-11-02 02:24:23 +00:00
Detimes a366ed22a2 Merge branch 'feat-food-level-hook' of https://github.com/Detimes/horizon into feat-food-level-hook 2026-11-02 02:24:20 +00:00
Detimes bcece942b9 Merge branch 'feat-player-swap-hands-hook' of https://github.com/Detimes/horizon into feat-player-swap-hands-hook 2026-11-02 02:24:17 +00:00
Detimes 6f2273e42c Fragments: add mod_api and correct the NeoForge counterparts\n\nCo-Authored-By: Claude Sonnet 5.5 <[email protected]> 2026-11-02 02:24:06 +00:00
Detimes 4106cc0ac0 Fragments: add mod_api and correct the NeoForge counterparts\n\nCo-Authored-By: Claude Sonnet 5.5 <[email protected]> 2026-11-02 02:24:05 +00:00
Detimes edb3022de8 Fragments: add mod_api and correct the NeoForge counterparts\n\nCo-Authored-By: Claude Sonnet 5.5 <[email protected]> 2026-11-02 02:24:03 +00:00
Detimes 85268545c0 Fragments: add mod_api and correct the NeoForge counterparts\n\nCo-Authored-By: Claude Sonnet 5.5 <[email protected]> 2026-11-02 02:24:02 +00:00
Detimes c7fd37fcb8 Fragments: add mod_api\n\nCo-Authored-By: Claude Sonnet 5.5 <[email protected]> 2026-11-02 02:24:00 +00:00
Detimes ee1e81509c Fragments: add mod_api and correct the NeoForge counterparts\n\nCo-Authored-By: Claude Sonnet 5.5 <[email protected]> 2026-11-02 02:23:59 +00:00
Detimes 53304cdd14 Fragments: add mod_api\n\nCo-Authored-By: Claude Sonnet 5.5 <[email protected]> 2026-11-02 02:23:57 +00:00
Detimes b90de83c64 Fragments: add mod_api\n\nCo-Authored-By: Claude Sonnet 5.5 <[email protected]> 2026-11-02 02:23:55 +00:00
some-du6eandClaude Opus 5.5 058edc663c Launcher import: retry the move on Windows, let leftovers give way
Windows refuses to rename a folder while a scan holds the program just
written in it: the move and the cleanup are retried for a second, as
backend::remove is. A folder at the version's place without the installed
mark (a removal that stopped at the running game) is cleared instead of
failing every import of that build. The .importing folder stays, so two
imports never remove it under each other.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:23:23 -06:00
Ludovic MarieandClaude Opus 5.5 c35fd5899a Merge main into #6547
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:23:07 +01:00
HrafnaValkyrja fb6af5b7fe Merge remote-tracking branch 'origin/main' into fix/tnt-minecarts 2026-11-01 21:22:40 -05:00
HrafnaValkyrjaandClaude Opus 5.5 ce2acb2cd5 TNT minecart landing: blast grows with the fall, in the landing tick
MinecartTNT.causeFallDamage explodes with (fall/10)^2 as the speed; Horizon set
a zero fuse instead (power 4, one tick late) and counted only the fall before
the last move.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 21:22:21 -05:00
some-du6eandClaude Opus 5.5 a8e24bc735 Launcher import: a version appears whole, in one rename
The copy is made under versions/.importing, where no version is listed,
with its installed mark already in it, and renamed into place: no other
import (or the window's list) ever sees it half made, and none deletes a
version another one finished. A damaged zip says so instead of "not a
Horizon game", and an artifact holding a .tgz is read too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:19:16 -06:00
Detimes 8e6dc878ae Native mods: route block hooks through active server player 2026-11-02 02:16:00 +00:00
Detimes d04865975a Native mods: cauldron fill veto (native_cauldron_level_changed) 2026-11-02 02:16:00 +00:00
Detimes 969a0c63ec Native mods: bell ring veto (native_bell_ring) 2026-11-02 02:16:00 +00:00
Detimes e4d82dade8 Native mods: note block sound veto (native_note_play) 2026-11-02 02:16:00 +00:00
Detimes c0bded22c6 Native mods: fire burnout veto (native_block_burn) 2026-11-02 02:15:59 +00:00
Detimes 43e6a5d7e9 Native mods: fire ignition veto (native_block_ignite) 2026-11-02 02:15:59 +00:00
Detimes bb4bad451e Native mods: player completes advancement (native_player_advancement_done) 2026-11-02 02:15:55 +00:00
Detimes e50aad00ba Native mods: player completes advancement (native_player_advancement_done) 2026-11-02 02:15:55 +00:00
Detimes 1618c7d709 Native mods: player shears entity (native_player_shear_entity) 2026-11-02 02:15:55 +00:00
Detimes 1d5a4e4255 Native mods: player edits book (native_player_edit_book) 2026-11-02 02:15:55 +00:00
Detimes 87f68a067d Native mods: player leaves bed (native_player_bed_leave) 2026-11-02 02:15:55 +00:00
Detimes a7f0d885a1 Native mods: player enters bed (native_player_bed_enter) 2026-11-02 02:15:55 +00:00
Detimes bdce43d31b Native mods: player pick item (native_player_pick_item) 2026-11-02 02:15:55 +00:00
Detimes ae5d8b26df Native mods: player hotbar selection (native_player_item_held) 2026-11-02 02:15:55 +00:00
some-du6eandClaude Opus 5.5 bbd01b89db Launcher import: read Actions artifacts as downloaded, guard races
- An Actions artifact is a zip around the release's archive: the game is
  found in a zip or .tar.gz inside it, and in a picked .tar.gz.
- Two imports of one program at once (window and CLI) each copy into their
  own folder, and the second leaves the first's version as it is.
- A second file picker's pick starts no second import.
- A native build for an instance on an imported build starts from the
  imported build's base, not from a tag GitHub doesn't know.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:14:15 -06:00
martipopsandClaude Opus 5.5 ba1b82058c Merge split/fresh-basic-setup (main merged) into split/fresh-mouse-controls
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:11:55 -05:00
martipopsandClaude Opus 5.5 587f377a87 Merge upstream main into the Setup PR
Re-export list conflict in client_lib.rs: main's list plus split_map.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:11:40 -05:00
HrafnaValkyrja ea3d261c1a Merge remote-tracking branch 'origin/main' into refactor/stable-ids-rest
# Conflicts:
#	crates/horizon-client/src/game/fireworks.rs
#	crates/horizon-client/src/game/persist.rs
2026-11-01 21:11:20 -05:00
JustKarkatandJustKarkat's AI agent f1cf4aa2bc Tell input apart by who caused it, and record repros for bug reports
An agent driving the control socket could not tell its own input from a
person's, and a player had no way to hand over a session that reproduces
a bug. Both are now in the agent surface:

- Every input event carries an origin: user (the window's own keyboard,
  mouse or pad), agent (the control socket) or game (input the game made
  up). state and status gain an input section; input-log returns the
  newest 512 events; a person's input during an agent's run logs once and
  pushes a user-input event, so wait-for event user-input catches it.
- record-start/stop/status/list write inputs, second-by-second state,
  events and, in single player, a copy of the world. repro-export zips
  that with logs/latest.log, the newest crash report and a status.
  record-replay loads the world, restores the start state and feeds the
  recorded keys, mouse, wheel, look, pad buttons and control lines back
  at their ticks, best effort. F3+R and F3+E, and the AI Agent page,
  give a player the same without horizon-ctl.

The commands come from the one command table, so horizon-ctl and MCP get
them together (also on the pending shared horizon-mcp crate).

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-11-01 20:09:52 -06:00
some-du6eandClaude Opus 5.5 bb2440cd12 Launcher import: fit the repo's names, docs and translations
- The module is build_import, apart from `import` (dropped files and packs).
- `import-build --version`, as native-build and prepare name the installed
  version a command works on; its help says a Nightly works too.
- launcher/README.md: the Versions page's import, the CLI line, and that an
  imported build stays selected through updates.
- fr uses "compilation" as the table does, tr "yükleyin", ja its half-width
  colons.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:06:56 -06:00
NazaandClaude Opus 5.5 1201038941 Tests: the command confirmation keeps the pointer
capture() reads it now, and the fixture compiles capture() as it is.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:06:33 -03:00
wxundandGLM Flash 3c9f09475e Fix the macOS launcher clippy fallback feature flag
Rust 1.99.1 toolchains fail the launcher checks because the fallback names
`gpui/runtime_shaders`, a feature that no longer exists: launcher/Cargo.toml
renames gpui-fast as `gpui` and gpui-fast-platform as `gpui_platform`, and
`runtime_shaders` lives on the latter. Fix the flag in pr_ready's launcher
snippet, the launcher.yml Metal fallback, and the mod browser harness doc,
and add a regression test that runs the snippet through fake uname/xcrun/
pkg-config/cargo to pin what the fallback asks for on Mac with and without
Metal, and on Linux.

Co-Authored-By: GLM Flash (T3 Code) <[email protected]>
2026-11-01 21:04:37 -05:00
some-du6eandClaude Opus 5.5 7990e63009 Launcher import: share the copy with native builds, test the checks
- native_mods::copy_base is the base-folder copy both a native build and an
  import start from; carried_over stays private.
- is_game (platform magic and the mod API mark) and base_for (with --base)
  are pure and tested: a program without the mark, a --base that is not an
  installed release or Nightly, and a failed import that must leave nothing.
- One reader for a plain file and a zip entry, refused past 1 GiB.
- install_as runs the same after-install step as an import.
- The Versions page draws native and imported builds with one helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:01:44 -06:00
Ludovic MarieandClaude Opus 5.5 e959cd1516 The GUI atlas keeps its pixels compressed once the GPU has them
The window's 4096x5120 GUI atlas stayed in memory as built (80 MB of
RGBA) for the whole session, though only a renderer being made again
reads it. Once a renderer exists, the pixels are lz4-compressed on a
thread of their own and the raw copy is dropped; a renderer rebuilt
later (graphics API switch, lost device) decompresses them. The
enchanting table's Standard Galactic text read glyph widths from the
raw pixels each frame: they are measured once when the atlas is built.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 02:58:26 +01:00
some-du6eandClaude Opus 5.5 a9a86950bd Launcher import: fix the build and the partner timer, harden the import
- The new field clashed with `importing` (import_page): it is
  `importing_build` now.
- The partner banner's 10 s timer is back; a formatting revert had put a
  second copy of the drain loop in its place.
- A picked file must be named as the game is (the launcher and horizon-ctl
  carry the mod API mark too), and no larger than 1 GiB, checked before it
  is read; a zip entry past that is refused instead of cut off.
- The same program imported on another day selects the version made then.
- A failed last step removes the version folder it had renamed into place.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:56:28 -06:00
NazaandClaude Opus 5.5 3ec71e20ee Command confirmation: no returns to the book or dialog, and it takes the keyboard
As openSendConfirmationWindow's currentScreen, a book or dialog a clicked
command left comes back on no, a dialog with what was typed in it; a
lectern's screen goes with its menu, as in vanilla. Tab, the arrows, Enter
and Space work its buttons. The pointer is now free while it is open: from
a book or a closing dialog it stayed captured, so its buttons couldn't be
clicked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:55:38 -03:00
martipopsandClaude Opus 5.5 9c44e4568d Merge the Setup PR (main merged) into mouse controls
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:54:09 -05:00
some-du6eandClaude Opus 5.5 f898dc0bb7 Launcher: import a game build you already have as its own version
A horizon-client built on a fork's Actions can't start on its own: it has
none of the prepared game files. The Versions page gets "Import a game
build…", which takes the program (or a zip holding it), checks that it is a
Horizon game for this platform, and installs it as imported-<date>-<sha>: a
copy of the selected (or newest) release or Nightly's folder with the picked
program in place of the game, so it plays with that version's prepared
files. It is listed apart as unsigned, can be used and removed like any
version, and no update replaces it, prunes it or moves the selection off it.

The CLI gets `import-build <path> --trust-unsigned`, kept out of MCP: running
a program nothing signed is the player's decision.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:49:05 -06:00
martipopsandClaude Opus 5.5 f3d5a8b94b Merge upstream main into the Setup PR
Main lists monitors once a second (#6620); keep that beside this branch's
split_hud.menu in about_to_wait.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 20:49:04 -05:00
arismoko 9200058875 Native builds: default, dev and release profiles
Native mods compiled with the workspace's `fast` profile, whose
`lto = false` still runs thin LTO over each crate's codegen units. The
default profile now sets `lto = "off"` over `fast` through
`cargo --config`, so the workspace's own `fast` builds stay as they
are: a mod edit compiles faster for about a fifth more tick time.

The player picks default (the everyday build for playing with mods),
dev (horizon-client unoptimized: the quickest rebuild while developing
a mod, for a game about twice as slow) or release (unchanged) on the
Mods page, with `settings native-profile`, `native-build --profile` or
HORIZON_LAUNCHER_NATIVE_PROFILE. An older settings file's release
switch still picks release. Three experimental profiles, named only by
the environment or --profile, keep other settings measurable:
performance (the old `fast` build), balanced and fast-rebuild.

Each profile's name and settings are in the build's key, and each has
its own folder under native/target/.
2026-11-02 00:57:40 +00:00
Ludovic MarieandClaude Opus 5.5 50fdee8614 perf_bench report: runs without --server (tests' Namespace) have no server condition
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:48:06 +01:00
DetimesandClaude Sonnet 5.5 00ad1449e5 Native mods: TNT about to be lit, cancellable (native_tnt_primed)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:46:59 +00:00
DetimesandClaude Sonnet 5.5 d2589c1a6d Docs: drop the paragraph of the hanging break hook
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:29:45 +00:00
DetimesandClaude Sonnet 5.5 b5541225ea Drop a stray copy of the hanging break hook (not part of this PR)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:29:36 +00:00
DetimesandClaude Sonnet 5.5 f8a0ed7d4d Fix the merge of the hanging place hook: one closing brace too many
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:25:50 +00:00
HrafnaValkyrjaandClaude Opus 5.5 39b08f9223 Take entity ids at creation for rockets and loaded tridents
Firework rockets (launched, angled, raid fireworks, NBT load) and tridents
loaded from NBT were pushed with id 0 and got one only when a LAN guest was
connected. They now draw it from the one counter when created, as vanilla's
Entity constructor does. Primed TNT, falling blocks and projectiles already
did at every creation site. Part of #6537.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 19:25:39 -05:00
DetimesandClaude Sonnet 5.5 494184464f Native mods: a server player emptying a bucket, cancellable (native_bucket_empty)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:23:27 +00:00
DetimesandClaude Sonnet 5.5 fbb15eee5f Native mods: a server player placing an item frame, painting or leash knot, cancellable (native_hanging_place)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:23:27 +00:00
DetimesandClaude Sonnet 5.5 860960c596 Drop a stray copy of the teleport hook (it has its own PR)
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:10:37 +00:00
DetimesandClaude Sonnet 5.5 b95ce53d06 Merge upstream/main: keep both hooks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:09:48 +00:00
HrafnaValkyrja 9c6d2c1076 Merge branch 'refactor/stable-entity-ids' into refactor/stable-ids-rest 2026-11-01 19:09:11 -05:00
Ludovic MarieandClaude Opus 5.5 9762e4f3db Mesher: one-biome sections skip the tint blend
When every quart cell the section's blend squares can zoom to holds one
biome, each block's blended tint is that biome's colour (the mean of equal
colours), so Tints::get returns it without the per-block zoom and the
(2r+1)^2 sum. Swamp grass, whose colour follows noise, keeps the blend.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:08:48 +01:00
Ludovic MarieandClaude Opus 5.5 21832e9390 Mesher: face visibility shortcut, call-free rounding, layer-wise fill, bit-row VisGraph
- A face whose neighbour shows a full occlusion face toward it is hidden:
  should_render_face's first test, read from a per-state bit table without
  the visibility lookups.
- round_to_u16 and gray compute f32::round/floor's results without the
  roundf/floorf calls x86-64's baseline makes of them (tested against them).
- Padded::fill finds each chunk column's section once per layer and copies
  fixed-length runs.
- VisGraph's flood fill works on bit rows: each row filled along its open
  runs at once (an occluded fill), regions grown by sweeps over the rows.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:08:23 +01:00
Detimes ff0465854e Merge remote-tracking branch 'upstream/main' into feat-players-connection-hooks 2026-11-02 00:06:19 +00:00
DetimesandClaude Sonnet 5.5 635b622b58 Remove duplicate hook definitions left by the merge
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 00:06:17 +00:00
Ludovic MarieandClaude Opus 5.5 2b78d2638e perf_bench --server-bots N: a crowd of horizon-bots and a scene that flies over them
horizon-bots prints the first bot's spawn position, so the bench knows where
the crowd walks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:00:45 +01:00
Ludovic MarieandClaude Opus 5.5 20e429c3f8 Find a multiplayer client's mobs by id through an index
A client looked a mob up by a linear search of its mob list for every
movement, data, motion, attribute, equipment and event packet. A Mob is
~3.3 KB, so every compared id is its own cache line: in a 3000-mob farm,
the ~1000 movement packets of one tick took 5.5 ms of searching, 0.11 ms
with 1000 mobs. MobIndex keeps id -> position in a map and checks each hit
against the list; a miss falls back to the linear search and rebuilds the
map, so the list keeps changing freely in its ~35 places and the answer is
always the linear search's (ids are unique: spawn_entity drops the old mob
first).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:56:10 +01:00
Ludovic MarieandClaude Opus 5.5 c481f9c68a Pose mob models without allocating part names
anim::Poses keyed every part by an owned String, so each mob drew a new
String per model part for its body and for every layer (wool, clothes,
armour, eyes) every frame: tens of thousands of small allocations a frame
in a mob farm. It now keys parts by the Fx hash of their name (Fx maps
and sets), with the same lookups and first-name-wins rule; a test checks
that no two part names of any vanilla model share a key.

An ignored benchmark poses the stress_mobs farm (1000 mobs of 8 kinds)
through render_with and hashes the vertices: the same hash before and
after, 6.93 -> 5.73 ms of thread CPU per frame, the body's poses
1.28 -> 0.65 ms.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:44:18 +01:00
DetimesandClaude Sonnet 5.5 878e9d5018 Fix the merge of the login hook: one closing brace too many
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 23:41:06 +00:00
Ludovic MarieandClaude Opus 5.5 cffc6f24f6 Merge main into #6521
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:32:12 +01:00
Ludovic MarieandClaude Opus 5.5 db3ef8ecbc Merge main into #6520
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:31:35 +01:00
JustKarkatandJustKarkat's AI agent 81cc6b61d3 Answer the stateless MCP revision from one implementation
The three MCP servers carried their own copy of the version negotiation and
the JSON-RPC line loop. They now share crates/horizon-mcp, so what a client is
told and what it gets cannot drift between horizon-ctl, horizon-server and the
launcher.

The crate serves two revisions. 2026-07-28 is stateless: every request carries
its version and capabilities in _meta, server/discover reports the versions,
capabilities and identity, every result says resultType, tools/list carries
ttlMs and cacheScope, and each result names the server in _meta. 2025-06-18
stays for the clients that open with initialize, which includes the launcher's
game_* tools talking to whichever horizon-ctl the player has installed. A
request with no _meta is answered in the handshake revision's shapes.

A version we do not serve is refused with UnsupportedProtocolVersion (-32022)
listing the ones we do, a modern request missing its capabilities is malformed
(-32602), and ping is answered for the handshake revision only, since
2026-07-28 removed it.

Also regenerates docs/launcher-commands.md, which #6462 left stale (its own
up-to-date test fails on main).

Fixes #5673

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-11-01 17:29:01 -06:00
Ludovic MarieandClaude Opus 5.5 912941a8f7 Streaming coordinator: decoration and generation scheduling cost less
While a world loads, Streaming::update ran on one thread and at render
distance 32 took most of it: DecorationOrder::blocked asked waits_for
(36 lookups) for 24 neighbours of every wanted chunk on every update, in a
SipHash map, and schedule_generation scanned every column of the area each
time a worker finished.
- Decoration ranks use the Fx hasher; waits_for answers are kept for the
  pass (nothing it reads changes in it but decorated, which updates them).
- GenerationScan keeps the sorted candidates of a full scan and takes the
  next batches from them while the ranges stay the same and no column left;
  a removal, a dropped proto chunk or a range change scans again.
The decoration and generation order are unchanged (differential test
against the full scan through jobs finishing, columns and protos leaving
and the centre moving).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:27:23 +01:00
Detimes 4ae5d5425d Native mods: a server player's answer to a resource pack (native_resource_pack_status) 2026-11-01 23:24:10 +00:00
Detimes 37a10b81c4 Native mods: a server player's sprint starting or stopping (native_player_sprint) 2026-11-01 23:24:10 +00:00
Detimes 780ac5470a Native mods: the root commands a player's command tree holds (native_player_commands_sent) 2026-11-01 23:24:10 +00:00
Detimes 6d98c0f8d5 Native mods: a joining player the mods may refuse (native_player_login) 2026-11-01 23:24:10 +00:00
Ludovic MarieandClaude Opus 5.5 b73e5b7c98 perf_bench --server horizon|vanilla: clients join a local dedicated server
Every client of the run (Horizon, vanilla, vanilla with the performance
mods) joins the same fresh local server instead of creating a world:
seed 1234, creative, offline, the bench player an operator so the scenes'
/tp and /time work. A new server world gets an unmeasured warm-up join, so
measured joins compare the clients, not the server's generation.
startup.join_ms is connect to loaded. The vanilla driver connects with
ConnectScreen.startConnecting when its script names a server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:05:03 +01:00
martipopsandClaude Opus 5.5 52650fb1d2 Merge the Setup PR (main merged) into mouse controls
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:58:39 -05:00
martipopsandClaude Opus 5.5 b1259ce32a Merge upstream main into the Setup PR
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:58:04 -05:00
Detimes 894c33d745 Native mods: a server player teleported by a command (native_player_teleported) 2026-11-01 22:44:48 +00:00
Detimes aaf377b9ca Native mods: a totem about to save a player, cancellable (native_player_totem) 2026-11-01 22:38:15 +00:00
SenjinandClaude Opus 5.5 bff4dfc3aa HDR: conduits, the warden's glowing layers and distant terrain shine
Far terrain carries the same tier as the section mesher writes, so a light source keeps
shining across the LOD boundary. The warden's layers shine by how far they've faded in.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 23:14:57 +01:00
Ludovic MarieandClaude Opus 5.5 7ec58998d6 Draw dropped items only as far as vanilla does
ItemEntity keeps Entity.shouldRenderAtSqrDistance: 64 times its 0.25 box,
times the view scale, so 24 blocks at render distance 12, 32 at 16 and 40
from 20 on, measured from its current position. Horizon drew them up to 64
blocks, which posed and uploaded every item of a big storage or farm area
that vanilla leaves out. Item pickup particles keep their 64 blocks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:52:53 +01:00
Ludovic MarieandClaude Opus 5.5 7a52fde28f perf_bench: many-entity stress scenes, per-scene client tick and memory
tools/stress_scenes.py builds nine scenes above the spawn with vanilla
commands (1000 mobs, 2000 dropped items, 512 item frames, 500 armor
stands, 800 containers, 687 signs, 300 banners, steady particles from
redstone-powered command blocks, and an empty baseline) and removes each
afterwards. The same lines run in Horizon (horizon-ctl command) and in
vanilla (the driver mod's new per-scene setup and cleanup).
perf_bench --scenes stress holds each scene still and reports frame CPU,
client tick time (vanilla: Minecraft.tick through a new mixin) and
resident memory per scene.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:48:34 +01:00
martipops cdfe9e9bde Merge branch 'split/fresh-basic-setup' into split/fresh-mouse-controls 2026-11-01 16:14:47 -05:00
martipopsandClaude Opus 5.5 972519acdc Setup's switch says On/Off from its own keys, not vanilla's ON/OFF
With prepared resources, options.on/off made it "Splitscreen: OFF", unlike
the label before localization and what the live splitscreen tests expect.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:14:43 -05:00
martipopsandClaude Opus 5.5 018e50afb0 Localize mouse Setup controls: Identify, Names, layouts, drag stops, tooltips
Layout icons and the Auto tooltip now come from the draft's layout index, not
from parsing the English label, so translated labels keep their glyphs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:47:04 -05:00
martipops 902cb754ea Merge Setup localization into mouse controls
Co-Authored-By: OpenAI Codex <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/split_ui.rs
2026-11-01 15:39:43 -05:00
martipopsandOpenAI Codex b3dcefa2ac Localize the splitscreen Setup screen and its window feedback
Resolve labels, status text, instructions and warnings from the language table with parameterized messages and bundled English defaults. Add French translations and a process-isolated locale/pack-override regression that preserves draft state and literal player names.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 15:38:19 -05:00
martipopsandOpenAI Codex 79e3c7dc27 Merge current main through the Setup controls stack
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 15:36:26 -05:00
martipops adea816573 Merge current main into the Setup draft before localization fixes
Co-Authored-By: OpenAI Codex <[email protected]>

# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-01 15:33:16 -05:00
martipopsandOpenAI Codex 14c3a6e42e Merge portable native mouse fixture updates
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:56:47 -05:00
martipopsandOpenAI Codex a5da7abce2 Keep the native mouse regression fixture portable across filesystem paths
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:56:47 -05:00
martipopsandOpenAI Codex 64148cfe72 Merge the native input ownership fix into Setup controls
Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:53:16 -05:00
martipopsandOpenAI Codex 8ac0773305 Keep native mouse ownership when a second local player joins
Distinguish a visible composited view from a hidden offscreen run. Preserve capture when only the viewport changes, and deliver raw mouse motion only to the keyboard/mouse owner. Add behavioral regressions for capture, real mouse rotation and native-window changes.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:53:16 -05:00
martipopsandOpenAI Codex db240a1925 Enable the saved splitscreen setting in the Setup controls scenario
HORIZON_SPLIT remains a transient test override and does not enable the settings page.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:29:48 -05:00
Detimes 4e70c05497 Native mods: a player's food level changing, changeable (native_food_level_changed) 2026-11-01 19:29:24 +00:00
Detimes ab3ebbdd65 Native mods: a player swapping their hands, cancellable (native_player_swap_hands) 2026-11-01 19:29:23 +00:00
martipopsandClaude Opus 5.5 c837c1619f Splitscreen Setup tests: drop a helper only the controller tests use
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:27:45 -05:00
martipopsandClaude Opus 5.5 3a656be0a4 Splitscreen Setup: drag players, layouts for 2 and 3, Names, Identify
Setup's controls from the full feature (#3541), on the Setup screen:
- drag a player's tile onto a window, a place among its players, or a monitor; dropped where
  it was, nothing changes;
- while a window has two or three players, a square picks how they share it (saved in P1's
  options: splitscreenTwo, splitscreenThree; the views follow it);
- Names (splitscreenNames, on by default): each name stays in its view's corner;
- Identify: every view says its window; tooltips on every button.
13 unit tests and the live scenario 'controls'. Controller pick-up comes next.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:27:45 -05:00
martipopsandOpenAI Codex 69ce6f6c26 Merge current main into the Setup rebuild
Adapt the split map re-export to the extracted client library.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-11-01 14:27:24 -05:00
DetimesandClaude Sonnet 5.5 78295e840f Merge upstream/main: keep both hooks
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:17:10 +00:00
DetimesandClaude Sonnet 5.5 69096a5877 Add native_creative_slot hook
Heard when a creative player sets a slot (or throws a stack out) from the
creative screen, after vanilla's checks, cancellable, with the player's
state swapped in.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:16:54 +00:00
DetimesandClaude Sonnet 5.5 69af122b0d Add native_container_opened hook
Heard when a server player is about to open one of the game's own menus
(chest, furnace, crafting table, villager, station, lectern, horse
inventory), cancellable, with the player's state swapped in.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:16:53 +00:00
DetimesandClaude Sonnet 5.5 3139a6b8c0 Add native_vehicle_moved hook
Heard once a tick for each boat or minecart that carries a server player
(and for ten seconds after one a player left stood still), after it
moved, with the rider's state swapped in.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 19:06:15 +00:00
Ludovic MarieandClaude Opus 5.5 af3a264af1 Net replay bench: horizon-bots --capture, net_replay example
horizon-bots --capture FILE records the first bot's play packets; the
horizon-protocol example net_replay sends them through an in-memory
connection as single player, an offline server and an online-mode server
send them, and prints the CPU of ConnReader::recv, of decoding per packet
type and of parsing chunk sections.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:46:52 +01:00
DetimesandClaude Sonnet 5.5 4d0553be6e Add native_armor_stand_manipulate hook
Heard when a server player takes an item from an armor stand or puts one
on it, after the stand's disabled-slot checks and before anything moves.
Cancellable.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 12:02:27 +00:00
martipopsandOpenAI Codex fb0496a105 Align Setup pause icons and seed the saved switch in live checks
Use the effective split-menu flag consistently for drawing, hit testing and keyboard navigation. Setup fixtures enable the saved switch explicitly while keeping HORIZON_SPLIT transient.

Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-31 23:23:23 -04:00
d48b592ddd Splitscreen: arrange players with a Setup map and Apply/Revert
Combine draft arrangements and monitor-map projection with the usable Setup screen. Preserve current pause dialogs, HDR settings and keyboard navigation. Mouse dragging and controller pickup follow separately.

Co-Authored-By: OpenAI Codex <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:14:48 -04:00
martipopsandOpenAI Codex 8a65c97f3b Merge the profiles prerequisite onto current main for Setup
Co-Authored-By: OpenAI Codex <[email protected]>
2026-10-31 23:14:48 -04:00
474 changed files with 33537 additions and 3557 deletions

No files matched your search

+2 -1
View File
@@ -26,6 +26,7 @@ on:
- 'launcher/**'
- 'crates/horizon-bench/**'
- 'crates/horizon-friends/**'
- 'crates/horizon-mcp/**'
- 'crates/horizon-mods/**'
- 'crates/horizon-port-pack/**'
- 'crates/horizon-prepare/**'
@@ -174,7 +175,7 @@ jobs:
run: |
if ! xcrun metal --version > /dev/null 2>&1; then
echo "::notice::No Metal toolchain on this runner: GPUI compiles its shaders at run time"
echo "LAUNCHER_FEATURES=gpui/runtime_shaders" >> "$GITHUB_ENV"
echo "LAUNCHER_FEATURES=gpui_platform/runtime_shaders" >> "$GITHUB_ENV"
fi
# Every test target (the library, the binary, tests/), on each system the launcher ships for.
- run: cargo test --release --locked ${{ matrix.target && format('--target {0}', matrix.target) || '' }} ${LAUNCHER_FEATURES:+--features "$LAUNCHER_FEATURES"}
+4
View File
@@ -103,6 +103,10 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
- Developing a native mod? Run `horizon-launcher settings native-profile dev` once. `dev` builds the game crate
unoptimized: a mod edit rebuilds about 6× faster than with `performance`, while game logic runs up to about twice as
slow. Use it while developing, not for playing; `performance` stays the default for players and releases
([docs/native-mods.md](docs/native-mods.md)).
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- Harness selection uses Cargo dep-info and reachable-code narrowing (`tools/parity_reach.py`).
`horizon-client-lib` builds the control tool and client probes without gameplay; their dep-info
Generated
+11
View File
@@ -1399,6 +1399,7 @@ dependencies = [
"horizon-friends",
"horizon-hdr",
"horizon-items",
"horizon-mcp",
"horizon-mods",
"horizon-narrator",
"horizon-optifine",
@@ -1461,6 +1462,7 @@ dependencies = [
"horizon-threads",
"horizon-world",
"log",
"lz4_flex",
"miniz_oxide 0.8.9",
"naga",
"png",
@@ -1542,6 +1544,13 @@ dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mcp"
version = "0.1.0"
dependencies = [
"serde_json",
]
[[package]]
name = "horizon-mods"
version = "0.1.0"
@@ -1646,6 +1655,7 @@ dependencies = [
"horizon-bench",
"horizon-chat-filter",
"horizon-core",
"horizon-mcp",
"horizon-release-sign",
"horizon-server-files",
"libc",
@@ -1733,6 +1743,7 @@ dependencies = [
"flate2",
"horizon-core",
"libm",
"log",
"lz4_flex",
"postcard",
"serde",
+2
View File
@@ -17,6 +17,8 @@ members = [
"crates/horizon-server-files",
"crates/horizon-anticheat",
"crates/horizon-chat-filter",
# The Model Context Protocol, shared by horizon-ctl, horizon-server and the launcher.
"crates/horizon-mcp",
"crates/horizon-server",
"crates/horizon-bots",
"crates/horizon-assets",
+60 -2
View File
@@ -14,7 +14,8 @@
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
//!
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
//! It prints one JSON line a second (bots online, chunks received), one with the first bot's spawn position, and
//! writes a summary with `--report`.
//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk
//! it hasn't received, so a player can't outrun the server's chunk sending, on any server.
//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that
@@ -102,6 +103,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
to test what a server takes
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
--report FILE a JSON summary at the end (- prints it after the per-second lines)
--capture FILE record the first bot's play packets (milliseconds since it joined, id, body)
for crates/horizon-protocol/examples/net_replay.rs
";
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -177,6 +180,8 @@ struct Options {
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
report: Option<String>,
/// --capture: where the first bot's play packets go.
capture: Option<String>,
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
timer: f64,
}
@@ -207,6 +212,7 @@ fn options() -> Result<Options, String> {
click: None,
name: "Bot".into(),
report: None,
capture: None,
brand: None,
timer: 1.0,
};
@@ -256,6 +262,7 @@ fn options() -> Result<Options, String> {
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--capture" => o.capture = Some(value),
"--brand" => o.brand = Some(value),
"--give" => {
for part in value.split(',') {
@@ -819,12 +826,53 @@ impl BatchSize {
}
}
/// `--capture`: one record per play packet, little-endian: u32 milliseconds since the
/// bot joined, i32 packet id, u32 body length, then the body (without the id).
struct Capture {
out: std::io::BufWriter<std::fs::File>,
start: Instant,
/// The second last flushed: the process may end before the reader thread does.
flushed: u32,
}
impl Capture {
fn create(path: &str) -> Option<Self> {
match std::fs::File::create(path) {
Ok(file) => Some(Self {
out: std::io::BufWriter::new(file),
start: Instant::now(),
flushed: 0,
}),
Err(e) => {
eprintln!("--capture {path}: {e}");
None
}
}
}
fn record(&mut self, id: i32, body: &[u8]) {
use std::io::Write;
let ms = self.start.elapsed().as_millis() as u32;
let mut head = [0u8; 12];
head[..4].copy_from_slice(&ms.to_le_bytes());
head[4..8].copy_from_slice(&id.to_le_bytes());
head[8..].copy_from_slice(&(body.len() as u32).to_le_bytes());
let _ = self
.out
.write_all(&head)
.and_then(|()| self.out.write_all(body));
if ms / 1000 != self.flushed {
self.flushed = ms / 1000;
let _ = self.out.flush();
}
}
}
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
fn read(
mut reader: horizon_protocol::connection::ConnReader,
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
track: bool,
mut capture: Option<Capture>,
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
@@ -848,6 +896,9 @@ fn read(
return;
}
};
if let Some(c) = &mut capture {
c.record(id, &body);
}
let mut r = Reader::new(&body);
let sent = match id {
cb::KEEP_ALIVE => match r.i64() {
@@ -1007,7 +1058,10 @@ fn run_bot(
{
let (writer, bot) = (writer.clone(), bot.clone());
let track = behaviour == Behaviour::Fight;
thread::spawn(move || read(reader, writer, bot, track));
let capture = (index == 0)
.then(|| o.capture.as_deref().and_then(Capture::create))
.flatten();
thread::spawn(move || read(reader, writer, bot, track, capture));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
@@ -1025,6 +1079,10 @@ fn run_bot(
};
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
if index == 0 {
// Where the bots gather, for a client that comes to watch them (tools/perf_bench.py --server-bots).
println!("{}", json!({ "spawn": spawn }));
}
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
if !o.no_loaded {
send(raw(sb::PLAYER_LOADED, |_| {}));
+92 -18
View File
@@ -239,8 +239,19 @@ impl Action {
pub struct Mute {
pub uuid: String,
pub name: String,
/// Epoch milliseconds.
pub until: i64,
/// Epoch milliseconds; `None`: until `/unmute`.
pub until: Option<i64>,
/// The operator who muted them (`/mute`, `/tempmute`); `None`: the filter.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub by: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
impl Mute {
pub fn active(&self, now: i64) -> bool {
self.until.is_none_or(|until| until > now)
}
}
#[derive(Default, Serialize, Deserialize)]
@@ -312,12 +323,38 @@ impl Guard {
)
}
/// When the player's mute ends, if they are muted at `now`.
pub fn muted_until(&self, uuid: u128, now: i64) -> Option<i64> {
self.mutes
.get(&uuid)
.map(|m| m.until)
.filter(|&until| until > now)
/// The player's mute, if they are muted at `now`.
pub fn muted(&self, uuid: u128, now: i64) -> Option<&Mute> {
self.mutes.get(&uuid).filter(|m| m.active(now))
}
/// The mutes in force at `now`, by name.
pub fn mutes(&self, now: i64) -> Vec<&Mute> {
let mut mutes: Vec<&Mute> = self.mutes.values().filter(|m| m.active(now)).collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
mutes
}
/// An operator's `/mute` (`until` `None`) or `/tempmute`: replaces the player's mute.
pub fn mute(
&mut self,
uuid: u128,
name: &str,
until: Option<i64>,
by: &str,
reason: Option<String>,
) {
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
by: Some(by.to_owned()),
reason,
},
);
self.save();
}
/// A hit by the player at `now`: the hit's number in the window, and what it leads to.
@@ -343,15 +380,24 @@ impl Guard {
let until = now + i64::from(c.mute_minutes) * MINUTE;
// The window starts again when the mute ends.
s.counts_until = until + window;
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until,
},
);
self.save();
// An operator's longer mute stays.
if !self
.mutes
.get(&uuid)
.is_some_and(|m| m.until.is_none_or(|u| u >= until))
{
self.mutes.insert(
uuid,
Mute {
uuid: uuid_string(uuid),
name: name.to_owned(),
until: Some(until),
by: None,
reason: None,
},
);
self.save();
}
Action::Muted { until }
} else {
Action::Refused
@@ -377,7 +423,7 @@ impl Guard {
return;
};
let now = now_millis();
self.mutes.retain(|_, m| m.until > now);
self.mutes.retain(|_, m| m.active(now));
let mut mutes: Vec<Mute> = self.mutes.values().cloned().collect();
mutes.sort_by(|a, b| a.name.cmp(&b.name));
if let Ok(text) = serde_json::to_string_pretty(&MutesFile { mutes }) {
@@ -424,6 +470,34 @@ 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()
}
/// `millis` for players, rounded up: minutes up to an hour, hours up to two days, then days.
pub fn duration_text(millis: i64) -> String {
let up = |unit: i64| (millis + unit - 1) / unit;
let (count, unit) = if millis <= 60 * MINUTE {
(up(MINUTE), "minute")
} else if millis <= 48 * 60 * MINUTE {
(up(60 * MINUTE), "hour")
} else {
(up(24 * 60 * MINUTE), "day")
};
format!("{count} {unit}{}", if count == 1 { "" } else { "s" })
}
pub fn now_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
+74 -6
View File
@@ -120,14 +120,14 @@ fn the_second_hit_mutes_and_the_third_bans() {
let mut g = guard(Config::default());
let id = 7;
assert_eq!(g.strike(id, "Ann", NOW), (1, Action::Refused));
assert_eq!(g.muted_until(id, NOW), None);
assert_eq!(g.muted(id, NOW), None);
let until = NOW + 3 * MIN + 10 * MIN;
assert_eq!(
g.strike(id, "Ann", NOW + 3 * MIN),
(2, Action::Muted { until })
);
assert_eq!(g.muted_until(id, NOW + 4 * MIN), Some(until));
assert_eq!(g.muted_until(id, until), None);
assert_eq!(g.muted(id, NOW + 4 * MIN).unwrap().until, Some(until));
assert_eq!(g.muted(id, until), None);
// The time spent muted doesn't count: 8 minutes after the mute ended is still within the window.
assert_eq!(
g.strike(id, "Ann", until + 8 * MIN),
@@ -190,15 +190,83 @@ fn mutes_survive_a_restart_and_unmute_lifts_them() {
let (_, action) = g.strike(5, "Dee", now);
assert!(matches!(action, Action::Muted { .. }));
let (mut again, _) = Guard::load(Config::default(), &dir);
assert!(again.muted_until(5, now).is_some());
assert!(again.muted(5, now).is_some());
assert_eq!(again.unmute("dee").as_deref(), Some("Dee"));
assert_eq!(again.unmute("dee"), None);
assert_eq!(again.muted_until(5, now), None);
assert_eq!(again.muted(5, now), None);
// An operator's mutes are kept too, with who and why.
again.mute(6, "Eve", None, "Op", Some("spam".into()));
again.mute(7, "Fay", Some(now + MIN), "Op", None);
let (reloaded, _) = Guard::load(Config::default(), &dir);
assert_eq!(reloaded.muted_until(5, now), None);
assert_eq!(reloaded.muted(5, now), None);
let names: Vec<_> = reloaded
.mutes(now)
.iter()
.map(|m| m.name.as_str())
.collect();
assert_eq!(names, ["Eve", "Fay"]);
let eve = reloaded.muted(6, now).unwrap();
assert_eq!(
(eve.until, eve.by.as_deref(), eve.reason.as_deref()),
(None, Some("Op"), Some("spam"))
);
assert_eq!(reloaded.mutes(now + MIN).len(), 1);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_mute_file_from_before_manual_mutes_still_loads() {
let saved: MutesFile = serde_json::from_str(
r#"{"mutes":[{"uuid":"00000000-0000-0000-0000-000000000005","name":"Dee","until":5}]}"#,
)
.unwrap();
assert_eq!(saved.mutes[0].until, Some(5));
assert_eq!(saved.mutes[0].by, None);
}
#[test]
fn the_filter_never_shortens_an_operators_mute() {
let mut g = guard(Config::default());
g.mute(1, "Bo", None, "Op", None);
g.strike(1, "Bo", NOW);
g.strike(1, "Bo", NOW + MIN);
assert_eq!(
g.muted(1, NOW + 60 * MIN).unwrap().by.as_deref(),
Some("Op")
);
// A shorter one is replaced by the filter's.
g.mute(2, "Cy", Some(NOW + MIN), "Op", None);
g.strike(2, "Cy", NOW);
g.strike(2, "Cy", NOW);
assert_eq!(g.muted(2, NOW).unwrap().until, Some(NOW + 10 * MIN));
}
#[test]
fn durations_read_and_print() {
assert_eq!(parse_duration("10m"), Some(10 * MIN));
assert_eq!(parse_duration("2h"), Some(120 * MIN));
assert_eq!(parse_duration("1d"), Some(24 * 60 * MIN));
assert_eq!(parse_duration("1w"), Some(7 * 24 * 60 * MIN));
assert_eq!(parse_duration("30s"), Some(30_000));
for bad in [
"",
"m",
"0m",
"-1m",
"1.5h",
"10",
"10x",
"99999999999999999w",
] {
assert_eq!(parse_duration(bad), None, "{bad}");
}
assert_eq!(duration_text(61_000), "2 minutes");
assert_eq!(duration_text(MIN), "1 minute");
assert_eq!(duration_text(2 * 60 * MIN), "2 hours");
assert_eq!(duration_text(24 * 60 * MIN), "24 hours");
assert_eq!(duration_text(3 * 24 * 60 * MIN), "3 days");
}
#[test]
fn a_missing_words_file_falls_back_to_the_built_in_list() {
let config = Config {
+6
View File
@@ -20,6 +20,8 @@ horizon-client-render = { path = "../horizon-client-render" }
horizon-command = { path = "../horizon-command" }
horizon-items = { path = "../horizon-items" }
horizon-core = { path = "../horizon-core" }
# `horizon-ctl mcp`: the Model Context Protocol, shared with the launcher and the server.
horizon-mcp = { path = "../horizon-mcp" }
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
horizon-optifine = { path = "../horizon-optifine" }
horizon-threads = { path = "../horizon-threads" }
@@ -132,6 +134,10 @@ path = "../horizon-client/src/bin/horizon-ctl/main.rs"
name = "chunk_load_bench"
path = "../horizon-client/examples/chunk_load_bench.rs"
[[example]]
name = "gen_light_bench"
path = "../horizon-client/examples/gen_light_bench.rs"
[[example]]
name = "gameplay_decode_profile"
path = "../horizon-client/examples/gameplay_decode_profile.rs"
+2
View File
@@ -7,6 +7,8 @@ rust-version = "1.90"
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
horizon-assets = { path = "../horizon-assets" }
horizon-bench = { path = "../horizon-bench" }
horizon-client-core = { path = "../horizon-client-core" }
+74 -6
View File
@@ -187,6 +187,7 @@ pub(crate) fn discovery() -> Result<Arc<serde_json::Value>, JoinError> {
let doc: serde_json::Value = agent()
.get(&discovery_url())
.call()
.inspect_err(|e| log::warn!("The services discovery document: {e}"))
.ok()
.and_then(|r| r.into_string().ok())
.and_then(|t| serde_json::from_str(&t).ok())
@@ -253,9 +254,33 @@ pub fn join_request(session: &AuthSession, server_hash: &str) -> String {
.to_string()
}
/// A join that got no answer is worth one more try, as leaving and joining
/// again would be: `join` is safe to repeat, it only records the server's
/// hash. Any other answer is the service's own and stands.
fn with_one_retry(
mut join: impl FnMut() -> Result<(), JoinError>,
pause: Duration,
) -> Result<(), JoinError> {
match join() {
Err(JoinError::Unavailable) => {
std::thread::sleep(pause);
join()
}
result => result,
}
}
/// MinecraftServicesSessionService.joinServer: tell the session service this
/// account is joining the server whose hash is `server_hash`.
/// account is joining the server whose hash is `server_hash`. Horizon tries
/// twice when the service doesn't answer; vanilla asks the player to.
pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
with_one_retry(
|| join_server_once(session, server_hash),
Duration::from_millis(500),
)
}
fn join_server_once(session: &AuthSession, server_hash: &str) -> Result<(), JoinError> {
let doc = discovery()?;
let url = endpoint(&doc, "session", "join").ok_or(JoinError::Unavailable)?;
match agent()
@@ -264,11 +289,19 @@ pub fn join_server(session: &AuthSession, server_hash: &str) -> Result<(), JoinE
.send_string(&join_request(session, server_hash))
{
Ok(_) => Ok(()),
Err(ureq::Error::Status(status, response)) => Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
)),
Err(ureq::Error::Transport(_)) => Err(JoinError::Unavailable),
Err(ureq::Error::Status(status, response)) => {
if status >= 500 {
log::warn!("The session service's join answered {status}");
}
Err(JoinError::from_response(
status,
&response.into_string().unwrap_or_default(),
))
}
Err(ureq::Error::Transport(e)) => {
log::warn!("The session service's join: {e}");
Err(JoinError::Unavailable)
}
}
}
@@ -564,6 +597,41 @@ mod tests {
const UUID: u128 = 0x069a79f444e94726a5befca90e38aaf5;
/// Runs `with_one_retry` over these answers, in order: the result and how
/// many it asked for.
fn joined(answers: &[Result<(), JoinError>]) -> (Result<(), JoinError>, usize) {
let mut asked = 0;
let result = with_one_retry(
|| {
asked += 1;
answers[asked - 1].clone()
},
Duration::ZERO,
);
(result, asked)
}
#[test]
fn a_join_with_no_answer_is_tried_once_more() {
let none = Err(JoinError::Unavailable);
assert_eq!(joined(&[none.clone(), Ok(())]), (Ok(()), 2));
// Twice without an answer: the message the player knows.
assert_eq!(joined(&[none.clone(), none.clone()]), (none, 2));
}
#[test]
fn a_join_the_service_answered_is_not_tried_again() {
assert_eq!(joined(&[Ok(())]), (Ok(()), 1));
for refused in [
JoinError::InvalidSession,
JoinError::Banned,
JoinError::InsufficientPrivileges,
JoinError::Other("Status: 429".into()),
] {
assert_eq!(joined(&[Err(refused.clone())]), (Err(refused), 1));
}
}
fn write(dir: &Path, text: &str, mode: u32) -> std::path::PathBuf {
let path = dir.join("session.json");
std::fs::write(&path, text).unwrap();
@@ -1792,6 +1792,15 @@ impl BlockEntityRenderer {
portal_cube(base, &faces, END_GATEWAY_LAYER, out);
}
Kind::Conduit => {
// HDR output: the conduit shines, not its crumbling overlay.
let start = out.len();
let shine = |out: &mut Vec<Vertex>| {
if !crumbling {
for v in &mut out[start..] {
v.layer |= u32::from(crate::mesher::BRIGHT) << 16;
}
}
};
let (ticks, active, hunting, raw_rotation) = (scene.anim)(pos).conduit;
// ConduitRenderer.extractRenderState.
let rotation = (raw_rotation + if active { scene.partial } else { 0.0 }) * -0.0375;
@@ -1809,6 +1818,7 @@ impl BlockEntityRenderer {
* Mat4::from_translation(Vec3::splat(0.5))
* Mat4::from_rotation_y(rotation.to_radians());
draw_part(model, "root", m, &none, paint, out);
shine(out);
return;
}
let anim_time = ticks as f32 + scene.partial;
@@ -1867,6 +1877,7 @@ impl BlockEntityRenderer {
* Mat4::from_scale(Vec3::splat(1.333_333_4));
draw_part(model, "root", m, &none, paint, out);
}
shine(out);
}
Kind::EnchantingTable => {
let book = "entity/enchantment/enchanting_table_book";
@@ -2141,11 +2152,13 @@ const PORTAL_FACES: [[[f32; 3]; 4]; 6] = [
];
/// `AbstractEndPortalRenderer.submitCube`: position-only quads (the portal
/// shader projects its textures from the screen; uv, colour and light are unused).
/// `rendertype_end_portal` culls back faces, as `entitySolid` does, so the cube's
/// two Y faces are one-sided: only the one facing the camera draws.
fn portal_cube(m: Mat4, faces: &[Direction], layer: u32, out: &mut Vec<Vertex>) {
for &d in faces {
for c in PORTAL_FACES[d as usize] {
out.push(Vertex {
normal: [0; 4],
normal: CULL_BACK,
pos: m.transform_point3(Vec3::from(c)).to_array(),
uv: [0.0; 2],
layer,
@@ -2716,6 +2729,35 @@ mod tests {
assert_eq!(first_line("\u{6771}", 3.0, &advance), "\u{6771}");
assert_eq!(first_line("ab\ncd", 100.0, &advance), "ab");
}
/// `rendertype_end_portal` culls back faces, and `AbstractEndPortalRenderer`
/// submits the unit cube's two Y faces. Both are wound outward, so of each
/// face only the side looking at the camera draws: the portal shows one
/// surface from above and one from below, never both at once.
#[test]
fn the_end_portal_cube_is_one_sided() {
let mut out = Vec::new();
portal_cube(
Mat4::IDENTITY,
&[Direction::Down, Direction::Up],
END_PORTAL_LAYER,
&mut out,
);
// `TheEndPortalBlockEntity.shouldRenderFace` keeps the Y faces only.
assert_eq!(out.len(), 8, "the cube submits both Y faces");
assert!(
out.iter().all(|v| v.normal == CULL_BACK),
"rendertype_end_portal culls back faces"
);
// Each face is wound outward, so the shader's `!front` discard keeps the
// camera-facing side of it and drops the other.
let facing = |quad: &[Vertex]| {
let p = |i: usize| Vec3::from(quad[i].pos);
(p(1) - p(0)).cross(p(2) - p(0))
};
assert!(facing(&out[..4]).y < 0.0, "the Down face looks down");
assert!(facing(&out[4..]).y > 0.0, "the Up face looks up");
}
/// `DecoratedPotRenderer` submits every part with `RenderTypes.entitySolid`,
/// whose pipeline culls back faces. The pot's `top` and `bottom` are flat
/// boxes, so without that cull both sides of each plane are drawn and they
+223 -17
View File
@@ -285,20 +285,122 @@ fn forget_shared() {
*shared = None;
}
}
/// Packed light per block: sky light in the high nibble, block light in the low one.
/// Packed light per block: sky light in the high nibble, block light in the
/// low one. A section keeps only the kind of light that varies in it
/// (`Light::packed`): most sections have no block light and only sky light
/// that changes, or the reverse below ground.
#[derive(Clone)]
pub enum Light {
Uniform(u8),
/// Sky light per block, as nibbles (block `i` in byte `i / 2`, the even
/// one in the low nibble, as a vanilla DataLayer), and one block light.
Sky(Box<[u8; 2048]>, u8),
/// Block light per block, as nibbles, and one sky light.
Block(Box<[u8; 2048]>, u8),
Full(Box<[u8; 4096]>),
}
#[inline]
fn nibble(layer: &[u8; 2048], i: usize) -> u8 {
layer[i >> 1] >> ((i & 1) << 2) & 15
}
impl Light {
#[inline]
pub fn get(&self, i: usize) -> u8 {
match self {
Self::Uniform(v) => *v,
Self::Sky(layer, block) => nibble(layer, i) << 4 | block,
Self::Block(layer, sky) => sky << 4 | nibble(layer, i),
Self::Full(a) => a[i],
}
}
/// The smallest form of packed light per block.
pub fn packed(light: Box<[u8; 4096]>) -> Self {
let first = light[0];
let (mut sky_varies, mut block_varies) = (false, false);
for &v in light.iter() {
let d = v ^ first;
sky_varies |= d & 0xF0 != 0;
block_varies |= d & 15 != 0;
}
let layer = |shift: u32| {
let mut layer = Box::new([0u8; 2048]);
for (out, pair) in layer.iter_mut().zip(light.as_chunks::<2>().0) {
*out = (pair[0] >> shift & 15) | (pair[1] >> shift & 15) << 4;
}
layer
};
match (sky_varies, block_varies) {
(false, false) => Self::Uniform(first),
(true, false) => Self::Sky(layer(4), first & 15),
(false, true) => Self::Block(layer(0), first >> 4),
(true, true) => Self::Full(light),
}
}
/// The packed light of blocks `at..at + out.len()`, into `out`.
#[inline]
pub fn copy_run(&self, at: usize, out: &mut [u8]) {
match self {
Self::Uniform(v) => out.fill(*v),
Self::Full(a) => out.copy_from_slice(&a[at..at + out.len()]),
Self::Sky(layer, block) => {
for (k, o) in out.iter_mut().enumerate() {
*o = nibble(layer, at + k) << 4 | block;
}
}
Self::Block(layer, sky) => {
for (k, o) in out.iter_mut().enumerate() {
*o = sky << 4 | nibble(layer, at + k);
}
}
}
}
/// The packed light of every block, in index order.
pub fn to_array(&self) -> Box<[u8; 4096]> {
match self {
Self::Full(a) => a.clone(),
Self::Uniform(v) => Box::new([*v; 4096]),
_ => Box::new(std::array::from_fn(|i| self.get(i))),
}
}
/// Whether any block's packed light has a bit of `mask` set.
pub fn any(&self, mask: u8) -> bool {
let layer_any = |layer: &[u8; 2048]| layer.iter().any(|&v| v != 0);
match self {
Self::Uniform(v) => v & mask != 0,
Self::Sky(layer, block) => block & mask != 0 || (mask & 0xF0 != 0 && layer_any(layer)),
Self::Block(layer, sky) => {
(sky << 4) & mask != 0 || (mask & 15 != 0 && layer_any(layer))
}
Self::Full(a) => a.iter().any(|v| v & mask != 0),
}
}
/// Whether any block's packed light differs from `v` in the bits of `mask`.
pub fn any_not(&self, mask: u8, v: u8) -> bool {
match self {
Self::Uniform(u) => (u ^ v) & mask != 0,
Self::Full(a) => a.iter().any(|u| (u ^ v) & mask != 0),
_ => (0..4096).any(|i| (self.get(i) ^ v) & mask != 0),
}
}
/// Heap bytes it holds.
pub fn heap_bytes(&self) -> usize {
match self {
Self::Uniform(_) => 0,
Self::Sky(..) | Self::Block(..) => 2048,
Self::Full(_) => 4096,
}
}
/// Whether every block's light is the same as in `other`.
pub fn same_as(&self, other: &Self) -> bool {
match (self, other) {
(Self::Uniform(a), Self::Uniform(b)) => a == b,
(Self::Full(a), Self::Full(b)) => a == b,
(Self::Sky(a, x), Self::Sky(b, y)) | (Self::Block(a, x), Self::Block(b, y)) => {
x == y && a == b
}
_ => (0..4096).all(|i| self.get(i) == other.get(i)),
}
}
}
#[inline]
fn column(x: i32, z: i32) -> usize {
@@ -347,14 +449,15 @@ impl Section {
pub fn is_uniform(&self, v: u32) -> bool {
self.blocks.is_uniform(v)
}
/// Whether every block, biome and light value is the same as `other`'s.
pub fn same_as(&self, other: &Self) -> bool {
self.blocks == other.blocks
&& self.biomes == other.biomes
&& self.light.same_as(&other.light)
}
/// Heap bytes held by the block, biome and light storage (benchmark accounting).
pub fn heap_bytes(&self) -> usize {
self.blocks.heap_bytes()
+ self.biomes.heap_bytes()
+ match &self.light {
Light::Uniform(_) => 0,
Light::Full(_) => 4096,
}
self.blocks.heap_bytes() + self.biomes.heap_bytes() + self.light.heap_bytes()
}
/// Collapse storage that became uniform, and drop palette entries no
/// block uses any more.
@@ -366,13 +469,47 @@ impl Section {
*container = PalettedContainer::new(container.strategy(), v);
}
}
if let Light::Full(a) = &self.light
&& a.iter().all(|&b| b == a[0])
{
self.light = Light::Uniform(a[0]);
// Only the kind of light that varies is kept.
if matches!(self.light, Light::Full(_)) {
let Light::Full(a) = std::mem::replace(&mut self.light, Light::Uniform(0)) else {
unreachable!()
};
self.light = Light::packed(a);
}
}
}
/// The heap bytes of a set of columns by what holds them (`Chunk::census`,
/// `horizon-ctl memory`).
#[derive(Clone, Copy, Debug, Default)]
pub struct ColumnCensus {
pub columns: usize,
/// Section slots of every column, shared ones included.
pub sections: usize,
/// Distinct section allocations.
pub unique_sections: usize,
/// Distinct sections of a single block state (no data array).
pub uniform_block_sections: usize,
/// Distinct sections with light per block (not one value).
pub light_arrays: usize,
pub block_bytes: usize,
pub biome_bytes: usize,
pub light_bytes: usize,
pub heightmap_bytes: usize,
/// The section records themselves (`Arc<Section>`).
pub section_record_bytes: usize,
/// The column records and their lists of sections.
pub column_bytes: usize,
}
impl ColumnCensus {
pub fn total_bytes(&self) -> usize {
self.block_bytes
+ self.biome_bytes
+ self.light_bytes
+ self.heightmap_bytes
+ self.section_record_bytes
+ self.column_bytes
}
}
#[derive(Clone)]
pub struct Chunk {
pub x: i32,
@@ -564,19 +701,61 @@ impl Chunk {
}
#[inline]
pub fn light(&self, x: i32, y: i32, z: i32) -> u8 {
let s = (y - self.min_y) >> 4;
if s < 0 {
return 0;
match self.section_at(y) {
Ok(section) => section.light(index(x, y, z)),
Err(light) => light,
}
if s >= self.sections.len() as i32 {
return 0xF0;
}
/// The section holding block height `y`, or the light of the air outside the
/// column's sections there: none below them, full sky above.
#[inline]
pub fn section_at(&self, y: i32) -> Result<&Section, u8> {
match self.section_index(y) {
Some(s) => Ok(&self.sections[s]),
None if y < self.min_y => Err(0),
None => Err(0xF0),
}
self.sections[s as usize].light(index(x, y, z))
}
/// Heap bytes held by the sections (benchmark accounting).
pub fn heap_bytes(&self) -> usize {
self.sections.iter().map(|s| s.heap_bytes()).sum::<usize>()
}
/// Adds this column's heap bytes to `census` by what holds them. A
/// section shared with another column (`share_uniform`, or a copy not yet
/// written to) counts once: `seen` holds the sections counted so far.
pub fn census(
&self,
census: &mut ColumnCensus,
seen: &mut crate::fast_hash::FxHashSet<*const Section>,
) {
census.columns += 1;
census.sections += self.sections.len();
census.column_bytes += std::mem::size_of::<Self>()
+ self.sections.capacity() * std::mem::size_of::<Arc<Section>>();
census.heightmap_bytes += [&self.surface, &self.motion_blocking]
.iter()
.filter(|map| map.get().is_some())
.count()
* std::mem::size_of::<[i16; 256]>();
for section in &self.sections {
if !seen.insert(Arc::as_ptr(section)) {
continue;
}
census.unique_sections += 1;
// The `Arc`'s two counts and the section record.
census.section_record_bytes +=
2 * std::mem::size_of::<usize>() + std::mem::size_of::<Section>();
if section.blocks.bits() == 0 {
census.uniform_block_sections += 1;
}
census.block_bytes += section.blocks.heap_bytes();
census.biome_bytes += section.biomes.heap_bytes();
if section.light.heap_bytes() != 0 {
census.light_arrays += 1;
census.light_bytes += section.light.heap_bytes();
}
}
}
/// Biome table index at a block position (`ChunkAccess.getNoiseBiome` with
/// the quart y clamped to the column).
#[inline]
@@ -674,6 +853,33 @@ pub struct ChunkExtras {
mod tests {
use super::*;
#[test]
fn each_light_form_keeps_every_value_and_run() {
// Sky varies alone, block varies alone, both, neither.
let cases: [(fn(usize) -> u8, &str); 4] = [
(|i| ((i % 16) as u8) << 4 | 3, "sky"),
(|i| 0xA0 | (i / 256) as u8, "block"),
(|i| (i % 251) as u8, "full"),
(|_| 0x7C, "uniform"),
];
for (value, name) in cases {
let light = Light::packed(Box::new(std::array::from_fn(value)));
let form = match light {
Light::Sky(..) => "sky",
Light::Block(..) => "block",
Light::Full(_) => "full",
Light::Uniform(_) => "uniform",
};
assert_eq!(form, name);
for at in [0, 17, 1000, 4078] {
let mut run = [0u8; 18];
light.copy_run(at, &mut run);
let expected: Vec<u8> = (at..at + 18).map(value).collect();
assert_eq!(run.as_slice(), expected, "{name} at {at}");
assert_eq!(light.get(at + 5), value(at + 5), "{name}");
}
}
}
#[test]
fn highest_skips_only_sections_that_cannot_match() {
let (air, cave_air, stone) = (0, 7, 1);
let mut chunk = Chunk::empty(0, 0);
+2 -1
View File
@@ -518,7 +518,8 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
}
}
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
/// Whether `name` is one of the shared worker pool's threads (`horizon_threads::jobs`), vanilla's
/// `Util` worker threads.
fn is_worker_thread(name: Option<&str>) -> bool {
name.is_some_and(|n| n.starts_with("horizon-worker-"))
}
+3 -1
View File
@@ -351,7 +351,9 @@ fn shadow_color(in: Out) -> vec4<f32> {
if in.tier == 0u {
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
}
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb) * (1.0 - fogged)));
// A layer that fades (the warden's) shines by what's left of it.
let y = brightness(texel.rgb * in.color.rgb) * in.color.a * (1.0 - fogged);
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, y));
}
fn brightness(c: vec3<f32>) -> f32 {
let l = pow(max(c, vec3(0.0)), vec3(2.2));
+39 -2
View File
@@ -120,10 +120,21 @@ pub struct Extras {
/// Agents may control the game the player starts: it opens its control socket for
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
pub agent_control: bool,
/// F3+R and F3+E start/stop a repro recording and write its bundle (off by
/// default: a recording copies the world).
pub repro_keys: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
/// Two players in one window: 0 auto (side by side when wider than tall), 1 side by side,
/// 2 top and bottom (`splitscreenTwo`).
pub splitscreen_two: u8,
/// Three players on a screen: 0 one wide on top, 1 one tall on the left, 2 side by side
/// (`splitscreenThree`).
pub splitscreen_three: u8,
/// Splitscreen: each player's name in the corner of their view (`splitscreenNames`).
pub splitscreen_names: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -160,8 +171,12 @@ impl Default for Extras {
chat_indicator: true,
no_ai: false,
agent_control: false,
repro_keys: false,
discord_presence: true,
splitscreen: false,
splitscreen_two: 0,
splitscreen_three: 0,
splitscreen_names: true,
}
}
}
@@ -240,8 +255,12 @@ impl Extras {
"chatIndicator" => e.chat_indicator = flag(e.chat_indicator),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"reproKeys" => e.repro_keys = flag(e.repro_keys),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
"splitscreenTwo" => e.splitscreen_two = value.parse().unwrap_or(0).min(2),
"splitscreenThree" => e.splitscreen_three = value.parse().unwrap_or(0).min(2),
"splitscreenNames" => e.splitscreen_names = flag(e.splitscreen_names),
_ => {}
}
}
@@ -249,7 +268,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -287,8 +306,12 @@ impl Extras {
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.repro_keys),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
self.splitscreen_two,
self.splitscreen_three,
bool_text(self.splitscreen_names),
)
}
/// Binds the extra key `name` (`SORT_KEY`); false for
@@ -402,6 +425,10 @@ impl Cps {
self.clicks.len()
}
}
/// Two players sharing a window (`splitscreen_two`): halves this way.
pub const SPLIT_TWO: [&str; 3] = ["Auto", "Side by side", "Top and bottom"];
/// Three players sharing a window (`splitscreen_three`).
pub const SPLIT_THREE: [&str; 3] = ["One wide on top", "One tall on the left", "Side by side"];
/// The settings the Extras page shows, one per row.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -425,6 +452,8 @@ pub enum Item {
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the AI Agent page, next to Agent Control: the F3 recording shortcuts.
ReproKeys,
/// On the Splitscreen settings page.
Splitscreen,
}
@@ -469,6 +498,7 @@ impl Item {
Item::SortKey => "horizon.extras.sort_key",
Item::NoAi => "horizon.extras.no_ai",
Item::AgentControl => "horizon.extras.agent_control",
Item::ReproKeys => "horizon.extras.repro_keys",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
@@ -490,6 +520,7 @@ impl Item {
Item::SortKey => "horizon.extras.sort_key.tooltip",
Item::NoAi => "horizon.extras.no_ai.tooltip",
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::ReproKeys => "horizon.extras.repro_keys.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
@@ -522,6 +553,7 @@ impl Item {
Item::ChatIndicator => "Message indicator",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::ReproKeys => "Recording shortcuts",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
}
@@ -535,7 +567,7 @@ impl Item {
SortKey => Sort,
ChatIndicator => ChatAnimation,
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | DiscordPresence | Splitscreen
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation => {
return None;
}
@@ -588,6 +620,9 @@ impl Item {
Item::AgentControl => {
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
}
Item::ReproKeys => {
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
}
}
}
/// The frame-time cost the main switch of an extra shows.
@@ -623,6 +658,7 @@ impl Item {
Item::ChatIndicator => e.chat_indicator,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::ReproKeys => e.repro_keys,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
_ => return None,
@@ -655,6 +691,7 @@ impl Item {
Item::ChatIndicator => e.chat_indicator = !e.chat_indicator,
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::ReproKeys => e.repro_keys = !e.repro_keys,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::SortKey => {}
+269 -142
View File
@@ -110,9 +110,9 @@ mod arena;
pub mod compositor;
mod cull;
mod end_sky;
pub mod entity_skin;
mod flat;
mod frame_gen;
mod gpu_lists;
mod hdr;
mod hdr_icc;
mod hdr_stats;
@@ -358,8 +358,6 @@ struct SectionGpu {
sorted_indices: Option<(u32, u64)>,
/// Its id for flat draws (`flat::Flat::add`), when they are on.
flat_id: Option<u32>,
/// Its row for phase 1's lists made on the GPU (`gpu_lists::GpuLists::add`).
list_id: Option<u32>,
/// When it began to fade in over the far terrain it replaces, which
/// stays whole behind it until then: a section new where far tiles are
/// drawn. Meshed again, it stays as it was.
@@ -450,19 +448,12 @@ impl Part {
/// 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` and, in the section arena, the ranges' flat draw ids and
/// GPU list rows (`ids`) along. False when the other pages have no room left.
/// The flat draws and GPU lists that name a mesh's range, with what writes to them.
type MeshIds<'a> = (
Option<&'a mut flat::Flat>,
Option<&'a mut gpu_lists::GpuLists>,
&'a wgpu::Device,
&'a mut upload::Uploads,
);
/// copies into `encoder` and, in the section arena, the ranges' flat draw ids
/// (`flat`) along. False when the other pages have no room left.
fn move_off_page<'a>(
arena: &mut arena::Arena,
encoder: &mut wgpu::CommandEncoder,
mut ids: Option<MeshIds<'_>>,
mut flat: Option<(&mut flat::Flat, &wgpu::Device, &mut upload::Uploads)>,
meshes: impl Iterator<Item = &'a mut SectionGpu>,
page: u32,
part: Part,
@@ -484,14 +475,9 @@ fn move_off_page<'a>(
};
arena.copy(encoder, (page, start), to, vertices);
arena.release(page, start, vertices);
if let Some((flat, lists, device, uploads)) = &mut ids {
if let (Some(flat), Some(id)) = (flat, s.flat_id) {
flat.remove(id);
s.flat_id = Some(flat.add(device, encoder, uploads, to.0, to.1, vertices));
}
if let (Some(lists), Some(id)) = (lists, s.list_id) {
lists.set(device, encoder, uploads, id, to.1 as u32, &s.sides);
}
if let (Some((flat, device, uploads)), Some(id)) = (&mut flat, s.flat_id) {
flat.remove(id);
s.flat_id = Some(flat.add(device, encoder, uploads, to.0, to.1, vertices));
}
part.set(s, to);
*budget = budget.saturating_sub(bytes);
@@ -642,6 +628,9 @@ pub struct Frame<'a> {
pub glowing: &'a [Vertex],
/// Runs of `entities` by entity, while a shader pack draws them (`pack_draw::EntitySpan`).
pub entity_spans: &'a [pack_draw::EntitySpan],
/// Ordinary entity models posed on the GPU (`entity_skin`), drawn before `entities`'
/// ordinary ones; only when `Renderer::skins_entities` said so.
pub skin: Option<&'a entity_skin::SkinFrame>,
pub weather: &'a [crate::weather::Vertex],
pub weather_time: crate::weather::Time,
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
@@ -788,12 +777,11 @@ pub struct Renderer {
solid_opaque: wgpu::RenderPipeline,
solid_cutout: wgpu::RenderPipeline,
translucent: wgpu::RenderPipeline,
translucent_mark: wgpu::RenderPipeline,
/// Flat section draws (`flat`), where multi-draw is emulated.
flat: Option<flat::Flat>,
/// `flat` draws this frame's sections (off: the per-section draws).
flat_on: bool,
/// Phase 1's solid lists made on the GPU, in flat frames (with `flat`).
gpu_lists: Option<gpu_lists::GpuLists>,
/// Occlusion culling of solid section draws, where the GPU's instance
/// counts reach a multi-draw's entries (Vulkan, Metal).
cull: Option<cull::Cull>,
@@ -873,6 +861,8 @@ pub struct Renderer {
entity_buffer: wgpu::Buffer,
entity_capacity: u64,
entity_batch: EntityBatch,
/// Ordinary entity models posed on the GPU; `None` without compute shaders.
entity_skin: Option<entity_skin::EntitySkin>,
group: wgpu::BindGroup,
globals: wgpu::Buffer,
/// The resource packs' LabPBR maps (normals, specular), for native shader packs.
@@ -1034,6 +1024,8 @@ pub struct Renderer {
pack_atlas: Option<pack_atlas::PackAtlas>,
/// The pack's terrain program reads flat draws' vertices (`pack_terrain_inputs`).
pack_terrain_flat: bool,
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
pub separate_ao: bool,
pub hud: HudRenderer,
/// Vanilla's menu background blur, made on first use.
menu_blur: Option<crate::menu_blur::MenuBlur>,
@@ -1540,37 +1532,64 @@ async fn open_device(
api: GraphicsApi,
layers: u32,
) -> Result<Opened> {
// A software adapter (WARP, llvmpipe) is a last resort: a later backend may
// have the real GPU (#3978: a Fermi GPU with no Vulkan and no DX12 driver
// still has OpenGL).
match open_device_with(window, offscreen, api, layers, false).await {
Ok(opened) => Ok(opened),
Err(first) => match open_device_with(window, offscreen, api, layers, true).await {
let wgpu_backend = std::env::var_os("WGPU_BACKEND").is_some();
let mut first = None;
for (order, allow_cpu) in device_search(api.with_env(), std::env::consts::OS, wgpu_backend) {
match open_device_with(window, offscreen, &order, layers, allow_cpu).await {
Ok(opened) => {
log::warn!(
"Horizon renderer: no GPU adapter could be used ({first}); \
rendering on the CPU, which is slow"
);
Ok(opened)
if let Some(first) = &first {
log::warn!(
"Horizon renderer: the chosen graphics API gave no GPU ({first}); {}",
if allow_cpu {
"rendering on the CPU, which is slow"
} else {
"using the GPU through another one"
}
);
}
return Ok(opened);
}
Err(_) => Err(first),
},
Err(e) => {
first.get_or_insert(e);
}
}
}
Err(first.unwrap_or_else(|| NO_GPU_DRIVER.into()))
}
/// `open_device`, taking an adapter of the CPU type (software rendering) only
/// when `allow_cpu`.
/// The tries `open_device` makes, in turn: the chosen API's backends (`backend_order`) on a
/// real GPU; when the player chose one API, the other backends `Auto` would try, on a real
/// GPU; and last the chosen backends with a software adapter (WARP, llvmpipe) allowed.
/// Software rendering takes most of a weak CPU even on the title screen: an Intel HD 2500 has
/// DirectX 12 only through WARP and its GPU through OpenGL (#5185), and a Fermi GPU has no
/// Vulkan or DirectX 12 driver but OpenGL (#3978). DirectX 12 never falls back to Vulkan: the
/// launcher chooses it after Vulkan crashed in the driver (#746), which the game can't catch.
fn device_search(
api: GraphicsApi,
os: &str,
wgpu_backend: bool,
) -> Vec<(Vec<wgpu::Backends>, bool)> {
let chosen = backend_order(api, os, wgpu_backend);
let others: Vec<_> = backend_order(GraphicsApi::Auto, os, wgpu_backend)
.into_iter()
.filter(|b| !chosen.contains(b))
.filter(|&b| !(api == GraphicsApi::Dx12 && b == wgpu::Backends::VULKAN))
.collect();
let mut tries = vec![(chosen.clone(), false)];
if !others.is_empty() {
tries.push((others, false));
}
tries.push((chosen, true));
tries
}
/// `open_device` on `order`'s backends in turn, taking an adapter of the CPU type
/// (software rendering) only when `allow_cpu`.
async fn open_device_with(
window: &Arc<Window>,
offscreen: bool,
api: GraphicsApi,
order: &[wgpu::Backends],
layers: u32,
allow_cpu: bool,
) -> Result<Opened> {
let order = backend_order(
api.with_env(),
std::env::consts::OS,
std::env::var_os("WGPU_BACKEND").is_some(),
);
let mut failures = Vec::new();
for (attempt, backends) in order.iter().copied().enumerate() {
let name = backends_name(backends);
@@ -1825,6 +1844,53 @@ impl Renderer {
let arenas = self.arena.bytes() + translucent.map_or(0, arena::Arena::bytes);
[arenas, vertices * stride]
}
/// Device memory the backend's allocator holds, by the label of what holds
/// it, largest first, with its allocated and reserved totals (Vulkan and
/// DirectX 12; none on Metal and OpenGL) (`horizon-ctl memory`).
pub fn gpu_memory_by_label(&self) -> Option<(u64, u64, Vec<(String, u64)>)> {
let report = self.device.generate_allocator_report()?;
let mut by_label: HashMap<String, u64> = HashMap::new();
for a in report.allocations {
*by_label.entry(a.name).or_default() += a.size;
}
let mut labels: Vec<_> = by_label.into_iter().collect();
labels.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
Some((
report.total_allocated_bytes,
report.total_reserved_bytes,
labels,
))
}
/// Heap bytes of the renderer's per-section bookkeeping (`horizon-ctl memory`):
/// resident sections, far tiles, block entities and light sources by section.
pub fn cpu_census(&self) -> [(&'static str, usize); 4] {
use std::mem::size_of;
let per_section = |map_len: usize, lists: usize| {
map_len * (size_of::<SectionKey>() + size_of::<Vec<([i32; 3], u32)>>() + 1)
+ lists * size_of::<([i32; 3], u32)>()
};
let lists = |map: &crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>| {
per_section(
map.capacity(),
map.values().map(Vec::capacity).sum::<usize>(),
)
};
let animated: usize = self
.sections
.values()
.map(|s| s.animated_layers.len() * size_of::<u16>())
.sum();
[
("resident_sections", self.sections.heap_bytes() + animated),
(
"far_tiles",
self.lods.capacity()
* (size_of::<crate::lod::TileKey>() + size_of::<lod::Tile>() + 1),
),
("block_entities", lists(&self.block_entities)),
("light_sources", lists(&self.light_blocks)),
]
}
}
impl Renderer {
pub async fn new(
@@ -2217,6 +2283,17 @@ impl Renderer {
true,
Some(alpha_blend),
);
let translucent_mark = pipeline(
"Terrain translucent HDR mark",
"vs_terrain",
"fs_translucent_mark",
&terrain_buffers,
tri,
Some(wgpu::Face::Back),
false,
true,
Some(flat::ALPHA_ONLY),
);
let sky = pipeline(
"Sky",
"vs_sky",
@@ -3460,10 +3537,14 @@ impl Renderer {
mapped_at_creation: false,
});
let entity_capacity = 1 << 14;
let entity_skin = entity_skin::EntitySkin::new(
&device,
animate::SpriteAnimator::compute(&adapter, &device),
);
let entity_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity vertices"),
size: entity_capacity * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
usage: entity_skin::EntitySkin::usage(entity_skin.is_some()),
mapped_at_creation: false,
});
let flat_wanted = flat::wanted(&adapter, &device);
@@ -3514,7 +3595,6 @@ impl Renderer {
.get_downlevel_capabilities()
.flags
.contains(wgpu::DownlevelFlags::INDIRECT_EXECUTION);
let gpu_lists = flat.as_ref().map(|_| gpu_lists::GpuLists::new(&device));
let batchable = translucent_batchable(
&adapter.get_info(),
multi_draw,
@@ -3628,8 +3708,8 @@ impl Renderer {
solid_opaque,
solid_cutout,
translucent,
translucent_mark,
flat_on: flat.is_some(),
gpu_lists,
cull_on: cull.is_some(),
sides_on: true,
cull,
@@ -3691,6 +3771,7 @@ impl Renderer {
entity_buffer,
entity_capacity,
entity_batch: EntityBatch::default(),
entity_skin,
group,
globals,
pbr_maps,
@@ -3778,6 +3859,7 @@ impl Renderer {
pack_weather: None,
pack_atlas: None,
pack_terrain_flat: false,
separate_ao: false,
hud,
menu_blur: None,
post_effects: None,
@@ -3816,6 +3898,31 @@ impl Renderer {
pass.draw_indexed(0..quads * 6, (first * 4) as i32, 0..1);
}
}
/// HDR lighting on translucent terrain, over the draws that just blended it.
fn mark_translucent_terrain(
&self,
pass: &mut wgpu::RenderPass<'_>,
flat: Option<&flat::Flat>,
solid_draws: u32,
translucent_draws: u32,
) {
if self.hdr.as_ref().is_none_or(|h| !h.lighting) {
return;
}
match flat {
Some(flat) => {
pass.set_pipeline(&flat.translucent_mark);
flat.draw(pass, flat.translucent_calls(), |p| self.arena.buffer(p));
pass.set_bind_group(0, &self.group, &[]);
}
None => {
pass.set_pipeline(&self.translucent_mark);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections(pass, &self.indirect, 0, solid_draws, translucent_draws);
}
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
/// LevelRenderer.submitBlockOutline: the buffer holds `edges` per stroke, the black
/// secondary stroke first when high contrast is on.
fn draw_outline(&self, pass: &mut wgpu::RenderPass<'_>, edges: u32, high_contrast: bool) {
@@ -4176,11 +4283,6 @@ impl Renderer {
} else {
None
};
let sides = if mesh.sides.quads() == solid {
mesh.sides
} else {
Sides::unsorted(solid)
};
// Flat draws name the blocks of its range in `arena`: split, its solid
// quads alone (no frame draws flat then: `translucent_split`).
let flat_id = self.flat.as_mut().filter(|_| main > 0).map(|f| {
@@ -4193,15 +4295,6 @@ impl Renderer {
main,
)
});
let list_id = self.gpu_lists.as_mut().filter(|_| main > 0).map(|l| {
l.add(
&self.device,
encoder,
&mut self.section_uploads,
start as u32,
&sides,
)
});
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let translucent_arena = self.translucent_arena.as_ref().unwrap_or(&self.arena);
for (part, arena, (part_page, first)) in [
@@ -4232,7 +4325,11 @@ impl Renderer {
start,
translucent_at,
solid,
sides,
sides: if mesh.sides.quads() == solid {
mesh.sides
} else {
Sides::unsorted(solid)
},
translucent,
sort: translucency::SortState::new(
mesh.translucent
@@ -4244,7 +4341,6 @@ impl Renderer {
),
sorted_indices,
flat_id,
list_id,
fading: None,
leaving: None,
animated_layers,
@@ -4317,9 +4413,6 @@ impl Renderer {
if let (Some(flat), Some(id)) = (&mut self.flat, s.flat_id) {
flat.remove(id);
}
if let (Some(lists), Some(id)) = (&mut self.gpu_lists, s.list_id) {
lists.remove(id);
}
if let Some((page, start)) = s.sorted_indices {
self.sorted_indices
.release(page, start, u64::from(s.translucent) * 6);
@@ -4362,15 +4455,13 @@ impl Renderer {
.sections
.values_mut()
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
let ids = Some((
self.flat.as_mut(),
self.gpu_lists.as_mut(),
&self.device,
&mut self.section_uploads,
));
let flat = self
.flat
.as_mut()
.map(|f| (f, &self.device, &mut self.section_uploads));
let split = self.translucent_arena.is_some();
let (arena, part) = (&mut self.arena, Part::Section { split });
if !move_off_page(arena, encoder, ids, meshes, page, part, &mut budget) {
if !move_off_page(arena, encoder, flat, meshes, page, part, &mut budget) {
// The other pages have no room after all.
arena.reopen();
}
@@ -4676,11 +4767,6 @@ impl Renderer {
flat.remove(id);
}
}
if let Some(lists) = &mut self.gpu_lists {
for id in released.iter().filter_map(|s| s.list_id) {
lists.remove(id);
}
}
self.sorted_indices
.release_all(released.iter().filter_map(|s| {
let (page, start) = s.sorted_indices?;
@@ -4881,6 +4967,23 @@ impl Renderer {
}
/// The share of real frames frame generation shows a made frame before (1 while it
/// keeps up), `None` while it isn't running.
/// Whether the game may pose ordinary entity models on the GPU (`Frame::skin`): not
/// while a shader pack or a native pack draws entities from their vertices, nor while
/// frame generation follows them.
/// The posed models' store size (`SkinFrame::base`), with entity skinning.
pub fn skinned_models(&self) -> Option<u32> {
self.entity_skin
.as_ref()
.map(entity_skin::EntitySkin::stored)
}
pub fn skins_entities(&self) -> bool {
self.entity_skin.is_some()
&& !self.frame_gen.on
&& self.pack_post.is_none()
&& self.native.is_none()
}
pub fn frame_generation_share(&self) -> Option<f32> {
self.frame_gen.made_share
}
@@ -4975,6 +5078,16 @@ impl Renderer {
self.pack_post = None;
self.native = None;
self.pack_atlas = None;
self.separate_ao = false;
}
/// Sets whether shader pack terrain inputs pass vertex alpha to `hz_Color.a` (`separateAo=true`).
pub fn set_separate_ao(&mut self, separate_ao: bool) {
if self.separate_ao != separate_ao {
self.separate_ao = separate_ao;
if let Some(atlas) = &mut self.pack_atlas {
atlas.separate_ao = separate_ao;
}
}
}
/// Runs the sky, material and post stages of one of Horizon's own shader packs from now
/// on (`None`: no pack), with its options' values; replaces a `shaders/` pack. Returns the
@@ -5057,7 +5170,7 @@ impl Renderer {
.iter()
.map(|t| t.depth_or_array_layers())
.sum();
self.pack_atlas = pack_atlas::PackAtlas::new(
let mut atlas = pack_atlas::PackAtlas::new(
&self.device,
&mut encoder,
&self.sprite_textures,
@@ -5065,6 +5178,10 @@ impl Renderer {
layers,
self.sprite_textures[0].width(),
);
if let Some(atlas) = &mut atlas {
atlas.separate_ao = self.separate_ao;
}
self.pack_atlas = atlas;
self.queue.submit([encoder.finish()]);
}
// Flat draws whenever the device has them (`render` keeps them on with a pack).
@@ -6039,40 +6156,10 @@ impl Renderer {
(true, true) => &[(1, Quads::All)],
}
};
// Phase 1's sections can leave finding their facing quads to the GPU
// (`gpu_lists`) in frames drawn flat with nothing but the camera's
// terrain: no mirror, shader pack terrain or shadow, nor tiles.
let use_lists = gpu_lists_on()
&& !pack_terrain
&& shadow_view.is_none()
&& mirror.is_none()
&& visible_lods.is_empty()
&& self.flat_on
&& (self.flat.as_ref()).is_some_and(|f| self.drawn_sections >= f.min_sections);
if let Some(lists) = &mut self.gpu_lists {
lists.clear(&self.device);
}
let mut lists = self.gpu_lists.as_mut().filter(|_| use_lists);
let mut opaque_draws = 0;
for (i, v) in visible.iter().enumerate() {
let s = self.sections.get(v.slot);
let first_phase = cull.as_ref().is_none_or(|c| c.first(v.slot));
if first_phase
&& let (Some(lists), Some(id)) = (&mut lists, s.list_id)
&& lists.list(
id,
s.page,
v.distance,
&s.sides,
parts(fade_code(s, &v.key) != 0),
v.origin,
)
{
if let Some(c) = &mut cull {
c.drawn(i, v.slot, s.sides.quads() * 6);
}
continue;
}
let (draws, pages) = if first_phase {
(&mut self.draws, &mut self.draw_pages)
} else {
@@ -6482,6 +6569,13 @@ impl Renderer {
oit_on,
);
self.entity_batch.append_shadow_only(frame.shadow_only);
// The posed models' vertices come first: the ordinary entities' draw takes them in.
let skin = frame.skin.filter(|skin| match &mut self.entity_skin {
Some(skinner) => skinner.register(&self.device, &mut self.uploads, &mut encoder, skin),
None => false,
});
let posed = skin.map_or(0, |s| s.vertices);
let entity_quads = entity_quads + posed / 4;
let weather_quads = frame.weather.len() as u32 / 4;
let world_quads = entity_quads
+ text_quads
@@ -6537,7 +6631,7 @@ impl Renderer {
camera,
);
// Frame generation's made frame draws its own hand, halfway, after the others.
let made_hand_vertex = self.entity_batch.combined.len() as i32;
let made_hand_vertex = (posed as usize + self.entity_batch.combined.len()) as i32;
if fg {
self.frame_gen
.halfway_hand(frame.hand, hand_view, &mut self.entity_batch.combined);
@@ -6554,24 +6648,34 @@ impl Renderer {
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
let see_through_quads = (frame.see_through.len() / 4) as u32;
let shadow_only_quads = (frame.shadow_only.len() / 4) as u32;
let entity_vertices = all_entities.len() as u64;
let entity_vertices = u64::from(posed) + all_entities.len() as u64;
if entity_vertices > self.entity_capacity {
self.entity_capacity = entity_vertices.next_power_of_two();
self.entity_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity vertices"),
size: self.entity_capacity * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
usage: entity_skin::EntitySkin::usage(self.entity_skin.is_some()),
mapped_at_creation: false,
});
}
if entity_vertices > 0 {
if !all_entities.is_empty() {
self.uploads.buffer(
&mut encoder,
&self.entity_buffer,
0,
u64::from(posed) * std::mem::size_of::<Vertex>() as u64,
bytemuck::cast_slice(all_entities),
);
}
if let (Some(skinner), Some(skin)) = (&mut self.entity_skin, skin) {
skinner.dispatch(
&self.device,
&mut self.uploads,
&mut encoder,
skin,
&self.entity_buffer,
0,
);
}
self.entity_outline.prepare(
&self.device,
&mut self.uploads,
@@ -6682,8 +6786,7 @@ impl Renderer {
let flat_frame = match &mut self.flat {
Some(flat)
if self.flat_on
&& (!self.draws.is_empty()
|| self.gpu_lists.as_ref().is_some_and(|l| l.listed() > 0))
&& !self.draws.is_empty()
&& (self.drawn_sections >= flat.min_sections
|| pack_terrain
|| shadow_view.is_some()) =>
@@ -6722,8 +6825,6 @@ impl Renderer {
Some((id, lod::instance(v.origin, v.fade)))
})),
|page| sorted.buffer(page).clone(),
self.gpu_lists.as_mut().filter(|l| l.listed() > 0),
eye.map(|e| e.to_array()),
self.own_timing.as_mut(),
)
})
@@ -6731,8 +6832,7 @@ impl Renderer {
_ => false,
};
if flat_frame {
self.section_draw_calls = self.flat.as_ref().map_or(0, |f| f.shadow_calls[0])
+ self.gpu_lists.as_ref().map_or(0, |l| l.calls().len());
self.section_draw_calls = self.flat.as_ref().map_or(0, |f| f.shadow_calls[0]);
} else if self.multi_draw && !self.draws.is_empty() {
if self.draws.len() as u64 > self.indirect_capacity {
self.indirect_capacity = (self.draws.len() as u64).next_power_of_two();
@@ -7333,26 +7433,19 @@ impl Renderer {
// Without a pack, on Metal, the opaque pass, then the cutout one
// (terrain.wgsl's `opaque_quad`), each over phase 1's lists with
// quads for it, and over all of phase 2's.
// Each pass's GPU-made lists by group, too (`flat::Flat::list`).
let [opaque, cutout] = flat.pass_calls;
type Passes<'p> =
&'p [(&'p wgpu::RenderPipeline, std::ops::Range<usize>, &'p [u8])];
let passes: Passes = match pack_flat {
Some(p) => &[(&p.0, 0..flat.phase1_calls, &[0, 1])],
let passes: &[(&wgpu::RenderPipeline, std::ops::Range<usize>)] = match pack_flat
{
Some(p) => &[(&p.0, 0..flat.phase1_calls)],
None if split_solid => &[
(&flat.solid_opaque, 0..opaque, &[0]),
(&flat.solid_cutout, cutout..flat.phase1_calls, &[1]),
(&flat.solid_opaque, 0..opaque),
(&flat.solid_cutout, cutout..flat.phase1_calls),
],
None => &[(&flat.solid, 0..flat.phase1_calls, &[0, 1])],
None => &[(&flat.solid, 0..flat.phase1_calls)],
};
let draw = |pass: &mut wgpu::RenderPass<'_>| {
for (pipeline, calls, groups) in passes {
for (pipeline, calls) in passes {
pass.set_pipeline(pipeline);
if let Some(lists) = &self.gpu_lists {
for &group in *groups {
flat.draw_lists(pass, lists, group, vertices);
}
}
flat.draw(pass, &flat.calls[calls.clone()], vertices);
}
};
@@ -7382,7 +7475,7 @@ impl Renderer {
};
pass.set_bind_group(0, &self.group, &[]);
pack_frame(&mut pass);
for (pipeline, ..) in passes {
for (pipeline, _) in passes {
pass.set_pipeline(pipeline);
flat.draw_phase2(&mut pass, vertices);
}
@@ -7846,6 +7939,7 @@ impl Renderer {
}
pass.set_bind_group(0, &self.group, &[]);
}
self.mark_translucent_terrain(&mut pass, flat, solid_draws, translucent_draws);
}
Some(flat) => {
pass.set_pipeline(pack_flat.map_or(&flat.translucent, |p| &p.1));
@@ -7854,6 +7948,12 @@ impl Renderer {
});
pass.set_bind_group(0, &self.group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
self.mark_translucent_terrain(
&mut pass,
Some(flat),
solid_draws,
translucent_draws,
);
}
None => {
pass.set_pipeline(translucent_pipeline);
@@ -7866,6 +7966,7 @@ impl Renderer {
solid_draws,
translucent_draws,
);
self.mark_translucent_terrain(&mut pass, None, solid_draws, translucent_draws);
}
}
// LevelRenderer.executeTranslucentAfterTerrain: vanilla's
@@ -8603,8 +8704,9 @@ impl Renderer {
if let Some(radius) = menu_blur {
let format = self.config.format;
let device = &self.device;
let software = is_software(self.info.device_type);
self.menu_blur
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format))
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format, software))
.apply(
&self.device,
&self.queue,
@@ -9034,12 +9136,6 @@ fn projection_center(view_proj: Mat4) -> Option<Vec3> {
fn facing_runs(sides: &Sides, eye: Option<Vec3>, origin: [f32; 3], draw: impl FnMut(u32, u32)) {
facing_runs_from(sides, eye, None, origin, Quads::All, draw)
}
/// Whether phase 1's solid lists may be made on the GPU (`flat::Flat::list`);
/// `HORIZON_GPU_LISTS=0` keeps them on the CPU.
fn gpu_lists_on() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("HORIZON_GPU_LISTS").as_deref() != Ok("0"))
}
/// `facing_runs` for the eye and, with `low`, every eye straight below it down to that
/// camera-relative height (the water mirror's eye).
fn facing_runs_from(
@@ -9399,6 +9495,37 @@ mod tests {
assert_eq!(order(api, "macos"), [Backends::default()]);
}
}
#[test]
fn a_chosen_api_without_a_gpu_tries_the_others_before_software() {
use super::{GraphicsApi, device_search};
use wgpu::Backends;
let (v, d, g) = (Backends::VULKAN, Backends::DX12, Backends::GL);
// #5185: DirectX 12 chosen on an Intel HD 2500 (WARP only): OpenGL's GPU before WARP.
assert_eq!(
device_search(GraphicsApi::Dx12, "windows", false),
[(vec![d], false), (vec![g], false), (vec![d], true)]
);
assert_eq!(
device_search(GraphicsApi::Vulkan, "windows", false),
[(vec![v], false), (vec![d, g], false), (vec![v], true)]
);
assert_eq!(
device_search(GraphicsApi::Auto, "windows", false),
[(vec![v, d, g], false), (vec![v, d, g], true)]
);
assert_eq!(
device_search(GraphicsApi::OpenGl, "linux", false),
[(vec![g], false), (vec![v], false), (vec![g], true)]
);
// WGPU_BACKEND and macOS: one set of backends, nothing else to try.
let all = Backends::default();
for (os, env) in [("windows", true), ("macos", false)] {
assert_eq!(
device_search(GraphicsApi::Vulkan, os, env),
[(vec![all], false), (vec![all], true)]
);
}
}
/// The second try at a backend shows the adapters wgpu hides for reporting
/// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and
/// Haswell, reports 0.0.0.0 and works).
@@ -836,7 +836,6 @@ mod tests {
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: None,
flat_id: None,
list_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -1120,7 +1119,6 @@ mod tests {
sort: super::super::translucency::SortState::new(Vec::new()),
sorted_indices: (translucent > 0).then_some(if indices { at } else { (7, 7) }),
flat_id: None,
list_id: None,
fading: None,
leaving: None,
animated_layers: Box::new([]),
@@ -0,0 +1,511 @@
//! Entity models posed on the GPU. The CPU path (`mobs::render::draw`) walks a
//! model's parts and pushes every vertex of every polygon, transformed by its
//! part's matrix: ~280 vertices of 44 bytes per mob, then copied by material
//! and uploaded each frame. Here each model's vertices are uploaded once, in
//! model space, and a frame only sends each drawn part's matrices and one job
//! per run of drawn parts; `entity_skin.wgsl` writes the same vertices, in the
//! same order, into the entity vertex buffer, after the CPU's ordinary entities
//! (`EntityBatch` leaves the gap). Every pass that draws ordinary entities
//! then draws them as before.
//!
//! The game decides which draws come here (`SkinFrame`): models whose
//! polygons all face an axis, drawn as ordinary opaque or cutout entities.
//! The rest stays on the CPU path.
use super::upload::Uploads;
use crate::mesher::Vertex;
/// A model vertex: position / 16, model UV, the face's normal as an axis (0..2
/// = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in its model.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinVertex {
pub pos: [f32; 3],
pub uv: [f32; 2],
pub axis: u32,
pub part: u32,
}
/// A drawn part: its geometry matrix and its normal matrix, by columns, the
/// normal matrix's columns normalized when the part's scale is not uniform.
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinPart {
pub matrix: [f32; 16],
pub normal: [f32; 9],
}
/// One run of a model's vertices drawn with one texture rect, colour, light
/// and overlay. `dst` counts from the frame's first posed vertex.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinJob {
pub first: u32,
pub count: u32,
pub part: u32,
pub dst: u32,
pub rect: [f32; 4],
pub scroll: f32,
pub color: u32,
pub light: u32,
pub overlay: u32,
/// 1 when the overlay is the armour glint: the model UV rides in `glint_uv`.
pub glint: u32,
pub spare: [u32; 3],
}
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
const _: () = assert!(std::mem::size_of::<SkinVertex>() == 7 * 4);
const _: () = assert!(std::mem::size_of::<SkinPart>() == 25 * 4);
const _: () = assert!(std::mem::size_of::<SkinJob>() == 16 * 4);
/// What the game hands the renderer for one frame.
#[derive(Default)]
pub struct SkinFrame {
/// The model store restarted (models are registered again from 0).
pub reset: bool,
/// The store's size before `new_model`: the renderer takes the frame only
/// when its own store is that large (it missed none of the earlier ones).
pub base: u32,
/// Model vertices registered since the last frame, at the store's end.
pub new_model: Vec<SkinVertex>,
pub parts: Vec<SkinPart>,
pub jobs: Vec<SkinJob>,
/// Vertices the jobs write: a whole number of quads.
pub vertices: u32,
}
impl SkinFrame {
/// Starts a frame's draws; models not yet handed to the renderer stay.
pub fn clear_draws(&mut self) {
self.parts.clear();
self.jobs.clear();
self.vertices = 0;
}
/// The renderer took the new models.
pub fn handed(&mut self) {
self.base += self.new_model.len() as u32;
self.new_model.clear();
self.reset = false;
}
/// Where the draws stand, to take back a mob's (`truncate`).
pub fn mark(&self) -> (usize, usize, u32) {
(self.jobs.len(), self.parts.len(), self.vertices)
}
/// Takes back the draws since `mark` (a mob culled after drawing).
pub fn truncate(&mut self, (jobs, parts, vertices): (usize, usize, u32)) {
self.jobs.truncate(jobs);
self.parts.truncate(parts);
self.vertices = vertices;
}
/// Whether the renderer has anything to take.
pub fn pending(&self) -> bool {
self.reset || !self.new_model.is_empty() || !self.jobs.is_empty()
}
}
/// Jobs per row of workgroups (the dispatch limit is 65535 per dimension).
const ROW: u32 = 32_768;
pub(super) struct EntitySkin {
pipeline: wgpu::ComputePipeline,
layout: wgpu::BindGroupLayout,
model: Vec<SkinVertex>,
model_buffer: Option<wgpu::Buffer>,
parts: Grow,
jobs: Grow,
/// The jobs with `dst` moved to the frame's gap, as uploaded.
staged: Vec<SkinJob>,
/// The bind group and the buffers it binds (buffers compare by identity).
group: Option<(wgpu::BindGroup, [wgpu::Buffer; 4])>,
}
/// A storage buffer that grows to the next power of two.
struct Grow {
label: &'static str,
buffer: Option<wgpu::Buffer>,
}
impl Grow {
fn ensure(&mut self, device: &wgpu::Device, bytes: u64) -> bool {
let bytes = bytes.max(256).next_power_of_two();
if self.buffer.as_ref().is_some_and(|b| b.size() >= bytes) {
return false;
}
self.buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some(self.label),
size: bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
true
}
}
impl EntitySkin {
/// The entity vertex buffer's usage: the jobs write into it.
pub(super) fn usage(skinning: bool) -> wgpu::BufferUsages {
let usage = wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST;
if skinning {
usage | wgpu::BufferUsages::STORAGE
} else {
usage
}
}
/// The compute pipeline, or `None` without compute shaders (the CPU path
/// then draws every model).
pub(super) fn new(device: &wgpu::Device, compute: bool) -> Option<Self> {
// HORIZON_NO_ENTITY_SKIN: every model on the CPU path (A/B runs, a driver's bug).
if !compute
|| device.limits().max_storage_buffers_per_shader_stage < 4
|| std::env::var_os("HORIZON_NO_ENTITY_SKIN").is_some()
{
return None;
}
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Entity skinning"),
source: wgpu::ShaderSource::Wgsl(include_str!("entity_skin.wgsl").into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Entity skinning"),
layout: None,
module: &module,
entry_point: Some("skin"),
compilation_options: Default::default(),
cache: None,
});
let layout = pipeline.get_bind_group_layout(0);
let internal = pollster::block_on(internal.pop());
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
log::warn!("Horizon renderer: entity models posed on the CPU ({e})");
return None;
}
Some(Self {
pipeline,
layout,
model: Vec::new(),
model_buffer: None,
parts: Grow {
label: "Entity skinning parts",
buffer: None,
},
jobs: Grow {
label: "Entity skinning jobs",
buffer: None,
},
staged: Vec::new(),
group: None,
})
}
/// The model vertices stored (`SkinFrame::base` of the next frame).
pub(super) fn stored(&self) -> u32 {
self.model.len() as u32
}
/// Takes the frame's new models; whether its jobs can run (the store
/// holds every model they read).
pub(super) fn register(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
frame: &SkinFrame,
) -> bool {
if frame.reset {
self.model.clear();
self.model_buffer = None;
}
if self.model.len() as u32 != frame.base {
// Out of step with the game's store: it restarts it.
return false;
}
if frame.new_model.is_empty() {
return self.model_buffer.is_some() || frame.jobs.is_empty();
}
let old = self.model.len();
self.model.extend_from_slice(&frame.new_model);
let bytes = (self.model.len() * std::mem::size_of::<SkinVertex>()) as u64;
if self
.model_buffer
.as_ref()
.is_some_and(|b| b.size() >= bytes)
{
let buffer = self.model_buffer.as_ref().expect("checked");
uploads.buffer(
encoder,
buffer,
(old * std::mem::size_of::<SkinVertex>()) as u64,
bytemuck::cast_slice(&frame.new_model),
);
} else {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity skinning models"),
size: bytes.max(256).next_power_of_two(),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
uploads.buffer(encoder, &buffer, 0, bytemuck::cast_slice(&self.model));
self.model_buffer = Some(buffer);
}
true
}
/// Records the frame's jobs: they write `frame.vertices` vertices into
/// `out` from vertex `base` on. Call after the CPU vertices' upload.
#[allow(clippy::too_many_arguments)]
pub(super) fn dispatch(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
frame: &SkinFrame,
out: &wgpu::Buffer,
base: u32,
) {
if frame.jobs.is_empty() {
return;
}
let Some(model) = &self.model_buffer else {
return;
};
// Whole rows of workgroups past `ROW` jobs: the padding jobs write nothing.
let count = frame.jobs.len() as u32;
let rows = count.div_ceil(ROW);
let padded = if rows > 1 {
(rows * ROW) as usize
} else {
frame.jobs.len()
};
self.staged.clear();
self.staged.extend(frame.jobs.iter().map(|job| SkinJob {
dst: job.dst + base,
..*job
}));
self.staged.resize(padded, SkinJob::default());
self.parts.ensure(
device,
(frame.parts.len() * std::mem::size_of::<SkinPart>()) as u64,
);
self.jobs
.ensure(device, (padded * std::mem::size_of::<SkinJob>()) as u64);
let (Some(parts), Some(jobs)) = (&self.parts.buffer, &self.jobs.buffer) else {
return;
};
uploads.buffer(encoder, parts, 0, bytemuck::cast_slice(&frame.parts));
uploads.buffer(encoder, jobs, 0, bytemuck::cast_slice(&self.staged));
let key = [model.clone(), parts.clone(), jobs.clone(), out.clone()];
if self.group.as_ref().is_none_or(|(_, k)| *k != key) {
let entries: Vec<_> = key
.iter()
.enumerate()
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding: binding as u32,
resource: buffer.as_entire_binding(),
})
.collect();
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Entity skinning"),
layout: &self.layout,
entries: &entries,
});
self.group = Some((group, key));
}
let Some((group, _)) = &self.group else {
return;
};
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Entity skinning"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(count.min(ROW), rows, 1);
}
}
/// What `entity_skin.wgsl` writes for `frame`'s jobs, computed on the CPU
/// the same way: the reference its tests hold the shader and the game's walk
/// (`mobs::skin`) to. `model` is the whole store.
pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
let mut out = vec![bytemuck::Zeroable::zeroed(); frame.vertices as usize];
for job in &frame.jobs {
for k in 0..job.count {
let v = model[(job.first + k) as usize];
let part = &frame.parts[(job.part + v.part) as usize];
let m = glam::Mat4::from_cols_array(&part.matrix);
let pos = m.transform_point3(glam::Vec3::from_array(v.pos));
let c = (v.axis % 3) as usize * 3;
let column = glam::Vec3::from_slice(&part.normal[c..c + 3]);
let n = if v.axis >= 3 { -column } else { column };
let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
let u = if job.scroll == 0.0 {
v.uv[0]
} else {
(v.uv[0] + job.scroll) % 1.0
};
let r = job.rect;
out[(job.dst + k) as usize] = Vertex {
pos: pos.to_array(),
uv: [r[0] + (r[2] - r[0]) * u, r[1] + (r[3] - r[1]) * v.uv[1]],
layer: job.overlay,
color: job.color,
light: job.light,
normal: [packed[0], packed[1], packed[2], -127],
glint_uv: if job.glint != 0 { v.uv } else { [0.0, 0.0] },
};
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// A cube's 24 vertices for part `part`, its faces on every axis.
fn cube(part: u32) -> Vec<SkinVertex> {
let mut out = Vec::new();
for axis in 0..6u32 {
for corner in 0..4u32 {
let a = (axis % 3) as usize;
let sign = if axis >= 3 { -0.25 } else { 0.25 };
let mut pos = [0.0f32; 3];
pos[a] = sign;
pos[(a + 1) % 3] = if corner & 1 == 0 { -0.25 } else { 0.25 } + 0.1 * part as f32;
pos[(a + 2) % 3] = if corner & 2 == 0 { -0.3 } else { 0.3 };
out.push(SkinVertex {
pos,
uv: [corner as f32 / 3.0, 0.125 * axis as f32 + 0.0625],
axis,
part,
});
}
}
out
}
fn part(m: glam::Mat4, normal: glam::Mat3) -> SkinPart {
SkinPart {
matrix: m.to_cols_array(),
normal: normal.to_cols_array(),
}
}
/// Two draws of a two-part model with turned, scaled parts, a scrolled
/// texture and the armour glint: the shader writes what `expand` does,
/// to the bit, after the vertices before them.
#[test]
fn the_shader_writes_what_the_cpu_reference_computes() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let Some(mut skin) = EntitySkin::new(&device, true) else {
eprintln!("no compute shaders: skipped");
return;
};
let model: Vec<SkinVertex> = cube(0).into_iter().chain(cube(1)).collect();
let turn =
glam::Mat4::from_quat(glam::Quat::from_euler(glam::EulerRot::ZYX, 0.3, -1.1, 2.0));
let base = glam::Mat4::from_translation(glam::Vec3::new(-3.7, 1.25, 9.1)) * turn;
let child = base
* glam::Mat4::from_translation(glam::Vec3::new(0.2, -0.4, 0.05))
* glam::Mat4::from_scale(glam::Vec3::new(1.0, 2.5, 0.5));
let normals = glam::Mat3::from_mat4(child).inverse().transpose();
let normals = glam::Mat3::from_cols(
normals.x_axis.normalize_or_zero(),
normals.y_axis.normalize_or_zero(),
normals.z_axis.normalize_or_zero(),
);
let frame = SkinFrame {
reset: false,
base: 0,
new_model: model.clone(),
parts: vec![
part(base, glam::Mat3::from_mat4(turn)),
part(child, normals),
part(base * turn, glam::Mat3::from_mat4(turn * turn)),
part(child * turn, glam::Mat3::IDENTITY),
],
jobs: vec![
SkinJob {
first: 0,
count: 48,
part: 0,
dst: 0,
rect: [0.25, 0.5, 0.375, 0.5625],
scroll: 0.0,
color: 0xff80_40ff,
light: 0xf0_70,
overlay: 1,
..Default::default()
},
SkinJob {
first: 24,
count: 24,
part: 2,
dst: 48,
rect: [0.0, 0.0, 0.0625, 0.0625],
scroll: 0.73,
color: u32::MAX,
light: 0xf0f0,
overlay: 36,
glint: 1,
..Default::default()
},
],
vertices: 72,
};
let expected = expand(&model, &frame);
// The posed vertices land after 5 others, which they leave alone.
let before = 5u32;
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(before + frame.vertices) * 44,
usage: EntitySkin::usage(true) | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let sentinel = vec![0xabu8; before as usize * 44];
queue.write_buffer(&out, 0, &sentinel);
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
assert!(skin.register(&device, &mut uploads, &mut encoder, &frame));
skin.dispatch(&device, &mut uploads, &mut encoder, &frame, &out, before);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: out.size(),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(&out, 0, &read, 0, out.size());
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
assert_eq!(&data[..sentinel.len()], &sentinel[..]);
let got: &[Vertex] = bytemuck::cast_slice(&data[sentinel.len()..]);
for (i, (got, want)) in got.iter().zip(&expected).enumerate() {
assert_eq!(
bytemuck::bytes_of(got),
bytemuck::bytes_of(want),
"vertex {i}: {got:?} != {want:?}"
);
}
}
}
@@ -0,0 +1,110 @@
// Entity models posed on the GPU (gfx/entity_skin.rs): each job is one draw of
// a model (`mobs::render::draw`), and writes the same vertices the CPU path
// would push, in the same order, into the entity vertex buffer. One workgroup
// per job; its invocations walk the model's vertices.
//
// Words, not structs: every record is read and written as u32 words, so the
// layouts are the Rust ones byte for byte.
// A model vertex (7 words): position / 16, model UV, the face's normal as an
// axis (0..2 = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in the model.
const MODEL_WORDS: u32 = 7u;
// A posed part (25 words): the geometry matrix (16, by columns), then the
// normal matrix (9, by columns), its columns already normalized when the
// part's scale is not uniform (`ModelNormals.trusted` false).
const PART_WORDS: u32 = 25u;
// A job (16 words): first model vertex, vertex count, first part, first output
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
// whether the overlay is the armour glint (its model UV in `glint_uv`), 3 spare.
const JOB_WORDS: u32 = 16u;
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
// (4 x i8), glint_uv (2).
const VERTEX_WORDS: u32 = 11u;
@group(0) @binding(0) var<storage, read> model: array<u32>;
@group(0) @binding(1) var<storage, read> parts: array<u32>;
@group(0) @binding(2) var<storage, read> jobs: array<u32>;
@group(0) @binding(3) var<storage, read_write> out: array<u32>;
fn f(words: u32) -> f32 {
return bitcast<f32>(words);
}
fn part_f(part: u32, i: u32) -> f32 {
return f(parts[part * PART_WORDS + i]);
}
// `mobs::render::scroll_u`.
fn scroll_u(u: f32, scroll: f32) -> f32 {
if scroll == 0.0 {
return u;
}
// Rust's `%` on floats: the remainder with the dividend's sign.
let s = u + scroll;
return s - trunc(s);
}
// `Polygon::vertex_normal`'s packing: clamped, times 127, cut toward zero.
fn pack(n: vec3<f32>) -> u32 {
let b = vec3<i32>(clamp(n, vec3(-1.0), vec3(1.0)) * 127.0);
return (u32(b.x) & 0xffu) | ((u32(b.y) & 0xffu) << 8u) | ((u32(b.z) & 0xffu) << 16u)
| (u32(-127 & 0xff) << 24u);
}
@compute @workgroup_size(64)
fn skin(
@builtin(workgroup_id) group: vec3<u32>,
@builtin(num_workgroups) groups: vec3<u32>,
@builtin(local_invocation_index) lane: u32,
) {
let job = group.y * groups.x + group.x;
if job >= arrayLength(&jobs) / JOB_WORDS {
return;
}
let j = job * JOB_WORDS;
let first = jobs[j];
let count = jobs[j + 1u];
let first_part = jobs[j + 2u];
let dst = jobs[j + 3u];
let rect = vec4(f(jobs[j + 4u]), f(jobs[j + 5u]), f(jobs[j + 6u]), f(jobs[j + 7u]));
let scroll = f(jobs[j + 8u]);
let color = jobs[j + 9u];
let light = jobs[j + 10u];
let overlay = jobs[j + 11u];
let glint = jobs[j + 12u] != 0u;
for (var k = lane; k < count; k += 64u) {
let v = (first + k) * MODEL_WORDS;
let p = vec3(f(model[v]), f(model[v + 1u]), f(model[v + 2u]));
let uv = vec2(f(model[v + 3u]), f(model[v + 4u]));
let axis = model[v + 5u];
let part = first_part + model[v + 6u];
// glam's Mat4::transform_point3, in its order.
var pos = vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x;
pos = vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y + pos;
pos = vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z + pos;
pos = vec3(part_f(part, 12u), part_f(part, 13u), part_f(part, 14u)) + pos;
// An axis normal times the normal matrix is that column, exactly.
let c = 16u + (axis % 3u) * 3u;
var n = vec3(part_f(part, c), part_f(part, c + 1u), part_f(part, c + 2u));
if axis >= 3u {
n = -n;
}
let o = (dst + k) * VERTEX_WORDS;
out[o] = bitcast<u32>(pos.x);
out[o + 1u] = bitcast<u32>(pos.y);
out[o + 2u] = bitcast<u32>(pos.z);
out[o + 3u] = bitcast<u32>(rect.x + (rect.z - rect.x) * scroll_u(uv.x, scroll));
out[o + 4u] = bitcast<u32>(rect.y + (rect.w - rect.y) * uv.y);
out[o + 5u] = overlay;
out[o + 6u] = color;
out[o + 7u] = light;
out[o + 8u] = pack(n);
if glint {
out[o + 9u] = bitcast<u32>(uv.x);
out[o + 10u] = bitcast<u32>(uv.y);
} else {
out[o + 9u] = 0u;
out[o + 10u] = 0u;
}
}
}
+20 -42
View File
@@ -55,6 +55,15 @@ struct PageTable {
group: Option<wgpu::BindGroup>,
}
/// Writes the fragment's alpha over the frame's and leaves its colour.
pub(super) const ALPHA_ONLY: wgpu::BlendState = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent::REPLACE,
};
/// A run of the frame's index list drawn from one arena page.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Call {
@@ -74,6 +83,7 @@ pub struct Flat {
/// The solid draws as the water mirror's camera draws them (`fs_solid_mirror`).
pub solid_mirror: wgpu::RenderPipeline,
pub translucent: wgpu::RenderPipeline,
pub translucent_mark: wgpu::RenderPipeline,
flatten: wgpu::ComputePipeline,
compute_layout: wgpu::BindGroupLayout,
flatten_seen: wgpu::ComputePipeline,
@@ -223,7 +233,6 @@ fn buffer(
/// from `shadow[0]` (solid) and `shadow[1]` (translucent) on are a shader
/// pack's shadow camera's, with calls of their own. Phase 1's draws before
/// `passes[0]` and from `passes[1]` on, the two solid passes' on Metal, too.
/// The list starts at index `base` (the GPU-made lists' rooms come first: `gpu_lists`).
#[allow(clippy::too_many_arguments)]
fn plan(
draws: &[[u32; 5]],
@@ -236,7 +245,6 @@ fn plan(
calls: &mut Vec<Call>,
ranges: &mut Vec<Range>,
segments: &mut Vec<(Source, u64, u64)>,
base: u64,
) -> Plan {
calls.clear();
ranges.clear();
@@ -244,7 +252,7 @@ fn plan(
let (mut phase1_calls, mut solid_calls) = (None, None);
let mut shadow_calls = [None; 2];
let mut pass_calls = [None; 2];
let mut total = base;
let mut total = 0u64;
let mut by_source: Vec<(Source, Vec<Range>)> = Vec::new();
for (i, (d, &(page, sorted))) in draws.iter().zip(pages).enumerate() {
if i == phase1_draws {
@@ -420,6 +428,14 @@ impl Flat {
Some(wgpu::BlendState::ALPHA_BLENDING),
back,
),
translucent_mark: pipeline(
&layout,
"Terrain translucent HDR mark (flat)",
"vs_terrain_flat",
"fs_translucent_mark",
Some(ALPHA_ONLY),
back,
),
flatten,
compute_layout,
flatten_seen,
@@ -542,8 +558,6 @@ impl Flat {
phase2: Option<&wgpu::Buffer>,
origins: impl Iterator<Item = (u32, [f32; 4])>,
sorted_page: impl Fn(u32) -> wgpu::Buffer,
mut lists: Option<&mut super::gpu_lists::GpuLists>,
eye: Option<[f32; 3]>,
timing: Option<&mut super::pack_timing::PassTiming>,
) -> bool {
self.phase2_group = None;
@@ -581,8 +595,6 @@ impl Flat {
bytemuck::cast_slice(&self.origin_data),
);
}
// The GPU-made lists' rooms come first in the index list.
let room = lists.as_mut().map_or(0, |l| l.rooms());
let Plan {
phase1_calls,
solid_calls,
@@ -600,7 +612,6 @@ impl Flat {
&mut self.calls,
&mut self.range_data,
&mut self.segments,
room,
);
self.phase1_calls = phase1_calls;
self.solid_calls = solid_calls;
@@ -663,9 +674,6 @@ impl Flat {
bytemuck::cast_slice(&self.phase2_call_data),
);
}
if let Some(lists) = &mut lists {
lists.prepare(device, encoder, uploads, &self.indices, eye);
}
let ranges_at = |first: u64, count: u64| {
wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &self.ranges,
@@ -735,17 +743,13 @@ impl Flat {
label: Some("Flatten section draws"),
timestamp_writes: timing.and_then(|t| t.compute_writes("Flatten")),
});
if let Some(lists) = &lists {
lists.dispatch(&mut pass);
}
pass.set_pipeline(&self.flatten);
for (group, count) in &groups {
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
drop(pass);
let list_pages = lists.iter().flat_map(|l| l.calls().iter().map(|c| c.page));
for page in self.calls.iter().map(|c| c.page).chain(list_pages) {
for page in self.calls.iter().map(|c| c.page) {
let table = self.pages[page as usize]
.as_mut()
.expect("a drawn page has its block table");
@@ -786,27 +790,6 @@ impl Flat {
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
/// Draws pass `group`'s calls of `lists` (`gpu_lists::Out::Indices`, prepared with
/// this frame's `prepare`) with a flat pipeline set.
pub fn draw_lists<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
lists: &super::gpu_lists::GpuLists,
group: u8,
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
) {
if !lists.prepared() {
return;
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for (k, call) in lists.calls().iter().enumerate() {
if call.group == group {
self.bind_page(pass, call.page, &vertices);
lists.draw_indexed(pass, k);
}
}
}
/// The camera's translucent calls.
pub fn translucent_calls(&self) -> &[Call] {
&self.calls[self.solid_calls..self.shadow_calls[0]]
@@ -932,7 +915,6 @@ mod tests {
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!(total, 6 * 13);
assert_eq!(phase1_calls, solid_calls);
@@ -981,7 +963,6 @@ mod tests {
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!((plan.pass_calls, plan.solid_calls), ([3, 1], 4));
let call = |page, first, count| Call { page, first, count };
@@ -1017,7 +998,6 @@ mod tests {
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!((plan.solid_calls, plan.shadow_calls), (3, [4, 5]));
let call = |page, first, count| Call { page, first, count };
@@ -1046,7 +1026,6 @@ mod tests {
&mut calls,
&mut ranges,
&mut segments,
0,
);
assert_eq!(
plan,
@@ -1164,7 +1143,6 @@ mod tests {
&mut calls,
&mut ranges,
&mut segments,
0,
);
// Phase 2's draws as the test leaves them: instance counts of 0,
// 1 where it saw the section.
@@ -1,639 +0,0 @@
//! Phase 1's solid section draws made on the GPU (gpu_lists.wgsl).
//!
//! Each frame the render thread walked every section phase 1 draws through
//! `mesher::Sides::visible_from` to find the runs of its quads that face the eye,
//! and pushed one draw per run. Here it pushes one record per section instead:
//! its row (where its quads start and how `Sides` sorted them, uploaded with its
//! mesh), the call each of its parts draws in and its camera-relative origin. The
//! GPU finds the same runs and writes their indices, per call (one per pass
//! group and arena page), into the flat draws' index list, drawn with
//! `draw_indexed_indirect`. Calls take room for all the quads of their sections'
//! parts; the GPU counts the indices it wrote.
//!
//! Solid draws write depth and are tested against it, so their order within a
//! call does not change the picture.
use crate::mesher::{Part, Sides};
/// Words per row: first vertex, `Sides::gpu_row`, padding.
const ROW: usize = 24;
/// Most workgroups along one dimension of a dispatch.
const GROUP_LIMIT: u32 = 65535;
/// A part of a section in a record: `Part` as gpu_lists.wgsl names it.
fn part_code(part: Part) -> u32 {
match part {
Part::All => 0,
Part::Opaque => 1,
Part::Cutout => 2,
}
}
/// One of the frame's calls: what pass `group` draws of arena `page`, from
/// index `first` with room for `count`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct ListCall {
pub group: u8,
pub page: u32,
pub first: u32,
pub count: u32,
}
pub(super) struct GpuLists {
pipeline: wgpu::ComputePipeline,
layout: wgpu::BindGroupLayout,
/// Per id: its first vertex and `Sides::gpu_row` (`ROW` words).
rows: wgpu::Buffer,
row_capacity: u64,
free_ids: Vec<u32>,
next_id: u32,
/// The frame's sections: id, the call and part of each of its parts, its
/// number of parts; then its origin.
records: Vec<[u32; 8]>,
record_buffer: wgpu::Buffer,
record_capacity: u64,
calls: Vec<ListCall>,
/// `calls`' index by page and group in the band of distance the last
/// section listed is in (`u32::MAX`: none yet).
call_of: Vec<u32>,
band: u32,
call_data: Vec<[u32; 5]>,
/// Per call `DrawIndexedIndirectArgs`; the GPU adds what it writes to the count.
call_buffer: wgpu::Buffer,
call_capacity: u64,
view: wgpu::Buffer,
group: Option<(wgpu::BindGroup, u32)>,
/// The indices the frame's calls take, and the most they may.
room: u64,
cap: u64,
}
fn buffer(
device: &wgpu::Device,
label: &str,
size: u64,
usage: wgpu::BufferUsages,
) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
// COPY_SRC: rows move to a larger buffer; tests read calls back.
usage: usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
})
}
fn entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::COMPUTE,
ty: wgpu::BindingType::Buffer {
ty,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}
}
impl GpuLists {
pub fn new(device: &wgpu::Device) -> Self {
let read = wgpu::BufferBindingType::Storage { read_only: true };
let write = wgpu::BufferBindingType::Storage { read_only: false };
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Section lists"),
entries: &[
entry(0, read),
entry(1, read),
entry(2, wgpu::BufferBindingType::Uniform),
entry(3, write),
entry(4, write),
],
});
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Section lists"),
source: wgpu::ShaderSource::Wgsl(include_str!("gpu_lists.wgsl").into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Section lists"),
layout: Some(
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Section lists"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
}),
),
module: &module,
entry_point: Some("flatten_sides"),
compilation_options: Default::default(),
cache: None,
});
let storage = wgpu::BufferUsages::STORAGE;
let indirect = wgpu::BufferUsages::INDIRECT | storage;
Self {
pipeline,
layout,
rows: buffer(device, "Section rows", ROW as u64 * 4, storage),
row_capacity: 1,
free_ids: Vec::new(),
next_id: 0,
records: Vec::new(),
record_buffer: buffer(device, "Sections listed", 32, storage),
record_capacity: 1,
calls: Vec::new(),
call_of: Vec::new(),
band: 0,
call_data: Vec::new(),
call_buffer: buffer(device, "Section list calls", 20, indirect),
call_capacity: 1,
view: buffer(
device,
"Section lists' eye",
16,
wgpu::BufferUsages::UNIFORM,
),
group: None,
room: 0,
cap: 0,
}
}
/// A mesh uploaded from vertex `start` of its page, its solid quads sorted as
/// `sides` says: its id, whose row is written through `uploads` in `encoder`.
pub fn add(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
start: u32,
sides: &Sides,
) -> u32 {
let id = self.free_ids.pop().unwrap_or_else(|| {
self.next_id += 1;
self.next_id - 1
});
self.set(device, encoder, uploads, id, start, sides);
id
}
pub fn remove(&mut self, id: u32) {
self.free_ids.push(id);
}
/// Mesh `id` now starts at vertex `start` (moved to another page).
pub fn set(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
id: u32,
start: u32,
sides: &Sides,
) {
let need = u64::from(id) + 1;
if need > self.row_capacity {
let capacity = need.next_power_of_two().max(1024);
let rows = buffer(
device,
"Section rows",
capacity * ROW as u64 * 4,
wgpu::BufferUsages::STORAGE,
);
// Earlier rows: uploads record their copies in `encoder`, so this one follows them.
encoder.copy_buffer_to_buffer(
&self.rows,
0,
&rows,
0,
self.row_capacity * ROW as u64 * 4,
);
self.rows = rows;
self.row_capacity = capacity;
}
let mut row = [0u32; ROW];
row[0] = start;
row[1..23].copy_from_slice(&sides.gpu_row());
uploads.buffer(
encoder,
&self.rows,
u64::from(id) * ROW as u64 * 4,
bytemuck::cast_slice(&row),
);
}
/// Starts the frame's lists.
pub fn clear(&mut self, device: &wgpu::Device) {
self.records.clear();
self.calls.clear();
self.call_of.fill(u32::MAX);
self.band = 0;
self.room = 0;
self.group = None;
// A quarter of the binding they share with the CPU's draws.
self.cap = device.limits().max_storage_buffer_binding_size / 16;
}
/// Section `id`, in `page`, at `origin` from the camera and `distance`
/// (squared, as bits) from it: each of `parts` (pass group, part) draws the
/// quads of the part that face the eye. False, listing nothing, when the
/// frame's lists hold all the room they may: the caller draws it.
///
/// Sections come nearest first. Each band of distance gets calls of its own,
/// drawn in that order, as the CPU's lists keep them (`page_runs`): within
/// a call the GPU writes sections in no set order, and cutout quads, which
/// tile GPUs can't hide before shading, then draw mostly front to back.
pub fn list(
&mut self,
id: u32,
page: u32,
distance: u64,
sides: &Sides,
parts: &[(u8, Part)],
origin: [f32; 3],
) -> bool {
let parts = &parts[..parts.len().min(2)];
let mut counts = [0; 2];
for (count, &(_, part)) in counts.iter_mut().zip(parts) {
*count = sides.part_quads(part) * 6;
}
let room = u64::from(counts[0]) + u64::from(counts[1]);
if self.room + room > self.cap {
return false;
}
self.room += room;
let band = super::page_runs::band(distance) as u32;
if band != self.band {
self.band = band;
self.call_of.fill(u32::MAX);
}
let mut record = [id, 0, 0, parts.len() as u32, 0, 0, 0, 0];
for (k, &(group, part)) in parts.iter().enumerate() {
let key = page as usize * 2 + usize::from(group.min(1));
if self.call_of.len() <= key {
self.call_of.resize(key + 1, u32::MAX);
}
if self.call_of[key] == u32::MAX {
self.call_of[key] = self.calls.len() as u32;
self.calls.push(ListCall {
group,
page,
first: 0,
count: 0,
});
}
let call = self.call_of[key];
self.calls[call as usize].count += counts[k];
record[1 + k] = call | part_code(part) << 28;
}
for (to, o) in record[4..7].iter_mut().zip(origin) {
*to = o.to_bits();
}
self.records.push(record);
true
}
/// The sections listed this frame.
pub fn listed(&self) -> usize {
self.records.len()
}
/// The frame's calls, with their rooms once laid out (`rooms`).
pub fn calls(&self) -> &[ListCall] {
&self.calls
}
/// Lays the calls' rooms out one after the other from the start of the
/// index list: the indices they take.
pub fn rooms(&mut self) -> u64 {
let mut room = 0u64;
for call in &mut self.calls {
call.first = room as u32;
room += u64::from(call.count);
}
room
}
/// Stages the frame's sections, calls and eye (camera-relative; none: every
/// quad faces it), and binds them for `dispatch` writing into `indices`.
/// After `rooms`.
pub fn prepare(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut super::upload::Uploads,
indices: &wgpu::Buffer,
eye: Option<[f32; 3]>,
) {
self.group = None;
let records = self.records.len() as u64;
if records == 0 {
return;
}
if records > self.record_capacity {
self.record_capacity = records.next_power_of_two();
self.record_buffer = buffer(
device,
"Sections listed",
self.record_capacity * 32,
wgpu::BufferUsages::STORAGE,
);
}
uploads.buffer(
encoder,
&self.record_buffer,
0,
bytemuck::cast_slice(&self.records),
);
self.call_data.clear();
self.call_data
.extend(self.calls.iter().map(|c| [0, 1, c.first, 0, 0]));
let calls = self.call_data.len() as u64;
let indirect = wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::STORAGE;
if calls > self.call_capacity {
self.call_capacity = calls.next_power_of_two();
self.call_buffer = buffer(
device,
"Section list calls",
self.call_capacity * 20,
indirect,
);
}
uploads.buffer(
encoder,
&self.call_buffer,
0,
bytemuck::cast_slice(&self.call_data),
);
let [x, y, z] = eye.unwrap_or_default();
let view = [x, y, z, if eye.is_some() { 1.0 } else { 0.0 }];
uploads.buffer(encoder, &self.view, 0, bytemuck::cast_slice(&view));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Section lists"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &self.record_buffer,
offset: 0,
size: wgpu::BufferSize::new(records * 32),
}),
},
wgpu::BindGroupEntry {
binding: 1,
resource: self.rows.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.view.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: self.call_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: indices.as_entire_binding(),
},
],
});
self.group = Some((group, records as u32));
}
/// Writes the frame's lists (after `prepare`).
pub fn dispatch(&self, pass: &mut wgpu::ComputePass<'_>) {
let Some((group, records)) = &self.group else {
return;
};
let groups = *records;
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(groups.min(GROUP_LIMIT), groups.div_ceil(GROUP_LIMIT), 1);
}
/// Whether `dispatch` writes this frame's lists.
pub fn prepared(&self) -> bool {
self.group.is_some()
}
/// Draws call `k` with a pipeline, its page's vertices and the index list bound.
pub fn draw_indexed(&self, pass: &mut wgpu::RenderPass<'_>, k: usize) {
pass.draw_indexed_indirect(&self.call_buffer, k as u64 * 20);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesher::{Part, Sides};
use wgpu::util::DeviceExt;
struct Section {
sides: Sides,
origin: [f32; 3],
start: u32,
page: u32,
parts: &'static [(u8, Part)],
}
/// Random sorts: some parts empty, grouped or not, split or not, planes in
/// and out of a section, each listed in one of the three ways it can be.
fn sections(count: usize) -> Vec<Section> {
let mut seed = 0x2545_f491_4f6c_dd1du64;
let mut next = |n: u32| {
seed = seed
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
((seed >> 33) % u64::from(n)) as u32
};
(0..count)
.map(|_| {
let mut ends = [0; 14];
let mut end = 0;
for e in &mut ends {
if next(3) > 0 {
end += next(40);
}
*e = end;
}
let planes = std::array::from_fn(|_| next(17) as f32 + next(16) as f32 / 16.0);
Section {
sides: Sides::from_parts(ends, next(2) == 0, next(4) > 0, planes),
origin: [0, 1, 2].map(|_| next(129) as f32 - 64.0),
start: next(10_000) * 4,
page: next(3),
parts: match next(3) {
0 => &[(0, Part::All)],
1 => &[(0, Part::Opaque), (1, Part::Cutout)],
_ => &[(1, Part::All)],
},
}
})
.collect()
}
/// The runs the CPU finds for a section's part, as `Gfx` drew them.
fn cpu_runs(s: &Section, part: Part, eye: Option<[f32; 3]>) -> Vec<(u32, u32)> {
let mut runs = Vec::new();
let draw = |first, quads| runs.push((first, quads));
match eye {
Some(eye) => {
let eye = [0, 1, 2].map(|a| eye[a] - s.origin[a]);
s.sides.visible_from(eye, eye[1], part, draw);
}
None => s.sides.every(part, draw),
}
runs
}
/// Lists `sections` from each of a few eyes; per eye, the lists, the
/// calls' words and the indices written.
fn run(sections: &[Section]) -> Option<Vec<(Option<[f32; 3]>, GpuLists, Vec<u32>, Vec<u32>)>> {
let (device, queue) = device()?;
let mut results = Vec::new();
for eye in [
Some([3.5f32, 70.25, -12.0]),
Some([-40.0, -3.0, 50.5]),
None,
] {
let mut lists = GpuLists::new(&device);
lists.clear(&device);
let mut rows = vec![0u32; sections.len() * ROW];
for (id, s) in sections.iter().enumerate() {
rows[id * ROW] = s.start;
rows[id * ROW + 1..id * ROW + 23].copy_from_slice(&s.sides.gpu_row());
// Nearest first, over several bands.
let distance = (id as f64 * 2.0).powi(2).to_bits();
assert!(lists.list(id as u32, s.page, distance, &s.sides, s.parts, s.origin));
}
let room = lists.rooms();
let init = |contents: &[u8], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents,
usage,
})
};
let storage = wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC;
lists.rows = init(bytemuck::cast_slice(&rows), storage);
let indices = init(&vec![0xffu8; room as usize * 4], storage);
let mut encoder = device.create_command_encoder(&Default::default());
let mut uploads = super::super::upload::Uploads::new(&device);
lists.prepare(&device, &mut encoder, &mut uploads, &indices, eye);
uploads.finish(&encoder);
{
let mut pass = encoder.begin_compute_pass(&Default::default());
lists.dispatch(&mut pass);
}
queue.submit([encoder.finish()]);
let read_back = |buffer: &wgpu::Buffer, words: u64| -> Vec<u32> {
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: words * 4,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(buffer, 0, &read, 0, words * 4);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
};
let calls = read_back(&lists.call_buffer, lists.calls.len() as u64 * 5);
let words = read_back(&indices, room);
results.push((eye, lists, calls, words));
}
Some(results)
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
eprintln!("no GPU adapter: skipped");
return None;
};
let device = pollster::block_on(adapter.request_device(&Default::default())).ok();
if device.is_none() {
eprintln!("no GPU device: skipped");
}
device
}
/// The call section `i`'s `k`th part draws in, as listed.
fn call_of(lists: &GpuLists, i: usize, k: usize) -> usize {
(lists.records[i][1 + k] & 0x0fff_ffff) as usize
}
/// Each band of distance and page gets a pass's calls of its own, in the
/// order sections come (nearest first), and sections of one share them.
#[test]
fn calls_go_by_band_then_page_nearest_first() {
let Some((device, _)) = device() else {
return;
};
let mut lists = GpuLists::new(&device);
lists.clear(&device);
let sides = Sides::unsorted(4);
let parts = &[(0, Part::Opaque), (1, Part::Cutout)];
for (page, blocks) in [(1, 10.0), (0, 20.0), (1, 30.0), (1, 100.0), (0, 130.0)] {
let distance = f64::powi(blocks, 2).to_bits();
assert!(lists.list(0, page, distance, &sides, parts, [0.0; 3]));
}
let calls: Vec<_> = lists
.calls()
.iter()
.map(|c| (c.group, c.page, c.count))
.collect();
assert_eq!(
calls,
[
(0, 1, 48),
(1, 1, 48),
(0, 0, 24),
(1, 0, 24),
(0, 1, 24),
(1, 1, 24),
(0, 0, 24),
(1, 0, 24)
]
);
}
/// `flatten_sides` writes, in each call's room, the indices of exactly the
/// runs `Sides::visible_from` finds for each listed section's parts (in
/// some order: workgroups take room as they come), and counts them.
#[test]
fn flatten_sides_writes_the_runs_the_cpu_finds() {
let sections = sections(200);
let Some(results) = run(&sections) else {
return;
};
for (eye, lists, calls, out) in results {
let mut expected = vec![Vec::new(); lists.calls.len()];
for (i, s) in sections.iter().enumerate() {
for (k, &(_, part)) in s.parts.iter().enumerate() {
let call = call_of(&lists, i, k);
for (first, quads) in cpu_runs(s, part, eye) {
for k in 0..quads * 6 {
let corner = [0, 1, 2, 0, 2, 3][(k % 6) as usize];
expected[call].push(s.start + first * 4 + (k / 6) * 4 + corner);
}
}
}
}
for (k, call) in lists.calls.iter().enumerate() {
let count = calls[k * 5] as usize;
assert_eq!(
count,
expected[k].len(),
"call {k}'s index count, eye {eye:?}"
);
assert_eq!(calls[k * 5 + 2], call.first);
let mut got = out[call.first as usize..call.first as usize + count].to_vec();
got.sort_unstable();
expected[k].sort_unstable();
assert_eq!(got, expected[k], "call {k}'s indices, eye {eye:?}");
}
}
}
}
@@ -1,178 +0,0 @@
// Phase 1's solid section draws made on the GPU (gfx/gpu_lists.rs): per section
// listed, the runs of its quads that face the eye, found as
// `mesher::Sides::visible_from` finds them on the CPU, written into the call
// each of its parts draws in (one per pass group and arena page) after taking
// room there: their indices, in the flat draws' index list, as `flatten_seen`
// does for occlusion culling's phase 2. Solid draws write depth and are tested
// against it, so their order within a call does not change the picture.
struct View {
// The eye relative to the camera, and 1.0 when there is one (`sides_on`);
// without, every bucket is drawn (`Sides::every`).
eye: vec3<f32>,
has_eye: f32,
}
// Per section listed: its row's id; for each of its parts (up to two) the call
// it draws in, with the part (`PART_*`) in the top bits; its number of parts.
// Then its origin relative to the camera.
struct Record {
words: vec4<u32>,
origin: vec4<f32>,
}
@group(0) @binding(0) var<storage, read> records: array<Record>;
// Per id, `ROW` words: its first vertex in its page, then `Sides::gpu_row`
// (14 ends, 7 planes' bits, flags).
@group(0) @binding(1) var<storage, read> rows: array<u32>;
@group(0) @binding(2) var<uniform> view: View;
// Per call `DrawIndexedIndirectArgs`: its index count starts at 0, its first
// index is where its room starts.
@group(0) @binding(3) var<storage, read_write> calls: array<atomic<u32>>;
// The flat draws' index list.
@group(0) @binding(4) var<storage, read_write> out: array<u32>;
const ROW: u32 = 24u;
const LANES: u32 = 64u;
const PART_ALL: u32 = 0u;
const PART_OPAQUE: u32 = 1u;
const PART_CUTOUT: u32 = 2u;
const CALL_MASK: u32 = 0x0fffffffu;
// `sides::MARGIN`.
const MARGIN: f32 = 0.0625;
// Index `k` of a solid draw: quad k / 6's vertices 0 1 2 0 2 3.
fn quad(k: u32) -> u32 {
let c = k % 6u;
return (k / 6u) * 4u + select(select(c - 2u, 0u, c == 3u), c, c < 3u);
}
fn plane(row: u32, bucket: u32) -> f32 {
return bitcast<f32>(rows[row + 15u + bucket]);
}
// `Sides::visible_from`'s `seen`, buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y.
fn seen(row: u32, bucket: u32, eye: vec3<f32>) -> bool {
var faces = true;
if view.has_eye != 0.0 {
switch bucket {
case 0u: { faces = eye.x > plane(row, 0u) - MARGIN; }
case 1u: { faces = eye.z > plane(row, 1u) - MARGIN; }
case 3u: { faces = eye.y > plane(row, 3u) - MARGIN; }
case 4u: { faces = eye.z < plane(row, 4u) + MARGIN; }
case 5u: { faces = eye.x < plane(row, 5u) + MARGIN; }
case 6u: { faces = eye.y < plane(row, 6u) + MARGIN; }
default: {}
}
}
return faces;
}
// A part's runs (first quad, quads): at most 7, as a run ends at a part left
// out and there are 14.
struct Runs {
n: u32,
quads: u32,
runs: array<vec2<u32>, 7>,
}
// `Sides::runs` for `part` of the mesh at `row`, seen from `eye` (relative to
// the section): empty parts do not break a run.
fn part_runs(row: u32, part: u32, eye: vec3<f32>) -> Runs {
var r: Runs;
let grouped = (rows[row + 22u] & 1u) != 0u;
let split = (rows[row + 22u] & 2u) != 0u;
var start = 0u;
var open = false;
var first = 0u;
for (var n = 0u; n < 14u; n++) {
let end = rows[row + 1u + n];
if end == start {
continue;
}
var bucket: u32;
var cutout: bool;
if grouped {
bucket = n % 7u;
cutout = n >= 7u;
} else {
bucket = n / 2u;
cutout = n % 2u == 1u;
}
let wanted = part == PART_ALL || !split
|| (part == PART_OPAQUE && !cutout) || (part == PART_CUTOUT && cutout);
let take = wanted && seen(row, bucket, eye);
if take && !open {
open = true;
first = start;
} else if !take && open {
r.runs[r.n] = vec2(first, start - first);
r.quads += start - first;
r.n++;
open = false;
}
start = end;
}
if open {
r.runs[r.n] = vec2(first, start - first);
r.quads += start - first;
r.n++;
}
return r;
}
fn record_index(group: vec3<u32>) -> u32 {
return group.y * 65535u + group.x;
}
// The eye relative to `record`'s section.
fn eye_of(record: Record) -> vec3<f32> {
return view.eye - record.origin.xyz;
}
fn part_word(record: Record, p: u32) -> u32 {
return select(record.words.z, record.words.y, p == 0u);
}
// Lane 0's runs per part, and where each part's indices start.
var<workgroup> found: array<Runs, 2>;
var<workgroup> at: array<u32, 2>;
// One workgroup per record: lane 0 finds the runs and takes room for their
// indices, then every lane writes them.
@compute @workgroup_size(64)
fn flatten_sides(@builtin(workgroup_id) group: vec3<u32>,
@builtin(local_invocation_index) lane: u32) {
let r = record_index(group);
if r >= arrayLength(&records) {
return;
}
let record = records[r];
let row = record.words.x * ROW;
let parts = record.words.w;
if lane == 0u {
let eye = eye_of(record);
for (var p = 0u; p < parts; p++) {
let word = part_word(record, p);
let runs = part_runs(row, word >> 28u, eye);
let call = (word & CALL_MASK) * 5u;
var begin = 0u;
if runs.quads > 0u {
begin = atomicLoad(&calls[call + 2u]) + atomicAdd(&calls[call], runs.quads * 6u);
}
found[p] = runs;
at[p] = begin;
}
}
workgroupBarrier();
let base = rows[row];
for (var p = 0u; p < parts; p++) {
var to = at[p];
for (var k = 0u; k < found[p].n; k++) {
let run = found[p].runs[k];
let first = base + run.x * 4u;
let count = run.y * 6u;
for (var i = lane; i < count; i += LANES) {
out[to + i] = first + quad(i);
}
to += count;
}
}
}
@@ -12,6 +12,8 @@ pub(super) struct PackAtlas {
pub sprite: u32,
/// Layers per sprite array.
split: u32,
/// OptiFine/Iris separate ambient occlusion: pass vertex alpha to hz_Color.a.
pub separate_ao: bool,
}
impl PackAtlas {
@@ -57,6 +59,7 @@ impl PackAtlas {
columns,
sprite,
split,
separate_ao: false,
};
atlas.copy(encoder, arrays, 0..layers);
Some(atlas)
@@ -179,7 +182,12 @@ impl PackAtlas {
];
let columns = self.columns;
// UVs past a sprite (models up to 2, distant tiles) cannot repeat in an atlas: clamped.
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1.
// The colour's alpha holds HDR's emission level (`mesher::EMISSION_BITS`): packs get 1 unless separate_ao.
let color_a = if self.separate_ao {
"hz_v_color.a"
} else {
"1.0"
};
let body = format!(
" vec4 hz_origin = {origin};\n \
uint hz_layer = hz_v_layer & 32767u;\n \
@@ -189,7 +197,7 @@ impl PackAtlas {
hz_MultiTexCoord0 = vec4((hz_cell + clamp(vec2(hz_v_uv) * (hz_origin.w > 0.0 ? uintBitsToFloat(floatBitsToUint(hz_origin.w) & 0xff800000u) : fract(abs(hz_origin.w))), 0.0, 1.0)) * hz_scale, 0.0, 1.0);\n \
hz_MultiTexCoord1 = vec4(float(hz_v_light.y), float(hz_v_light.x), 0.0, 1.0);\n \
hz_MultiTexCoord2 = hz_MultiTexCoord1;\n \
hz_Color = vec4(hz_v_color.rgb, 1.0);\n \
hz_Color = vec4(hz_v_color.rgb, {color_a});\n \
hz_Normal = vec3(0.0, 1.0, 0.0);\n \
hz_tangent = vec4(1.0, 0.0, 0.0, 1.0);\n",
scale[0], scale[1],
@@ -313,4 +321,50 @@ mod tests {
assert_eq!([red(0, 0), red(2, 0), red(4, 0)], [0, 40, 80]);
assert_eq!([red(1, 3), red(3, 2), red(5, 2)], [120, 160, 0]);
}
#[test]
fn terrain_inputs_respects_separate_ao() {
let instance = crate::gfx::test_gpu::instance();
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let (device, _) = pollster::block_on(adapter.request_device(&Default::default())).unwrap();
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: 16,
height: 16,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let view = texture.create_view(&Default::default());
let mut atlas = PackAtlas {
texture,
view,
columns: 1,
sprite: 16,
split: 1,
separate_ao: false,
};
let inputs_default = atlas.terrain_inputs(false);
assert!(
inputs_default
.body
.contains("hz_Color = vec4(hz_v_color.rgb, 1.0);")
);
atlas.separate_ao = true;
let inputs_separate = atlas.terrain_inputs(false);
assert!(
inputs_separate
.body
.contains("hz_Color = vec4(hz_v_color.rgb, hz_v_color.a);")
);
}
}
+104 -23
View File
@@ -30,11 +30,17 @@ const BUFFERS: usize = horizon_shaders::BUFFERS;
enum Source {
Color(usize),
Depth,
/// `depthtex1`/`depthtex2`: depth without translucent terrain (a copy taken before it).
/// `depthtex1`: depth without translucent terrain (a copy taken before it).
Depth1,
/// `depthtex2`: depth without translucent terrain and without the first-person hand.
Depth2,
Noise,
/// A 1×1 stand-in for names this slice does not provide yet.
Dummy,
/// A 1×1 flat normal (128, 128, 255) stand-in when no normal map is present.
Normals,
/// A 1×1 black (0, 0, 0, 0) stand-in when no specular map is present.
Specular,
/// `texture.<stage>.<name>`: the pack's own texture, filtered and wrapped as its
/// `.mcmeta` says.
Custom {
@@ -81,10 +87,18 @@ enum SamplerKind {
impl SamplerKind {
fn of(source: Source, filtering: bool) -> Self {
match source {
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy if !filtering => {
Source::Color(_)
| Source::ShadowColor(_)
| Source::Dummy
| Source::Normals
| Source::Specular
if !filtering =>
{
Self::Nearest
}
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(_, false) => {
Self::Nearest
}
Source::Depth | Source::Depth1 | Source::ShadowDepth(_, false) => Self::Nearest,
Source::Noise => Self::Repeat,
Source::Shadow | Source::ShadowDepth(_, true) => Self::Comparison,
// Blocks look as the game draws them: nearest texels.
@@ -97,9 +111,12 @@ impl SamplerKind {
(true, false) => Self::Repeat,
(false, false) => Self::NearestRepeat,
},
Source::Color(_) | Source::ShadowColor(_) | Source::Dummy | Source::Lightmap => {
Self::Linear
}
Source::Color(_)
| Source::ShadowColor(_)
| Source::Dummy
| Source::Normals
| Source::Specular
| Source::Lightmap => Self::Linear,
}
}
@@ -387,6 +404,8 @@ struct Targets {
depth_texture: wgpu::Texture,
/// `depthtex1`, when a program after translucent terrain reads it.
depth1: Option<(wgpu::Texture, wgpu::TextureView)>,
/// `depthtex2`, when a program after the hand reads it without the hand.
depth2: Option<(wgpu::Texture, wgpu::TextureView)>,
/// Both sides' mip chains of the buffers with mipmaps (`color` is their first level).
mips: [Option<[Mips; 2]>; BUFFERS],
/// Where a pass that both samples and stores a buffer samples it from.
@@ -493,6 +512,8 @@ pub struct PackPost {
hand: Option<(Pass, super::pack_hand::PackHand)>,
/// A program after translucent terrain reads `depthtex1`: it is copied before water.
depth1: bool,
/// A program after the hand reads `depthtex2`: it is copied before the hand.
depth2: bool,
/// The shadow buffers, when the shadow pass runs (2.5), and the `shadowcomp` programs.
shadow_targets: Option<super::pack_shadow::ShadowTargets>,
/// What draws terrain from the light (`shadow`), and translucent terrain (`shadow_water`).
@@ -535,6 +556,8 @@ pub struct PackPost {
targets: Option<Targets>,
noise: wgpu::TextureView,
dummy: wgpu::TextureView,
dummy_black: wgpu::TextureView,
dummy_normal: wgpu::TextureView,
dummy_3d: wgpu::TextureView,
dummy_1d: wgpu::TextureView,
/// `image.*` and `bufferObject.*`.
@@ -649,11 +672,13 @@ fn source(name: &str, shadow_space: bool, shadow_pass: bool, watershadow: bool)
}
match name {
"depthtex0" | "gdepthtex" => Source::Depth,
// The hand is drawn after the pack, so `depthtex2` (without it) is `depthtex1`.
"depthtex1" | "depthtex2" => Source::Depth1,
"depthtex1" => Source::Depth1,
"depthtex2" => Source::Depth2,
"noisetex" => Source::Noise,
"texture" | "gtexture" | "tex" => Source::Atlas,
"lightmap" => Source::Lightmap,
"normals" => Source::Normals,
"specular" => Source::Specular,
n => horizon_shaders::buffer_index(n).map_or(Source::Dummy, Source::Color),
}
}
@@ -1075,7 +1100,7 @@ fn bindings(
Source::Atlas => geometry.is_none(),
Source::Depth => geometry.is_some(),
// Translucent terrain reads the copy; opaque terrain would read itself.
Source::Depth1 => group == Group::Gbuffers,
Source::Depth1 | Source::Depth2 => group == Group::Gbuffers,
Source::Color(n) => in_place & 1 << n != 0,
_ => false,
};
@@ -1086,7 +1111,7 @@ fn bindings(
Source::ShadowColor(n) => shadow
.and_then(|f| f[n].sample_type(None, Some(features)))
.unwrap_or(float),
Source::Depth | Source::Depth1 | Source::ShadowDepth(..) => {
Source::Depth | Source::Depth1 | Source::Depth2 | Source::ShadowDepth(..) => {
wgpu::TextureSampleType::Float { filterable: false }
}
// Unfilterable on WebGPU-level devices; the lightmap is sampled at texel centres.
@@ -2080,13 +2105,20 @@ impl PackPost {
}
}
}
let depth1 = water.is_some()
let depth1 = (water.is_some() || hand.is_some())
&& passes
.iter()
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
.chain(&water)
.chain(weather.iter().take(1).flatten())
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth1));
let depth2 = hand.is_some()
&& passes
.iter()
.filter(|p| matches!(p.group, Group::Composite | Group::Final))
.chain(&water)
.chain(weather.iter().take(1).flatten())
.any(|p| p.samplers.iter().any(|s| s.source == Source::Depth2));
let every_pass = || {
(passes
.iter()
@@ -2161,7 +2193,7 @@ impl PackPost {
|| passes.iter().any(|p| {
p.samplers
.iter()
.any(|s| matches!(s.source, Source::Depth | Source::Depth1))
.any(|s| matches!(s.source, Source::Depth | Source::Depth1 | Source::Depth2))
});
// Buffers of a size of their own clear apart from the blit, which writes colortex0.
let (mut cleared, mut other_clears): (Vec<usize>, Vec<usize>) =
@@ -2250,6 +2282,8 @@ impl PackPost {
});
}
let white = [255u8; 4];
let black = [0u8; 4];
let flat_normal = [128u8, 128, 255, 255];
Self {
notes,
passes,
@@ -2268,6 +2302,7 @@ impl PackPost {
weather_generation: 0,
hand,
depth1,
depth2,
shadow_targets,
shadow_terrain,
shadow_water,
@@ -2302,6 +2337,14 @@ impl PackPost {
.cloned()
.unwrap_or_else(|| noise(device, queue)),
dummy: texture(device, queue, "Shader pack stand-in", 1, &white),
dummy_black: texture(device, queue, "Shader pack black stand-in", 1, &black),
dummy_normal: texture(
device,
queue,
"Shader pack normal stand-in",
1,
&flat_normal,
),
images: {
let mut images = images;
for (k, image) in images.iter_mut().enumerate() {
@@ -2507,6 +2550,27 @@ impl PackPost {
}
_ => &targets.depth,
},
Source::Depth2 => match &targets.depth2 {
Some(d)
if matches!(
pass.group,
Group::Composite | Group::Final | Group::Translucent
) =>
{
&d.1
}
_ => match &targets.depth1 {
Some(d)
if matches!(
pass.group,
Group::Composite | Group::Final | Group::Translucent
) =>
{
&d.1
}
_ => &targets.depth,
},
},
Source::Noise => &self.noise,
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D3 => {
&self.dummy_3d
@@ -2514,6 +2578,8 @@ impl PackPost {
Source::Dummy if s.dimension == wgpu::TextureViewDimension::D1 => {
&self.dummy_1d
}
Source::Normals => &self.dummy_normal,
Source::Specular => &self.dummy_black,
Source::Dummy => &self.dummy,
Source::Image(k) if pass.image_copies & 1 << k != 0 => {
self.images[k].copy.as_ref().unwrap_or(&self.dummy)
@@ -2685,16 +2751,6 @@ impl PackPost {
return;
};
let translucent = list.translucent();
if list == TerrainList::Translucent
&& let Some(t) = &self.targets
&& let Some((copy, _)) = &t.depth1
{
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
let group = self.bind(device, queue, encoder, &mut pass, size);
{
let mut rp = self.begin(encoder, &pass);
@@ -2972,6 +3028,22 @@ impl PackPost {
self.draw_terrain(device, queue, encoder, size, TerrainList::Solid, terrain);
self.draw_entities(device, queue, encoder, size, false);
self.draw_clouds(device, queue, encoder, size);
if let Some(t) = &self.targets {
if let Some((copy, _)) = &t.depth1 {
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
if let Some((copy, _)) = &t.depth2 {
encoder.copy_texture_to_texture(
t.depth_texture.as_image_copy(),
copy.as_image_copy(),
t.depth_texture.size(),
);
}
}
self.draw_hand(device, queue, encoder, size, frame);
}
@@ -3362,6 +3434,11 @@ impl PackPost {
let view = t.create_view(&Default::default());
(t, view)
});
let depth2 = self.depth2.then(|| {
let t = texture("depthtex2", depth32, wgpu::TextureUsages::COPY_DST);
let view = t.create_view(&Default::default());
(t, view)
});
let scratch = std::array::from_fn(|i| {
(self.copied & 1 << i != 0).then(|| {
// A copy also read as an image before a dispatch.
@@ -3395,6 +3472,7 @@ impl PackPost {
depth: depth_texture.create_view(&Default::default()),
depth_texture,
depth1,
depth2,
mips,
});
self.forget_groups();
@@ -4078,7 +4156,10 @@ mod tests {
let main = |n| source(n, false, false, false);
assert!(main("gaux1") == Source::Color(4));
assert!(main("gdepthtex") == Source::Depth);
assert!(main("depthtex2") == Source::Depth1);
assert!(main("depthtex1") == Source::Depth1);
assert!(main("depthtex2") == Source::Depth2);
assert!(main("normals") == Source::Normals);
assert!(main("specular") == Source::Specular);
assert!(main("noisetex") == Source::Noise);
// Without the shadow pass, the shadow buffers are stand-ins.
assert!(main("shadowcolor0") == Source::Dummy);
@@ -969,6 +969,7 @@ mod initial_history_tests {
emissive_layers: &[],
shadow_only: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
clouds: None,
@@ -29,11 +29,6 @@ pub(super) struct Run {
pub tag: [u32; 3],
}
/// The band of a section `distance` from the camera (squared, as bits).
pub(super) fn band(distance: u64) -> u64 {
(f64::from_bits(distance).sqrt() / BAND) as u64
}
#[derive(Default)]
pub(super) struct PageRuns {
runs: Vec<Run>,
@@ -63,7 +58,7 @@ impl PageRuns {
tag: [u32; 3],
) {
if len > 0 {
let band = band(distance);
let band = (f64::from_bits(distance).sqrt() / BAND) as u64;
self.runs.push(Run {
key: u64::from(group) << GROUP_SHIFT | band << 32 | u64::from(page),
start: start as u32,
@@ -55,6 +55,22 @@ impl<T> Resident<T> {
pub fn len(&self) -> usize {
self.items.len()
}
/// Heap bytes of its own lists and maps, not counting what a `T` points
/// to (`horizon-ctl memory`). A hash map entry costs its pair and a
/// control byte.
pub fn heap_bytes(&self) -> usize {
use std::mem::size_of;
self.items.capacity() * size_of::<(SectionKey, T)>()
+ self.bounds.capacity() * size_of::<Option<[Vec3; 2]>>()
+ self.slots.capacity() * (size_of::<(SectionKey, u32)>() + 1)
+ self.columns.capacity() * size_of::<Column>()
+ self
.columns
.iter()
.map(|c| c.sections.capacity() * size_of::<(i32, u32)>())
.sum::<usize>()
+ self.column_index.capacity() * (size_of::<((i32, i32), u32)>() + 1)
}
pub fn contains(&self, key: SectionKey) -> bool {
self.slots.contains_key(&key)
}
+111 -1
View File
@@ -7,6 +7,7 @@ use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
sync::{Arc, OnceLock},
};
use wgpu::util::DeviceExt;
@@ -58,7 +59,15 @@ impl TextureFlags {
}
}
pub struct HudAtlas {
/// RGBA8, `width` by `height`, as built: empty once `pack_pixels` has
/// compressed them (read them through `pixels()`).
pub pixels: Vec<u8>,
/// `pixels` compressed (lz4) on a thread of its own once the renderer
/// has uploaded them: only rebuilding a renderer reads them again.
packed: Option<Arc<OnceLock<Vec<u8>>>>,
/// The width each Standard Galactic letter uses in `font/ascii_sga`'s
/// cell, by ASCII code (the enchanting table's text), read when built.
pub galactic_widths: [u16; 128],
pub width: u32,
pub height: u32,
pub sprites: HashMap<String, Rect>,
@@ -483,7 +492,76 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
/// `MissingTextureAtlasSprite`: what a sprite the atlas lacks draws as.
pub const MISSING_SPRITE: &str = "horizon/missing";
/// The used width of each Standard Galactic letter (A-Z, a-z) in the
/// `font/ascii_sga` sheet: one past its last column with a texel that isn't
/// transparent.
fn galactic_widths(
sprites: &HashMap<String, Rect>,
pixels: &[u8],
width: u32,
height: u32,
) -> [u16; 128] {
let mut widths = [0u16; 128];
let Some(sheet) = sprites.get("font/ascii_sga") else {
return widths;
};
let cell = sheet.w / 16;
let (px, py) = (
(sheet.u0 * width as f32) as u32,
(sheet.v0 * height as f32) as u32,
);
for code in (0x41..=0x5Au32).chain(0x61..=0x7A) {
let (cx, cy) = (px + code % 16 * cell, py + code / 16 * cell);
let alpha = |x: u32, y: u32| {
pixels
.get(((y * width + x) * 4 + 3) as usize)
.copied()
.unwrap_or(0)
};
widths[code as usize] = (0..cell)
.rev()
.find(|&dx| (0..cell).any(|dy| alpha(cx + dx, cy + dy) != 0))
.map_or(0, |dx| (dx + 1).min(u32::from(u16::MAX)) as u16);
}
widths
}
impl HudAtlas {
/// The atlas's pixels: as built, or uncompressed again after
/// `pack_pixels` (a renderer being rebuilt).
pub fn pixels(&self) -> std::borrow::Cow<'_, [u8]> {
let Some(packed) = self.packed.as_ref().filter(|_| self.pixels.is_empty()) else {
return std::borrow::Cow::Borrowed(&self.pixels);
};
let size = (self.width * self.height * 4) as usize;
std::borrow::Cow::Owned(
lz4_flex::decompress_size_prepended(packed.wait())
.ok()
.filter(|p| p.len() == size)
.unwrap_or_else(|| vec![0; size]),
)
}
/// Keep the pixels compressed from now on, once a renderer has them: the
/// window's GPU holds the atlas, and a copy as built only serves a
/// renderer made again (another window, a lost device, another graphics
/// API). They are compressed on a thread of their own; `pixels()` waits
/// for it.
pub fn pack_pixels(&mut self) {
if self.pixels.is_empty() || self.packed.is_some() {
return;
}
let raw = Arc::new(std::mem::take(&mut self.pixels));
let slot = Arc::new(OnceLock::new());
let (job_raw, job_slot) = (raw.clone(), slot.clone());
let spawned = std::thread::Builder::new()
.name("Atlas packing".into())
.spawn(move || {
let _ = job_slot.set(lz4_flex::compress_prepend_size(&job_raw));
});
if spawned.is_err() {
let _ = slot.set(lz4_flex::compress_prepend_size(&raw));
}
self.packed = Some(slot);
}
/// A sprite, or the missing one when the atlas has no such sprite (an
/// effect without its icon), as a stitched atlas answers.
pub fn sprite_or_missing(&self, name: &str) -> Option<Rect> {
@@ -630,7 +708,9 @@ impl HudAtlas {
(glyph_top, height - glyph_top),
white,
),
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
pixels,
packed: None,
width,
height,
sprites,
@@ -1063,7 +1143,9 @@ impl HudAtlas {
white,
);
Ok(Self {
galactic_widths: galactic_widths(&sprites, &pixels, width, height),
pixels,
packed: None,
width,
height,
sprites,
@@ -1956,6 +2038,12 @@ impl HudRenderer {
label: Some("HUD"),
..Default::default()
});
let linear_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("HUD linear"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("HUD size"),
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default())),
@@ -2006,6 +2094,12 @@ impl HudRenderer {
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 5,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
@@ -2032,6 +2126,10 @@ impl HudRenderer {
binding: 4,
resource: wgpu::BindingResource::Sampler(glint_sampler),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&linear_sampler),
},
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
@@ -2134,7 +2232,7 @@ impl HudRenderer {
operation: wgpu::BlendOperation::Add,
},
},
"fs_main",
"fs_vignette",
false,
);
// RenderPipelines.END_PORTAL: opaque.
@@ -2781,6 +2879,18 @@ mod tests {
assert_eq!(texel_at(&atlas, *rect, 15, 15), [0, 0, 255, 255]);
}
#[test]
fn packed_pixels_come_back_the_same() {
let root = animated_root();
let mut atlas = HudAtlas::build(&root).unwrap();
let built = atlas.pixels.clone();
atlas.pack_pixels();
assert!(atlas.pixels.is_empty());
assert!(atlas.pixels() == built.as_slice());
// Packing again keeps the same image.
atlas.pack_pixels();
assert!(atlas.pixels() == built.as_slice());
}
#[test]
fn a_strip_frames_size_comes_from_its_mcmeta() {
let root = animated_root();
let block = root.join("assets/minecraft/textures/block");
@@ -10,6 +10,7 @@ struct Hud {
// enchanted_glint_item on its own repeating texture (vanilla's GlintSampler).
@group(0) @binding(3) var glint_tex: texture_2d<f32>;
@group(0) @binding(4) var glint_sampler: sampler;
@group(0) @binding(5) var linear_sampler: sampler;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@@ -75,6 +76,12 @@ struct CitSlots {
if c.a == 0.0 { discard; }
return c;
}
// RenderPipelines.VIGNETTE (WinScreen's credits vignette): bilinear sample of
// credits_vignette.png (which has blur: true in its .mcmeta and a wrapped border in the atlas).
@fragment fn fs_vignette(in: Out) -> @location(0) vec4<f32> {
let c = textureSample(atlas, linear_sampler, in.uv) * in.color;
return vec4(c.rgb, 1.0);
}
// rendertype_end_portal.fsh (entity.wgsl has the same for portal blocks).
const PORTAL_COLORS = array<vec3<f32>, 16>(
vec3(0.022087, 0.098399, 0.110818),
+3 -1
View File
@@ -49,7 +49,7 @@ pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
pub mod jigsaw_block;
pub mod json_folder;
pub use horizon_world::json_folder;
pub mod level_loading;
pub mod light;
pub mod lod;
@@ -91,7 +91,9 @@ pub mod sound_events;
pub mod sound_preview;
pub mod spans;
pub mod split_layout;
pub mod split_map;
pub mod split_profiles;
pub mod split_ui;
mod sprite_cache;
pub mod sprites;
pub mod streaming;
+11 -17
View File
@@ -562,11 +562,7 @@ fn pack_lighting(
}
}
}
if data.iter().all(|&v| v == data[0]) {
Light::Uniform(data[0])
} else {
Light::Full(data)
}
Light::packed(data)
})
.collect()
}
@@ -810,10 +806,9 @@ impl<'w> Region<'w> {
return;
};
let slot = &mut self.slots[slot];
let light = slot.light.get_or_insert_with(|| match &slot.section.light {
Light::Uniform(v) => Box::new([*v; 4096]),
Light::Full(a) => a.clone(),
});
let light = slot
.light
.get_or_insert_with(|| slot.section.light.to_array());
let i = index(x, y, z);
light[i] = f(light[i]);
}
@@ -830,7 +825,7 @@ impl<'w> Region<'w> {
drop(slot.section);
let (cx, s, cz) = slot.key;
self.world
.set_section_light(cx, s as usize, cz, Light::Full(light));
.set_section_light(cx, s as usize, cz, Light::packed(light));
}
}
}
@@ -1624,21 +1619,20 @@ mod tests {
current.set(8, MIN_Y, 8, TORCH);
let region = [[None; 3], [None, Some(&current), None], [None; 3]];
let lights = light_chunk(&region, &tables(), true);
let Light::Full(data) = &lights[0] else {
panic!("torch light varies");
let storage = |light: &Light| match light {
Light::Full(data) => data.as_ptr(),
Light::Sky(layer, _) | Light::Block(layer, _) => layer.as_ptr(),
Light::Uniform(_) => panic!("torch light varies"),
};
let result_storage = data.as_ptr();
let result_storage = storage(&lights[0]);
let snapshot = current.clone();
current.set(8, MIN_Y, 8, STONE);
Arc::make_mut(&mut current.sections[0]).biomes.set(0, 3);
let edited_snapshot = current.clone();
apply_lighting(&mut current, lights);
let Light::Full(data) = &current.sections[0].light else {
panic!("torch light varies");
};
assert_eq!(
result_storage,
data.as_ptr(),
storage(&current.sections[0].light),
"returned light storage moves without cloning"
);
current.lit = true;
+6 -1
View File
@@ -214,7 +214,12 @@ impl Mesher {
Paint {
layer: face.layer,
light: span.light,
color: channel(16) | channel(8) << 8 | channel(0) << 16 | 0xff000000,
// The HDR tier, as the section mesher writes it, so a light source
// keeps shining across the LOD boundary.
color: crate::mesher::emitting(
channel(16) | channel(8) << 8 | channel(0) << 16 | 0xff000000,
context.tables.hdr_tier(span.state),
),
translucent: face.translucent,
}
})
+157 -47
View File
@@ -4,6 +4,11 @@
//! framebuffer pixels wide, before the screen is drawn over it. Works in the
//! frame's own (gamma) values, as vanilla's main target does, and needs a
//! swapchain that can be copied from.
//!
//! On a software adapter (WARP, llvmpipe) the rounds run at half the frame's
//! width and height with half the radius: the same blur, a quarter of the
//! pixels. At full size they took 250 ms of a CPU per frame at 1440x900, and
//! every blurred menu ran at 3 FPS (#5185).
/// `blur.json`: the passes' directions, ping-ponging between the copied
/// frame and a swap texture; the last one writes the frame.
@@ -27,15 +32,20 @@ pub struct MenuBlur {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
sampler: wgpu::Sampler,
/// One per pass, then the half-size copy's.
uniforms: Vec<wgpu::Buffer>,
/// The copied frame (`minecraft:main`) and `swap`.
/// The copied frame (`minecraft:main`) and `swap`; at half size, then the
/// frame copied at full size.
targets: Vec<(wgpu::Texture, wgpu::TextureView)>,
size: [u32; 2],
format: wgpu::TextureFormat,
/// Blur at half size (`new`'s `software`).
half: bool,
}
impl MenuBlur {
/// `format` is the swapchain's; the blur works in its non-sRGB view.
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
/// `format` is the swapchain's; the blur works in its non-sRGB view. `software`:
/// the adapter renders on the CPU, so the blur runs at half size.
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, software: bool) -> Self {
let format = format.remove_srgb_suffix();
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Menu blur"),
@@ -111,8 +121,7 @@ impl MenuBlur {
address_mode_v: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
let uniforms = PASSES
.iter()
let uniforms = (0..=PASSES.len())
.map(|_| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Menu blur pass"),
@@ -130,6 +139,15 @@ impl MenuBlur {
targets: Vec::new(),
size: [0, 0],
format,
half: software,
}
}
/// The size the blur's rounds run at for a frame of `size`.
fn work_size(&self, size: [u32; 2]) -> [u32; 2] {
if self.half {
size.map(|s| s.div_ceil(2))
} else {
size
}
}
fn ensure(&mut self, device: &wgpu::Device, size: [u32; 2]) {
@@ -137,8 +155,15 @@ impl MenuBlur {
return;
}
self.size = size;
self.targets = (0..2)
.map(|_| {
let work = self.work_size(size);
let sizes = if self.half {
vec![work, work, size]
} else {
vec![work, work]
};
self.targets = sizes
.into_iter()
.map(|size| {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Menu blur target"),
size: wgpu::Extent3d {
@@ -180,65 +205,112 @@ impl MenuBlur {
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
self.ensure(device, size);
let work = self.work_size(size);
let copy = if self.half { 2 } else { 0 };
encoder.copy_texture_to_texture(
frame.as_image_copy(),
self.targets[0].0.as_image_copy(),
self.targets[copy].0.as_image_copy(),
wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
);
let radius = if self.half { radius / 2.0 } else { radius };
if self.half {
// A radius of 0 samples each half-size pixel's 2x2 block at its shared
// corner: their mean.
let data = Uniform {
size_dir: [size[0] as f32, size[1] as f32, 0.0, 0.0],
radius: [0.0; 4],
};
let (source, output) = (&self.targets[2].1, &self.targets[0].1);
self.pass(
device,
queue,
encoder,
PASSES.len(),
data,
source,
output,
None,
);
}
for (i, dir) in PASSES.iter().enumerate() {
let data = Uniform {
size_dir: [size[0] as f32, size[1] as f32, dir[0], dir[1]],
size_dir: [work[0] as f32, work[1] as f32, dir[0], dir[1]],
radius: [radius, 0.0, 0.0, 0.0],
};
queue.write_buffer(&self.uniforms[i], 0, bytemuck::bytes_of(&data));
let source = &self.targets[i % 2].1;
// The last pass writes the frame; from half size it samples up as it blurs.
let output = if i + 1 == PASSES.len() {
target
} else {
&self.targets[(i + 1) % 2].1
};
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu blur pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[i].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: output,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
self.pass(
device,
queue,
encoder,
i,
data,
source,
output,
timing.as_deref_mut(),
);
}
}
/// One `box_blur` pass from `source` into `output` with uniform buffer `slot`.
#[allow(clippy::too_many_arguments)]
fn pass(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
slot: usize,
data: Uniform,
source: &wgpu::TextureView,
output: &wgpu::TextureView,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
queue.write_buffer(&self.uniforms[slot], 0, bytemuck::bytes_of(&data));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu blur pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[slot].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: output,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
}
#[cfg(test)]
@@ -318,7 +390,7 @@ mod tests {
else {
return;
};
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, false);
let size = [64, 8];
let first = blurred(&device, &queue, &mut blur, size);
// The stripes are blurred into greys.
@@ -327,6 +399,44 @@ mod tests {
assert_eq!(blurred(&device, &queue, &mut blur, size), first);
}
/// On a software adapter the blur runs at half size and still blurs the
/// frame much as at full size: stripes two pixels wide turn to an even grey.
#[test]
fn the_half_size_blur_blurs_like_the_full_size_one() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
let size = [64, 8];
let full = blurred(
&device,
&queue,
&mut MenuBlur::new(&device, format, false),
size,
);
let half = blurred(
&device,
&queue,
&mut MenuBlur::new(&device, format, true),
size,
);
// Away from the edges, both are within a few levels of the stripes' mean grey.
for row in 0..size[1] as usize {
for x in 8..56 {
let at = (row * size[0] as usize + x) * 4;
for (name, frame) in [("full", &full), ("half", &half)] {
let v = frame[at];
assert!((120..=136).contains(&v), "{name} size: {v} at ({x}, {row})");
}
}
}
}
#[test]
fn box_blur_shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("menu_blur.wgsl")).unwrap();
+301 -141
View File
@@ -10,9 +10,9 @@ mod sides;
use crate::connected::{Pick, TileLayer};
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
biome::{Biomes, GrassModifier},
blocks::{BlockFacts, RenderShape},
chunk::{Chunk, Light, MIN_Y},
chunk::{Chunk, MIN_Y},
graphics::LeafCullingMode,
models::catalog::BakedFace,
};
@@ -73,6 +73,15 @@ pub struct TerrainVertex {
/// RGBA8: tint multiplied by directional shade and ambient occlusion.
pub color: u32,
}
/// `v.round().clamp(0.0, 65535.0) as u16`, without the `roundf` call x86-64's
/// baseline instruction set makes of `f32::round`: clamped first (the same
/// result), the fraction is exact below 2^23, and every float above is whole.
#[inline]
fn round_to_u16(v: f32) -> u16 {
let v = v.clamp(0.0, f32::from(u16::MAX));
let whole = v as u32;
(whole + u32::from(v - whole as f32 >= 0.5)) as u16
}
/// Fixed-point steps per unit of `TerrainVertex` UVs, which span `[0, 2)`.
pub const UV_STEPS: f32 = 32768.0;
/// Fixed-point steps per block of distant-terrain UVs (`TerrainVertex::tiled`),
@@ -82,7 +91,7 @@ impl TerrainVertex {
pub fn new(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
Self {
pos,
uv: uv.map(|v| (v * UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
uv: uv.map(|v| round_to_u16(v * UV_STEPS)),
layer,
light,
color,
@@ -93,7 +102,7 @@ impl TerrainVertex {
pub fn tiled(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
Self {
pos,
uv: uv.map(|v| (v * TILED_UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
uv: uv.map(|v| round_to_u16(v * TILED_UV_STEPS)),
layer,
light,
color,
@@ -116,6 +125,30 @@ mod vertex_tests {
}
}
#[test]
fn uv_rounding_matches_f32_round() {
let reference = |v: f32| v.round().clamp(0.0, f32::from(u16::MAX)) as u16;
let mut values = vec![
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
-0.0,
0.5,
1.5,
65534.5,
1e9,
];
for bits in (0..u32::MAX).step_by(9973) {
values.push(f32::from_bits(bits));
}
for i in 0..=140_000 {
values.push(i as f32 * 0.5 - 2.0);
values.push(i as f32 * 0.5 - 2.0 + f32::EPSILON);
}
for v in values {
assert_eq!(super::round_to_u16(v), reference(v), "{v:e}");
}
}
#[test]
fn other_uvs_round_within_a_step() {
// A flowing fluid's rotated UV.
let uv = [0.5 + 0.25 * 0.3f32.sin(), 0.5 - 0.25 * 0.3f32.cos()];
@@ -333,94 +366,112 @@ pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
#[cfg(test)]
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
visibility_reusing(pad, info, &mut Vec::new())
let solid: Vec<bool> = info.iter().map(|i| i.solid).collect();
let mut rows = Vec::new();
solid_rows(pad, &solid, &mut rows);
visibility_rows(&open_rows(&rows))
}
fn visibility_reusing(pad: &Padded, info: &[Info], stack: &mut Vec<usize>) -> u64 {
// The all-visible shortcut can leave a partially filled stack behind.
stack.clear();
// Opaque cells are already visited: resolve each state's opacity once,
// then reuse this map for the flood fill and the uniform-section shortcuts.
let mut seen = [false; 4096];
let mut opaque_count = 0;
for y in 0..16 {
for z in 0..16 {
let row = pi(0, y, z);
for x in 0..16 {
let opaque = info[pad.blocks[row + x] as usize].solid;
seen[(y as usize) << 8 | (z as usize) << 4 | x] = opaque;
opaque_count += usize::from(opaque);
}
}
/// The section's cells that are not `isSolidRender`, by row (`y << 4 | z`, bit
/// x), from the padded rows of `MeshScratch::solid_rows`.
fn open_rows(solid: &[u32]) -> [u16; 256] {
std::array::from_fn(|r| {
let (y, z) = ((r >> 4) as i32, (r & 15) as i32);
!(solid[((y + B) as usize) * P + (z + B) as usize] >> B) as u16
})
}
/// The cells of `open` connected to `seeds` (a subset) within the row: an
/// occluded fill each way, doubling its reach at each step.
#[inline]
fn fill_row(seeds: u16, open: u16) -> u16 {
let (mut up, mut through_up) = (seeds, open);
let (mut down, mut through_down) = (seeds, open);
for step in [1, 2, 4, 8] {
up |= through_up & up << step;
through_up &= through_up << step;
down |= through_down & down >> step;
through_down &= through_down >> step;
}
if opaque_count == 0 {
up | down
}
/// `visibility` on bit rows, one region at a time: a region grows a row at
/// a time (each row filled along its open runs at once) by sweeps over the
/// rows it can reach, until a sweep adds nothing.
fn visibility_rows(open: &[u16; 256]) -> u64 {
let open_count: u32 = open.iter().map(|r| r.count_ones()).sum();
if open_count == 4096 {
return ALL_VISIBLE;
}
if opaque_count == 4096 {
if open_count == 0 {
return 0;
}
let mut unvisited = *open;
let mut region = [0u16; 256];
let mut result = 0u64;
for start in 0..4096usize {
if seen[start] {
continue;
}
let mut faces = 0u8;
seen[start] = true;
stack.push(start);
while let Some(c) = stack.pop() {
let (x, y, z) = ((c & 15) as i32, (c >> 8) as i32, ((c >> 4) & 15) as i32);
if y == 0 {
faces |= 1 << 0;
for start in 0..256 {
while unvisited[start] != 0 {
let seed = unvisited[start] & unvisited[start].wrapping_neg();
region[start] = fill_row(seed, unvisited[start]);
let (mut lo, mut hi) = (start, start);
loop {
let mut grown = false;
let (first, last) = (lo.saturating_sub(16), (hi + 16).min(255));
for r in (first..=last).chain((first..=last).rev()) {
let mut near = region[r];
if r >= 16 {
near |= region[r - 16];
}
if r + 16 < 256 {
near |= region[r + 16];
}
if r & 15 != 0 {
near |= region[r - 1];
}
if r & 15 != 15 {
near |= region[r + 1];
}
let reached = near & unvisited[r];
if reached & !region[r] != 0 {
region[r] = fill_row(reached, unvisited[r]);
(lo, hi) = (lo.min(r), hi.max(r));
grown = true;
}
}
if !grown {
break;
}
}
if y == 15 {
faces |= 1 << 1;
}
if z == 0 {
faces |= 1 << 2;
}
if z == 15 {
faces |= 1 << 3;
}
if x == 0 {
faces |= 1 << 4;
}
if x == 15 {
faces |= 1 << 5;
let mut faces = 0u8;
for r in lo..=hi {
let row = std::mem::take(&mut region[r]);
if row == 0 {
continue;
}
unvisited[r] &= !row;
let (y, z) = (r >> 4, r & 15);
faces |= u8::from(y == 0)
| u8::from(y == 15) << 1
| u8::from(z == 0) << 2
| u8::from(z == 15) << 3
| u8::from(row & 1 != 0) << 4
| u8::from(row & 0x8000 != 0) << 5;
}
// A single connected region touching all six faces sets every
// pair (including the diagonal); unvisited cavities cannot add more.
if faces == 0b11_1111 {
return ALL_VISIBLE;
}
for (dx, dy, dz) in [
(1, 0, 0),
(-1, 0, 0),
(0, 1, 0),
(0, -1, 0),
(0, 0, 1),
(0, 0, -1),
] {
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
if !(0..16).contains(&nx) || !(0..16).contains(&ny) || !(0..16).contains(&nz) {
continue;
}
let n = (ny << 8 | nz << 4 | nx) as usize;
if !seen[n] {
seen[n] = true;
stack.push(n);
}
}
}
for a in 0..6 {
if faces & (1 << a) != 0 {
for b in 0..6 {
if faces & (1 << b) != 0 {
result |= 1 << (a * 6 + b);
for a in 0..6 {
if faces & (1 << a) != 0 {
for b in 0..6 {
if faces & (1 << b) != 0 {
result |= 1 << (a * 6 + b);
}
}
}
}
}
if result == ALL_VISIBLE {
return result;
if result == ALL_VISIBLE {
return result;
}
}
}
result
@@ -509,6 +560,9 @@ pub struct MeshTables {
/// reads 8,000 states, and a few bytes each stay in cache where `Info`
/// does not.
solid: Vec<bool>,
/// By state, bit `d` set where its occlusion face toward direction `d` is the
/// full block (`Info::face` 1): a face looking at it never shows.
full_faces: Vec<u8>,
pub visibility: FaceVisibility,
water_still: u16,
water_flow: u16,
@@ -678,6 +732,10 @@ impl MeshTables {
}
Ok(Self {
solid: info.iter().map(|i| i.solid).collect(),
full_faces: info
.iter()
.map(|i| (0..6).fold(0, |bits, d| bits | u8::from(i.face[d] == 1) << d))
.collect(),
info,
visibility,
water_still: layer("minecraft:block/water_still")?,
@@ -695,6 +753,10 @@ impl MeshTables {
.copied()
.unwrap_or(false)
}
/// HDR output's tier of a state (`hdr_tier`), for what meshes it elsewhere (the LOD tiles).
pub fn hdr_tier(&self, state: BlockStateId) -> u8 {
self.info.get(state.0 as usize).map_or(0, |i| i.tier)
}
pub fn facts(&self, state: BlockStateId) -> Option<MeshFacts> {
let i = self.info.get(state.0 as usize)?;
Some(MeshFacts {
@@ -851,68 +913,96 @@ impl Padded {
let (blocks, light) = (&mut self.blocks, &mut self.light);
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
// The section itself is unpacked in one pass and copied row by row;
// the border cells of the neighbouring sections are read one by one,
// each run of them from a section found once.
// the border cells of the neighbouring sections are read in runs, from
// sections found once per layer.
own.fill(0);
let center = region[1][1].map(|c| &c.sections[sy as usize]);
if let Some(section) = center {
section.unpack(own);
let center = region[1][1];
if let Some(c) = center {
c.sections[sy as usize].unpack(own);
}
// The x runs of a padded row by the chunk column they fall in.
const RUNS: [(usize, i32, i32); 3] = [(0, -B, 0), (1, 0, 16), (2, 16, 16 + B)];
for y in -B..16 + B {
let wy = base_y + y;
// Where each chunk column's cells of this layer come from, found once.
let sources: [[Source; 3]; 3] = std::array::from_fn(|rz| {
std::array::from_fn(|rx| match region[rz][rx] {
None => Source::Missing,
Some(c) => match c.section_at(wy) {
Ok(section) => Source::Section(section),
Err(light) => Source::Outside(light),
},
})
});
for z in -B..16 + B {
let row = pi(-B, y, z);
let rz = ((z + 16) >> 4) as usize;
for (rx, x0, x1) in RUNS {
let (start, end) = (row + (x0 + B) as usize, row + (x1 + B) as usize);
let (blocks, light) = (&mut blocks[start..end], &mut light[start..end]);
let Some(c) = region[rz][rx] else {
match region[1][1] {
// A neighbour not loaded continues this chunk's edge
// column. As lit air, the outermost chunks' water drew
// a wall toward it (fluids show both sides), a light
// sheet in the water where far terrain carries on.
// Vanilla draws no chunk without its neighbours.
Some(c) => {
let ez = z.clamp(0, 15);
for (k, x) in (x0..x1).enumerate() {
let ex = x.clamp(0, 15);
blocks[k] = c.get(ex, wy, ez);
light[k] = c.light(ex, wy, ez);
}
}
None => {
blocks.fill(0);
light.fill(0xF0);
}
}
continue;
};
// `Chunk::get` and `Chunk::light` with the section found once.
let s = (wy - c.min_y()) >> 4;
let Some(section) = c.sections.get(s as usize).filter(|_| s >= 0) else {
blocks.fill(0);
light.fill(if s < 0 { 0 } else { 0xF0 });
continue;
};
let at = crate::chunk::index(x0, wy, z);
if rx == 1 && rz == 1 && (0..16).contains(&y) {
let own_row = (y << 8 | z << 4) as usize;
blocks.copy_from_slice(&own[own_row..own_row + 16]);
} else {
section.blocks.get_run(at, blocks);
}
match &section.light {
Light::Uniform(v) => light.fill(*v),
Light::Full(all) => light.copy_from_slice(&all[at..at + light.len()]),
}
}
let (west, rest) = blocks[row..row + P].split_at_mut(B as usize);
let (middle, east) = rest.split_at_mut(16);
let (west_light, rest) = light[row..row + P].split_at_mut(B as usize);
let (middle_light, east_light) = rest.split_at_mut(16);
let own_row = (rz == 1 && (0..16).contains(&y))
.then(|| &own[(y << 8 | z << 4) as usize..][..16]);
let at = |x: i32| crate::chunk::index(x, wy, z);
let [w, m, e] = &sources[rz];
let edge = (center, wy, z);
fill_run(west, west_light, w, None, at(-B), -B, edge);
fill_run(middle, middle_light, m, own_row, at(0), 0, edge);
fill_run(east, east_light, e, None, at(16), 16, edge);
}
}
}
}
/// Where the cells of one chunk column at one height come from (`Padded::fill`).
enum Source<'a> {
/// A neighbour not loaded.
Missing,
/// Above or below the column's sections: air with this light.
Outside(u8),
Section(&'a crate::chunk::Section),
}
/// Fills one run of a padded row, `blocks.len()` cells from x = `x0`, starting
/// at section index `at`; `own` is the centre section's row, already unpacked.
#[inline(always)]
fn fill_run(
blocks: &mut [u32],
light: &mut [u8],
source: &Source,
own: Option<&[u32]>,
at: usize,
x0: i32,
(center, wy, z): (Option<&Chunk>, i32, i32),
) {
match source {
Source::Section(section) => {
match own {
Some(own) => blocks.copy_from_slice(own),
None => section.blocks.get_run(at, blocks),
}
section.light.copy_run(at, light);
}
Source::Outside(v) => {
blocks.fill(0);
light.fill(*v);
}
// A neighbour not loaded continues this chunk's edge column. As lit
// air, the outermost chunks' water drew a wall toward it (fluids show
// both sides), a light sheet in the water where far terrain carries
// on. Vanilla draws no chunk without its neighbours.
Source::Missing => match center {
Some(c) => {
let ez = z.clamp(0, 15);
for (k, (block, light)) in blocks.iter_mut().zip(light.iter_mut()).enumerate() {
let ex = (x0 + k as i32).clamp(0, 15);
*block = c.get(ex, wy, ez);
*light = c.light(ex, wy, ez);
}
}
None => {
blocks.fill(0);
light.fill(0xF0);
}
},
}
}
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
@@ -920,14 +1010,13 @@ impl Padded {
pub struct MeshScratch {
padded: Padded,
own: Option<Box<[u32; 4096]>>,
visibility: Vec<usize>,
leaf: leaf_culling::LeafMask,
/// Per padded row (`pi(-B, y, z) / P`), bit `x + B` set where the block
/// is `isSolidRender`.
opaque: Vec<u32>,
/// is `isSolidRender` (`MeshTables::solid`).
solid_rows: Vec<u32>,
}
/// Fills `rows` with the padded section's opacity, one bit per block (`MeshScratch::opaque`).
fn opaque_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
/// Fills `rows` with the padded section's `isSolidRender` blocks, one bit each (`MeshScratch::solid_rows`).
fn solid_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
rows.clear();
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
@@ -949,6 +1038,8 @@ fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
mod leaf_culling_tests;
#[cfg(test)]
mod reuse_tests;
#[cfg(test)]
mod tint_tests;
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
pub const OPAQUE_LAYER: u16 = 1 << 15;
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
@@ -1061,6 +1152,8 @@ struct Tints<'a> {
/// Chunk x, chunk z and the section's lowest block y.
origin: [i32; 3],
layers: [Option<Box<Layer>>; 16],
/// `uniform_biome`, once computed.
uniform: Option<Option<u16>>,
}
impl<'a> Tints<'a> {
fn new(
@@ -1078,6 +1171,7 @@ impl<'a> Tints<'a> {
plains: biomes.id("minecraft:plains"),
origin,
layers: Default::default(),
uniform: None,
}
}
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
@@ -1116,8 +1210,52 @@ impl<'a> Tints<'a> {
plains
}
}
/// The biome every block of the section's blend squares zooms to, when they
/// all do: `quart_at_block` picks one of the eight quart cells at its base
/// cell plus 0 or 1 on each axis, so if every such cell the section reaches
/// holds one biome, every zoomed cell is that biome.
fn uniform_biome(&mut self) -> Option<u16> {
if let Some(uniform) = self.uniform {
return uniform;
}
let (r, [cx, cz, base_y]) = (self.radius, self.origin);
// The base cells of blocks lo..=hi, and the cell past the last.
let span = |lo: i32, hi: i32| (lo.wrapping_sub(2) >> 2)..=(hi.wrapping_sub(2) >> 2) + 1;
let (xs, ys, zs) = (
span(cx * 16 - r, cx * 16 + 15 + r),
span(base_y, base_y + 15),
span(cz * 16 - r, cz * 16 + 15 + r),
);
let first = Self::quart_biome(
self.region,
self.plains,
[cx, cz],
[*xs.start(), *ys.start(), *zs.start()],
);
let uniform = ys
.flat_map(|y| zs.clone().map(move |z| (y, z)))
.flat_map(|(y, z)| xs.clone().map(move |x| [x, y, z]))
.all(|q| Self::quart_biome(self.region, self.plains, [cx, cz], q) == first)
.then_some(first);
self.uniform = Some(uniform);
uniform
}
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
// One biome over every blend square: the mean of (2r+1)^2 equal
// colours is that colour, unless it varies by position (swamp grass).
if let Some(biome) = self.uniform_biome() {
let info = self.biomes.get(biome);
match resolver {
Resolver::Grass if !matches!(info.modifier, GrassModifier::Swamp) => {
return self.biomes.grass(biome, 0.0, 0.0) & 0xFF_FFFF;
}
Resolver::Grass => {}
Resolver::Foliage => return info.foliage & 0xFF_FFFF,
Resolver::Dry => return info.dry_foliage & 0xFF_FFFF,
Resolver::Water => return info.water & 0xFF_FFFF,
}
}
let r = self.radius;
let dim = (16 + 2 * r) as usize;
let layer = self.layers[y as usize].get_or_insert_with(|| {
@@ -1209,6 +1347,8 @@ pub struct Mesher<'a> {
pub nether_shading: bool,
/// A shader pack lights the faces (its `oldLighting` off): no face shading in the colours.
pub shader_pack_lighting: bool,
/// OptiFine/Iris separate ambient occlusion: vertex RGB does not include AO, and AO factor is passed in vertex alpha.
pub separate_ao: bool,
pub assets: &'a Assets,
pub render: &'a RenderAssets,
pub tables: &'a MeshTables,
@@ -1255,9 +1395,9 @@ impl Mesher<'_> {
let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096]));
scratch.padded.fill(region, sy, own);
let pad = &scratch.padded;
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
opaque_rows(pad, &self.tables.solid, &mut scratch.opaque);
let opaque = &scratch.opaque;
solid_rows(pad, &self.tables.solid, &mut scratch.solid_rows);
let opaque = &scratch.solid_rows;
out.visibility = visibility_rows(&open_rows(opaque));
let base_y = center.min_y() + sy * 16;
if self.leaf_culling.is_interior() {
scratch
@@ -1272,6 +1412,7 @@ impl Mesher<'_> {
[center.x, center.z, base_y],
);
let info = &self.tables.info;
let full_faces = &self.tables.full_faces;
let catalog = &self.render.model_catalog;
let mut models = Vec::new();
for y in 0..16 {
@@ -1295,7 +1436,11 @@ impl Mesher<'_> {
}
if state.fluid.kind() != FluidKind::Empty {
let water = tints.get(Resolver::Water, x, y, z);
let shade = (self.nether_shading, self.shader_pack_lighting);
let shade = (
self.nether_shading,
self.shader_pack_lighting,
self.separate_ao,
);
fluid::tesselate(self.tables, shade, pad, water, &mut out, [x, y, z]);
}
if !state.model {
@@ -1329,6 +1474,11 @@ impl Mesher<'_> {
for d in Direction::ALL {
let [dx, dy, dz] = d.normal();
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
// `should_render_face`'s first test, without its lookups: a
// full occlusion face toward this block hides the face.
if full_faces[neighbor as usize] >> d.opposite() as u8 & 1 != 0 {
continue;
}
let other = &info[neighbor as usize];
// LeavesBlock.skipRendering with opaque leaves.
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
@@ -1438,19 +1588,23 @@ impl Mesher<'_> {
} else {
lighting::cardinal(self.nether_shading, shade_face)
};
// Grey level per vertex before tinting, and vanilla light coords.
let (gray, coords) = if ao {
// Grey level per vertex before tinting, vanilla light coords, and ambient occlusion factor.
let (gray, coords, ao_factor) = if ao {
let info = &self.tables.info;
let (brightness, coords) =
lighting::ambient_occlusion(pad, info, state, pos, &q.positions, q.facing);
let gray = brightness.map(|b| lighting::scale(lighting::gray(b), directional));
(gray, coords)
let gray = if self.separate_ao {
[lighting::gray(directional); 4]
} else {
brightness.map(|b| lighting::scale(lighting::gray(b), directional))
};
(gray, coords, brightness)
} else {
let coords = match q.cull {
Some(d) => lighting::coords(pad, state, lighting::step(pos, d)),
None => lighting::flat(pad, state, pos, &q.positions, q.facing),
};
([lighting::gray(directional); 4], [coords; 4])
([lighting::gray(directional); 4], [coords; 4], [1.0f32; 4])
};
let tint = Self::tint(state, q.tint_index, tints, pos);
// OptiFine connected textures: another sprite, or one per quarter, and the overlays
@@ -1493,12 +1647,18 @@ impl Mesher<'_> {
None => [g; 3],
};
let p = q.positions[i];
let color = if self.separate_ao {
let ao_byte = (ao_factor[i] * 255.0).round().clamp(0.0, 255.0) as u32;
ao_byte << 24 | (pack_rgb(rgb) & 0x00FF_FFFF)
} else {
emitting(pack_rgb(rgb), tier)
};
(
TerrainVertex::new(
[p[0] + origin[0], p[1] + origin[1], p[2] + origin[2]],
uv[i],
0,
emitting(pack_rgb(rgb), tier),
color,
pack_coords(lighting::with_emission(coords[i], q.light_emission)),
),
uv[i],
@@ -189,11 +189,11 @@ impl FluidFlowWorld for FlowView<'_> {
}
}
/// Emits one fluid cell at section-relative `[x, y, z]`.
/// `(nether, shader_pack_lighting)`: the Nether's face shading, or none when a shader pack lights
/// the faces (`Mesher::shader_pack_lighting`).
/// `(nether, shader_pack_lighting, separate_ao)`: the Nether's face shading, none when a shader pack
/// lights the faces (`Mesher::shader_pack_lighting`), and whether to keep pure alpha for separate AO.
pub(super) fn tesselate(
tables: &MeshTables,
(nether, shader_pack_lighting): (bool, bool),
(nether, shader_pack_lighting, separate_ao): (bool, bool, bool),
pad: &Padded,
water_color: u32,
out: &mut SectionMesh,
@@ -244,7 +244,11 @@ pub(super) fn tesselate(
};
let color = |by: f32| {
let rgb = [16, 8, 0].map(|shift| lighting::scale((tint >> shift & 255) as i32, by) as u32);
crate::mesher::emitting(pack_rgb(rgb), emission)
if separate_ao {
pack_rgb(rgb)
} else {
crate::mesher::emitting(pack_rgb(rgb), emission)
}
};
let height = |p: [i32; 3]| -> f32 {
let s = at(p);
@@ -330,7 +334,11 @@ pub(super) fn tesselate(
if tables.is_animated(layer) && !sprite_layers.contains(&layer) {
sprite_layers.push(layer);
}
let color = fluid_face(color, face);
let color = if separate_ao {
color
} else {
fluid_face(color, face)
};
let vertex = |(pos, uv)| TerrainVertex::new(pos, uv, layer, color, light);
target.extend(v.map(vertex));
if back {
@@ -72,6 +72,7 @@ fn mesh(
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
assets: &f.assets,
render: &f.render,
tables: &f.tables,
@@ -83,9 +83,17 @@ fn weighted_blend(c: [u32; 4], w: [f32; 4]) -> u32 {
};
channel(0) | channel(16) << 16
}
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`.
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`. Without the `floorf`
/// call x86-64's baseline instruction set makes of `f32::floor`: truncation,
/// one lower where that rounded a negative value up (saturating as `as` does).
pub(super) fn gray(brightness: f32) -> i32 {
(brightness * 255.0).floor() as i32
let v = brightness * 255.0;
let whole = v as i32;
if whole as f32 > v {
whole.saturating_sub(1)
} else {
whole
}
}
/// One channel of `ARGB.scaleRGB(color, float)`.
pub(super) fn scale(channel: i32, by: f32) -> i32 {
@@ -406,6 +414,28 @@ mod tests {
);
}
#[test]
fn gray_floors_like_f32_floor() {
let mut values = vec![
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
-0.0,
1.0,
-1.0,
1e10,
-1e10,
];
for bits in (0..u32::MAX).step_by(7919) {
values.push(f32::from_bits(bits));
}
for i in -3000..3000 {
values.push(i as f32 / 1024.0);
}
for v in values {
assert_eq!(gray(v), (v * 255.0).floor() as i32, "{v:e}");
}
}
#[test]
fn colour_arithmetic_matches_argb_helpers() {
assert_eq!(gray(1.0), 255);
assert_eq!(gray(0.8), 204);
@@ -1,4 +1,5 @@
use super::*;
use crate::chunk::Light;
#[test]
fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts() {
@@ -45,6 +46,65 @@ fn padded_reuse_resets_missing_neighbors_and_matches_chunk_reads_across_layouts(
}
}
#[test]
fn padded_fill_reads_every_neighbour_and_its_light_like_chunk_reads() {
let mut state = 0x2545_F491_4F6C_DD1Du64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
let mut pad = Padded::default();
let mut own = Box::new([0; 4096]);
for min_y in [MIN_Y, 0] {
let layout = crate::chunk::ColumnLayout::new(min_y, 32 * 16).unwrap();
// Nine chunks with their own blocks and per-block light, near the
// bottom and top sections too, where the padding leaves the column.
let chunks: Vec<Chunk> = (0..9)
.map(|i| {
let mut c = Chunk::with_layout(i % 3 - 1, i / 3 - 1, layout);
for s in [0, 1, 30, 31] {
for _ in 0..300 {
let (x, y, z) = (next() % 16, next() % 16, next() % 16);
let y = min_y + s * 16 + y as i32;
c.set(x as i32, y, z as i32, 1 + (next() % 40) as u32);
}
let mut light = Box::new([0u8; 4096]);
light.iter_mut().for_each(|l| *l = next() as u8);
std::sync::Arc::make_mut(&mut c.sections[s as usize]).light =
Light::Full(light);
}
c
})
.collect();
let region: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| Some(&chunks[rz * 3 + rx])));
for sy in [0, 1, 30, 31] {
pad.fill(&region, sy, &mut own);
let base_y = min_y + sy * 16;
for y in -B..16 + B {
for z in -B..16 + B {
for x in -B..16 + B {
let c = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize].unwrap();
let (cx, cy, cz) = (x & 15, base_y + y, z & 15);
assert_eq!(
pad.blocks[pi(x, y, z)],
c.get(cx, cy, cz),
"{min_y} {sy} {x} {y} {z}"
);
assert_eq!(
pad.light[pi(x, y, z)],
c.light(cx, cy, cz),
"{min_y} {sy} {x} {y} {z}"
);
}
}
}
}
}
}
/// Run explicitly with prepared assets; never silently pass without exercising geometry.
/// With a shader pack lighting the faces, every face of a lone stone block has one colour; the
/// game's face shading (up 1.0, down 0.5, north/south 0.8, east/west 0.6) is left to the pack.
@@ -72,6 +132,7 @@ fn a_shader_pack_lights_the_faces_itself() {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting,
separate_ao: false,
assets: &assets,
render: &render,
tables: &tables,
@@ -98,6 +159,54 @@ fn a_shader_pack_lights_the_faces_itself() {
assert_eq!(colours(true).len(), 1);
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn separate_ao_passes_ao_in_alpha_without_darkening_rgb() {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
let assets = Assets::load(&root).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let biomes = Biomes::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let visibility = crate::models::load_visibility(&root, assets.registry).unwrap();
let tables = MeshTables::new(&assets, &render, &facts, visibility.clone()).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap().0;
let mut chunk = Chunk::empty(0, 0);
// Place stone with neighbouring blocks to create AO variations.
chunk.set(8, MIN_Y + 8, 8, stone);
chunk.set(9, MIN_Y + 8, 8, stone);
let mut region = [[None; 3]; 3];
region[1][1] = Some(&chunk);
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: true,
separate_ao: true,
assets: &assets,
render: &render,
tables: &tables,
biomes: &biomes,
visibility,
zoom: BiomeZoom::new(0),
blend_radius: 0,
smooth_lighting: true,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
let mesh = mesher.mesh(&region, 0);
assert!(!mesh.solid.is_empty());
for v in &mesh.solid {
// RGB components should be pure un-darkened white (0xFFFFFF) because shader_pack_lighting is on.
assert_eq!(v.color & 0x00FF_FFFF, 0x00FF_FFFF);
// Alpha holds the AO factor, which must be non-zero.
assert!(v.color >> 24 > 0);
}
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
@@ -149,6 +258,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
let mut mesher = Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
assets: &assets,
render: &render,
tables: &tables,
@@ -181,7 +291,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
assert!(scratch.own.is_none());
assert_eq!(scratch.padded.blocks.capacity(), 0);
assert_eq!(scratch.padded.light.capacity(), 0);
assert_eq!(scratch.visibility.capacity(), 0);
assert_eq!(scratch.solid_rows.capacity(), 0);
}
fresh.sort_solid();
recycled.sort_solid();
@@ -103,22 +103,6 @@ impl Sides {
}
}
/// A sort with these parts' `ends` and `planes`, for tests.
#[cfg(test)]
pub(crate) fn from_parts(
ends: [u32; 14],
grouped: bool,
split: bool,
planes: [f32; 7],
) -> Self {
Self {
ends,
grouped,
split,
planes,
}
}
/// The number of solid quads the buckets hold.
pub fn quads(&self) -> u32 {
self.ends[13]
@@ -144,26 +128,6 @@ impl Sides {
self.runs(seen, part, draw);
}
/// How many quads `part` draws facing every way (`every`).
pub fn part_quads(&self, part: Part) -> u32 {
let mut quads = 0;
self.every(part, |_, count| quads += count);
quads
}
/// The facts `visible_from` reads, for the GPU to find the same runs
/// (gpu_lists.wgsl): the parts' ends, the planes' bits,
/// then 1 when grouped plus 2 when split.
pub fn gpu_row(&self) -> [u32; 22] {
let mut row = [0; 22];
row[..14].copy_from_slice(&self.ends);
for (to, plane) in row[14..21].iter_mut().zip(self.planes) {
*to = plane.to_bits();
}
row[21] = u32::from(self.grouped) | u32::from(self.split) << 1;
row
}
/// The runs of `part`'s quads facing every way.
pub fn every(&self, part: Part, draw: impl FnMut(u32, u32)) {
self.runs(|_| true, part, draw);
@@ -239,7 +203,8 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
/// Reorders `solid`'s quads by bucket, opaque ones first in each. Solid
/// cutout quads are also ordered front to back within each axis-facing
/// bucket; stable sorting preserves the mesher's order for coplanar overlays.
/// bucket; quads that tie keep the mesher's order (the quad's index ends its
/// sort key), so coplanar overlays still draw over their base.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sort_parts(solid, GROUPED.load(Ordering::Relaxed))
}
@@ -249,9 +214,9 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, grouped, scratch))
}
/// `sort`'s buffers, kept by each meshing thread: a section's quads went
/// through fresh vectors, and the sorted copy replaced the mesh's own
/// (recycled) buffer.
/// `sort`'s buffers, kept by each meshing thread so sorting a mesh allocates
/// nothing: each quad's part, its sort key, and the sorted quads, which are
/// copied back into the mesh's own (recycled) buffer.
#[derive(Default)]
struct SortScratch {
slots: Vec<u8>,
@@ -0,0 +1,114 @@
//! The one-biome shortcut of `Tints::get` against the full blend. Needs the
//! prepared biome data, like the other mesher tests that load resources.
use super::*;
fn biomes() -> Biomes {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
Biomes::load(&root).unwrap()
}
/// A chunk whose every quart cell holds `biome`.
fn filled(cx: i32, cz: i32, biome: u16) -> Chunk {
let mut chunk = Chunk::empty(cx, cz);
for qy in 0..(384 / 4) {
for qz in 0..4 {
for qx in 0..4 {
chunk.set_biome_cell(qx, qy, qz, biome);
}
}
}
chunk
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn one_biome_shortcut_blends_like_the_full_square() {
let biomes = biomes();
let desert = biomes.id("minecraft:desert");
let (cx, cz) = (3, -2);
// Grass without and with the dark forest modifier, swamp noise, and
// biomes whose foliage, dry foliage and water differ from the defaults.
for name in [
"minecraft:plains",
"minecraft:dark_forest",
"minecraft:swamp",
"minecraft:mangrove_swamp",
"minecraft:cherry_grove",
"minecraft:warm_ocean",
"minecraft:badlands",
] {
let biome = biomes.id(name);
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| {
(0..3)
.map(|rx| filled(cx + rx - 1, cz + rz - 1, biome))
.collect()
})
.collect();
// One desert column in the east neighbour, next to the section's edge,
// where every blend radius reaches it.
let mut east = filled(cx + 1, cz, biome);
for qy in 0..(384 / 4) {
east.set_biome_cell(0, qy, 2, desert);
}
let all = |rz: usize, rx: usize| Some(&chunks[rz][rx]);
let uniform: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| all(rz, rx)));
let mut mixed = uniform;
mixed[1][2] = Some(&east);
// A missing neighbour reads as plains.
let mut missing = uniform;
missing[0][0] = None;
for (region, one_biome) in [
(&uniform, true),
(&mixed, false),
(&missing, name == "minecraft:plains"),
] {
for base_y in [MIN_Y, 0, 304] {
for (radius, seed) in [(0, 0), (1, 42), (2, -7), (5, 1234), (7, i64::MAX)] {
let new = || {
Tints::new(
region,
&biomes,
BiomeZoom::new(seed),
radius,
[cx, cz, base_y],
)
};
let mut fast = new();
let mut full = new();
full.uniform = Some(None);
assert_eq!(
fast.uniform_biome().is_some(),
one_biome,
"{name} r{radius} y{base_y}"
);
for resolver in [
Resolver::Grass,
Resolver::Foliage,
Resolver::Dry,
Resolver::Water,
] {
for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
assert_eq!(
fast.get(resolver, x, y, z),
full.get(resolver, x, y, z),
"{name} resolver {} r{radius} at {x} {y} {z} of y{base_y}",
resolver as usize
);
}
}
}
}
}
}
}
}
}
@@ -190,20 +190,74 @@ fn visibility_matches_reference() {
}
#[test]
fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() {
let info = [info(false), info(true), info(false), info(true)];
let mut stack = vec![usize::MAX];
for _ in 0..3 {
for (name, pad) in cases() {
assert_eq!(
visibility_reusing(&pad, &info, &mut stack),
reference(&pad, &info),
"{name}"
);
fn row_fill_matches_a_cell_by_cell_fill() {
let naive = |seeds: u16, open: u16| {
let mut fill = seeds;
loop {
let next = (fill | fill << 1 | fill >> 1) & open;
if next == fill {
return fill;
}
fill = next;
}
};
let mut state = 0x9E37_79B9u32;
for _ in 0..200_000 {
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
let open = state as u16;
let seeds = (state >> 16) as u16 & open;
assert_eq!(
fill_row(seeds, open),
naive(seeds, open),
"{open:016b} {seeds:016b}"
);
}
// Several seeds in one run, the case a carry-based fill gets wrong.
assert_eq!(fill_row(0b0110, 0b1111), 0b1111);
assert_eq!(fill_row(0xC000, 0xF000), 0xF000);
}
#[test]
fn row_fill_follows_open_runs() {
for open in [0u16, 0xFFFF, 0b0111_0110, 0x8001, 0xF0F0, 0x5555] {
for bit in 0..16 {
let seed = 1u16 << bit;
if open & seed == 0 {
continue;
}
let mut expected = seed;
for x in (0..bit).rev().take_while(|&x| open & 1 << x != 0) {
expected |= 1 << x;
}
for x in (bit + 1..16).take_while(|&x| open & 1 << x != 0) {
expected |= 1 << x;
}
assert_eq!(fill_row(seed, open), expected, "{open:016b} from {bit}");
}
}
}
/// Mazes: one long winding cavity, and many small ones, where the row
/// sweeps are slowest.
#[test]
fn winding_and_scattered_cavities_match_reference() {
let info = [info(false), info(true), info(false), info(true)];
let serpentine = fixture(|x, y, z| {
let lane = z % 4 == 0 && y % 4 == 0;
let turn = (x == 0 && z % 8 == 4) || (x == 15 && z % 8 == 0);
let climb = x == 0 && z == 0 && y % 4 != 0;
!(lane || (turn && y % 4 == 0) || climb)
});
assert_eq!(
visibility(&serpentine, &info),
reference(&serpentine, &info)
);
let pockets = fixture(|x, y, z| !(x % 3 == 1 && y % 3 == 1 && z % 3 == 1));
assert_eq!(visibility(&pockets, &info), reference(&pockets, &info));
}
#[test]
fn visibility_preserves_every_face_pair() {
let info = [info(false), info(true), info(false), info(true)];
+187 -21
View File
@@ -1264,7 +1264,7 @@ pub enum Page {
Online,
/// Splitscreen: the profile another player joins as (`split_profiles`).
SplitProfiles,
/// Splitscreen: enable joining on this computer.
/// Splitscreen: the monitor map and draft window arrangement.
SplitSetup,
}
#[derive(Clone, Debug, PartialEq)]
@@ -1308,6 +1308,10 @@ enum Act {
/// Credits & Attribution's Credits button (WinScreen without the poem).
Credits,
CopyAgentDetails,
/// AI Agent: start or stop the repro recording.
ToggleRecording,
/// AI Agent: write the repro bundle now.
ExportRepro,
}
enum WKind {
Button {
@@ -1435,6 +1439,10 @@ pub enum Event {
Credits,
/// Copy how an agent connects (the AI Agent page).
CopyAgentDetails,
/// The AI Agent page's recording button: start or stop.
ToggleRecording,
/// The AI Agent page's export button.
ExportRepro,
/// The narrator option changed (after its `Changed`): the option's
/// `onValueUpdate`, `GameNarrator.updateNarratorStatus`.
Narrator,
@@ -1512,10 +1520,17 @@ pub struct OptionsUi {
pub calibration: Option<crate::hdr_calibration::Calibration>,
/// Whether the window was fullscreen before calibration made it so (the game keeps it).
pub calibration_fullscreen: Option<bool>,
pub split_setup: crate::split_ui::SplitUi,
/// The AI Agent page's status lines (set by the game while it shows).
pub agent_status: Vec<String>,
/// A repro recording is running: the AI Agent page's button says stop.
pub agent_recording: bool,
/// Keyboard focus: the interactive target currently focused.
focus: Option<FocusId>,
/// The focus came from keyboard or pad navigation, so the mouse wheel steps it
/// from anywhere (`wheel`). A click focuses its widget too, for the highlight,
/// but vanilla's wheel ignores that: only the hovered widget then reacts.
focus_from_keyboard: bool,
/// Whether a focused slider is in value-editing mode (`AbstractSliderButton.canChangeValue`).
pub(crate) can_change_value: bool,
/// The current GUI size (w, h).
@@ -1528,8 +1543,6 @@ pub struct OptionsUi {
/// The list's rows as last laid out: each row's top and bottom (GUI
/// pixels), for the narration's row under the mouse.
row_bands: Vec<[f32; 2]>,
/// Other local players are joined: leave before turning splitscreen off.
pub split_joined: bool,
}
impl Default for OptionsUi {
fn default() -> Self {
@@ -1568,14 +1581,16 @@ impl Default for OptionsUi {
split_profiles: Default::default(),
calibration: None,
calibration_fullscreen: None,
split_setup: Default::default(),
agent_status: Vec::new(),
agent_recording: false,
focus: None,
focus_from_keyboard: false,
can_change_value: false,
size: (427.0, 240.0),
framebuffer: [1280, 720],
split_guest: false,
row_bands: Vec::new(),
split_joined: false,
}
}
}
@@ -2093,6 +2108,7 @@ impl OptionsUi {
self.stack.clear();
self.data_pack_dir = None;
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
self.push(page);
self.in_world = in_world;
@@ -2103,6 +2119,7 @@ impl OptionsUi {
self.scroll = 0.0;
self.scrollbar_drag_y = None;
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
if matches!(page, Page::Video | Page::Controls | Page::KeyBinds) {
self.settings_search.set_value("");
@@ -2122,6 +2139,9 @@ impl OptionsUi {
) {
self.graphics.reset();
}
if page == Page::SplitSetup {
self.split_setup.reset();
}
if page == Page::SplitProfiles {
self.split_profiles.reset();
}
@@ -2262,14 +2282,7 @@ impl OptionsUi {
fn list_rows(&self, s: &Settings) -> Vec<Row> {
match self.page() {
Page::SplitSetup => vec![Row::Buttons(
if self.split_guest || self.split_joined {
inactive(self.extra_button(s, crate::extras::Item::Splitscreen))
} else {
self.extra_button(s, crate::extras::Item::Splitscreen)
},
None,
)],
Page::SplitSetup => Vec::new(),
Page::Extras => {
// Each section's switches two to a row, as vanilla's
// OptionsList.addSmall lays out an options screen.
@@ -2376,6 +2389,30 @@ impl OptionsUi {
Act::CopyAgentDetails,
)),
)];
rows.push(Row::Buttons(
button(
0.0,
0.0,
150.0,
tr(if self.agent_recording {
"horizon.agent.record_stop"
} else {
"horizon.agent.record_session"
}),
Act::ToggleRecording,
),
Some(button(
0.0,
0.0,
150.0,
tr("horizon.agent.export_repro"),
Act::ExportRepro,
)),
));
rows.push(Row::Buttons(
self.extra_button(s, crate::extras::Item::ReproKeys),
None,
));
rows.extend(self.agent_status.iter().cloned().map(Row::Note));
rows
}
@@ -3346,6 +3383,11 @@ impl OptionsUi {
.draw(level, s, batch, atlas, cursor, self.in_world);
return;
}
if self.page() == Page::SplitSetup {
self.split_setup.on = s.extras.splitscreen;
self.split_setup.draw(batch, atlas, cursor, self.in_world);
return;
}
if self.page() == Page::SplitProfiles {
self.split_profiles
.draw(batch, atlas, cursor, self.in_world, self.pad_style);
@@ -3646,6 +3688,26 @@ impl OptionsUi {
}
return Vec::new();
}
if self.page() == Page::SplitSetup {
use crate::split_ui::Click;
if right {
return Vec::new();
}
self.split_setup.on = s.extras.splitscreen;
return match self.split_setup.click((x, y), size, drag) {
Click::Nothing => Vec::new(),
Click::Pressed => vec![Event::ButtonClick],
Click::Switch => {
crate::extras::Item::Splitscreen.cycle(&mut s.extras, false);
vec![Event::ButtonClick, Event::Changed]
}
Click::Done => {
let mut events = vec![Event::ButtonClick];
events.extend(self.back(s));
events
}
};
}
if self.page() == Page::SplitProfiles {
use crate::split_profiles::Click;
if right || drag {
@@ -3750,6 +3812,7 @@ impl OptionsUi {
search.focused = ui::hovered((x, y), search.x, search.y, search.width, search.height);
if search.focused {
self.focus = Some(FocusId::Search);
self.focus_from_keyboard = false;
self.can_change_value = false;
search.click(atlas, x, false, false);
self.scroll = 0.0;
@@ -3762,6 +3825,7 @@ impl OptionsUi {
search.focused = ui::hovered((x, y), search.x, search.y, search.width, search.height);
if search.focused {
self.focus = Some(FocusId::Search);
self.focus_from_keyboard = false;
self.can_change_value = false;
search.click(atlas, x, false, false);
self.scroll = 0.0;
@@ -3875,7 +3939,10 @@ impl OptionsUi {
if wd.list && (y < top || y >= bottom) {
continue;
}
// A click focuses the widget for its highlight only: vanilla's wheel
// steps a keyboard-focused widget from anywhere, not a clicked one.
self.focus = Some(focus_id(wd_idx, &wd));
self.focus_from_keyboard = false;
match wd.kind {
WKind::Button {
active: true, act, ..
@@ -3994,6 +4061,8 @@ impl OptionsUi {
}
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::ToggleRecording => events.push(Event::ToggleRecording),
Act::ExportRepro => events.push(Event::ExportRepro),
Act::None => {}
}
}
@@ -4049,8 +4118,11 @@ impl OptionsUi {
let slider = self.dragging.take().is_some() | self.shader_dragging.take().is_some();
let graphics_slider = self.graphics.release();
let calibration_slider = self.calibration.as_mut().is_some_and(|c| c.release());
let split_drop = self.split_setup.release();
if slider || graphics_slider || calibration_slider {
vec![Event::ButtonClick, Event::Changed]
} else if split_drop {
vec![Event::ButtonClick]
} else {
Vec::new()
}
@@ -4095,15 +4167,19 @@ impl OptionsUi {
}
events
}
/// The mouse wheel at the cursor. Over a cycle button it takes the step
/// (`CycleButton.mouseScrolled`: up goes back) and the list stays put;
/// over a slider it nudges the slider up or down.
/// The mouse wheel at the cursor. Vanilla scrolls the list
/// (`AbstractScrollArea.mouseScrolled`), except that a hovered cycle button
/// takes the step and keeps the wheel (`CycleButton.mouseScrolled`: up goes
/// back); `AbstractSliderButton` does not override it, so a slider under the
/// cursor lets the list scroll.
///
/// **Horizon improvement (diverges from Vanilla 26.3):** when a slider or
/// cycle button is keyboard-focused (highlighted), the wheel steps or
/// cycles that widget regardless of where the cursor is. Vanilla 26.3
/// does not intercept the wheel for sliders at all; this makes keyboard
/// navigation far more usable.
/// **Horizon improvement (diverges from Vanilla 26.3):** a slider or cycle
/// button focused by the keyboard (Tab, the arrows, a pad) steps or cycles on
/// the wheel wherever the cursor is. Vanilla 26.3 does not intercept the wheel
/// for sliders at all; this makes keyboard navigation far more usable. Focus
/// left by a click does not count (`focus_from_keyboard`): vanilla's wheel
/// ignores it, and honouring it toggled the clicked option on every later wheel
/// event, from anywhere on the screen, while the list never scrolled.
pub fn wheel(
&mut self,
s: &mut Settings,
@@ -4155,7 +4231,10 @@ impl OptionsUi {
}
// Horizon improvement: a keyboard-focused slider or cycle button
// intercepts the wheel before hover detection (see doc-comment above).
if amount != 0.0 {
// A click focuses its widget for the highlight too, and vanilla's wheel
// must not act on that: it would toggle the clicked option from anywhere
// on the screen and the list would never scroll.
if amount != 0.0 && self.focus_from_keyboard {
let (widgets, _, _, _) = self.layout(s, size.0, size.1);
match &self.focus {
Some(FocusId::Slider(opt)) => {
@@ -4341,6 +4420,7 @@ impl OptionsUi {
self.selected_key = None;
self.selected_pad_action = None;
self.focus = None;
self.focus_from_keyboard = false;
self.settings_search.focused = false;
self.language_search.focused = false;
self.can_change_value = false;
@@ -4684,6 +4764,7 @@ impl OptionsUi {
};
let (next_id, rect, in_list) = &focusables[next_idx];
self.focus = Some(next_id.clone());
self.focus_from_keyboard = true;
self.can_change_value = matches!(next_id, FocusId::Slider(_));
if *in_list {
if rect[1] < top {
@@ -4722,6 +4803,7 @@ impl OptionsUi {
if let Some(next_idx) = gamepad::focus_step_rect(from, dir, &rects) {
let (next_id, rect, in_list) = &focusables[next_idx];
self.focus = Some(next_id.clone());
self.focus_from_keyboard = true;
self.can_change_value = false;
if *in_list {
if rect[1] < top {
@@ -4735,6 +4817,7 @@ impl OptionsUi {
}
} else {
self.focus = Some(focusables[0].0.clone());
self.focus_from_keyboard = true;
self.can_change_value = false;
}
return Vec::new();
@@ -4757,6 +4840,7 @@ impl OptionsUi {
if let Some(next_idx) = step {
let (next_id, rect, in_list) = &focusables[next_idx];
self.focus = Some(next_id.clone());
self.focus_from_keyboard = true;
self.can_change_value = false;
if *in_list {
if rect[1] < top {
@@ -4774,6 +4858,7 @@ impl OptionsUi {
}
} else {
self.focus = Some(focusables[0].0.clone());
self.focus_from_keyboard = true;
self.can_change_value = false;
}
return Vec::new();
@@ -4797,6 +4882,7 @@ impl OptionsUi {
match act {
Act::Push(p) => {
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
if p == Page::ShaderPacks {
events.push(Event::ListShaderPacks);
@@ -4805,6 +4891,7 @@ impl OptionsUi {
}
Act::Done => {
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
events.extend(self.back(s));
}
@@ -4844,6 +4931,7 @@ impl OptionsUi {
}
Act::ModSettings(i) => {
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
self.push(Page::NativeMod(i));
events.push(Event::NativeSettings(i));
@@ -4868,6 +4956,7 @@ impl OptionsUi {
// The shader options page's buttons, as a click does them.
Act::ShaderOptions => {
self.focus = None;
self.focus_from_keyboard = false;
self.can_change_value = false;
s.shader_options = None;
self.push(Page::ShaderOptions);
@@ -4885,6 +4974,7 @@ impl OptionsUi {
Act::ShaderScreen(screen) => {
if let Some(options) = &mut s.shader_options {
self.focus = None;
self.focus_from_keyboard = false;
options.path.push(screen);
self.scroll = 0.0;
}
@@ -4917,6 +5007,8 @@ impl OptionsUi {
}
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::ToggleRecording => events.push(Event::ToggleRecording),
Act::ExportRepro => events.push(Event::ExportRepro),
Act::None => {}
}
return events;
@@ -7147,6 +7239,80 @@ mod tests {
assert_eq!(s.horizon.chunk_updates, 11);
}
/// Vanilla's wheel scrolls the list (`AbstractScrollArea.mouseScrolled`); only a
/// widget focused by the keyboard takes the step from anywhere on the screen.
/// A click focuses its widget for the highlight, and honouring that focus made a
/// clicked option toggle on every later wheel event while the list never scrolled.
#[test]
fn a_clicked_option_lets_the_wheel_scroll_the_list() {
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
ui.show(Page::Video);
let framebuffer = [854, 480];
let click_size = (427.0, 400.0);
let (widgets, click_top, click_bottom, _) = ui.layout(&s, click_size.0, click_size.1);
// A click leaves the widget focused for its highlight, not for the wheel.
let wd = widgets
.iter()
.find(|wd| {
matches!(
wd.kind,
WKind::Button {
act: Act::Cycle(_),
..
}
) && (!wd.list || (wd.y >= click_top && wd.y + wd.h <= click_bottom))
})
.expect("a visible cycle button");
ui.click(
&mut s,
&atlas,
wd.x + 5.0,
wd.y + 5.0,
click_size,
framebuffer,
false,
false,
);
assert!(
matches!(ui.focus, Some(FocusId::Cycle(_))),
"the click focuses the cycle button it hit"
);
assert!(
!ui.focus_from_keyboard,
"a click does not leave keyboard focus"
);
// The wheel over the list's empty margin scrolls it at a size where the
// page overflows, and the clicked cycle option stays put: no ButtonClick,
// no Changed.
let size = (427.0, 240.0);
let (_, top, bottom, content) = ui.layout(&s, size.0, size.1);
assert!(
content > bottom - top,
"the video page scrolls at this size"
);
let before = s.view_bobbing;
let start = ui.scroll;
let cursor = (2.0, (top + bottom) / 2.0);
let events = ui.wheel(&mut s, size, cursor, framebuffer, -1.0);
assert_eq!(s.view_bobbing, before, "the clicked option does not toggle");
assert!(!events.contains(&Event::ButtonClick), "no click reaches it");
assert!(!events.contains(&Event::Changed));
assert!(ui.scroll > start, "the list scrolls down instead");
// Keyboard focus keeps the documented divergence: the wheel steps it
// wherever the cursor is, even off the widget.
ui.focus = Some(FocusId::Cycle(Opt::ViewBobbing));
ui.focus_from_keyboard = true;
let events = ui.wheel(&mut s, size, cursor, framebuffer, -1.0);
assert_ne!(s.view_bobbing, before, "a keyboard-focused option cycles");
assert!(events.contains(&Event::ButtonClick));
assert!(events.contains(&Event::Changed));
}
#[test]
fn options_step_slider_matches_set_slider_for_all_stepped_options() {
let stepped_opts = [
+487 -25
View File
@@ -1,5 +1,6 @@
//! Splitscreen's layout, pure data tested without a window: where each player's view goes in
//! its window (`place`).
//! its window (`place`), and the setup screen's draft (`Draft`): the windows, where each is, who
//! plays in each, before any of it is applied.
use winit::dpi::PhysicalSize;
/// Players at most: P1 and fifteen more.
@@ -25,30 +26,6 @@ const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
&[4, 4, 4, 4],
];
/// Where a window is.
#[derive(Clone, Debug, PartialEq)]
pub enum Placement {
/// A normal window: its corner on the desktop and its size in pixels (`None`: a new one,
/// where the system opens it).
Free(Option<([i32; 2], [u32; 2])>),
/// Borderless over the monitor of this name.
Fill(String),
}
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
/// centred, each next one a step further, as systems cascade new windows (back to the centre
/// after six), so new windows never open exactly on one another.
pub fn cascade(number: u8) -> f64 {
f64::from(number.saturating_sub(2) % 6) * 32.0
}
/// A window: its number (1 is the game's own) and where it is.
#[derive(Clone, Debug, PartialEq)]
pub struct Window {
pub number: u8,
pub placement: Placement,
}
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
/// others, P1 first.
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
@@ -152,10 +129,310 @@ fn rows(n: usize, slot: u32, w: u32, h: u32) -> [u32; 4] {
}
}
/// A screen as the system reports it: its name, where it is on the desktop and its size (both
/// physical pixels), and its scale.
#[derive(Clone, Debug, PartialEq)]
pub struct Monitor {
pub name: String,
pub at: [i32; 2],
pub size: [u32; 2],
pub scale: f64,
}
impl Monitor {
/// Where it is on the desktop and its size, as windows are placed: macOS places them in
/// points, each monitor at its own scale; Windows and X11 in desktop pixels.
pub fn desktop(&self) -> [f64; 4] {
let k = if cfg!(target_os = "macos") {
self.scale
} else {
1.0
};
[
f64::from(self.at[0]) / k,
f64::from(self.at[1]) / k,
f64::from(self.size[0]) / k,
f64::from(self.size[1]) / k,
]
}
}
/// Where a window is.
#[derive(Clone, Debug, PartialEq)]
pub enum Placement {
/// A normal window: its corner on the desktop (`Monitor::desktop`'s units) and its size in
/// pixels (`None`: a new one, where the system opens it, `NEW_WINDOW` big).
Free(Option<([i32; 2], [u32; 2])>),
/// Borderless over the monitor of this name.
Fill(String),
}
/// A new free window's size, in logical pixels.
pub const NEW_WINDOW: [f64; 2] = [854.0, 480.0];
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
/// centred, each next one a step further, as systems cascade new windows (back to the centre
/// after six), so new windows never open exactly on one another.
pub fn cascade(number: u8) -> f64 {
f64::from(number.saturating_sub(2) % 6) * 32.0
}
/// A window: its number (1 is the game's own) and where it is.
#[derive(Clone, Debug, PartialEq)]
pub struct Window {
pub number: u8,
pub placement: Placement,
}
/// Where a player can go: a window there is (by number), a new free one, or a new one filling a
/// monitor (by name).
#[derive(Clone, Debug, PartialEq)]
pub enum Spot {
Window(u8),
New,
Fill(String),
}
/// Something to tell before Apply.
#[derive(Clone, Debug, PartialEq)]
pub enum Warning {
/// Nobody plays in this window: it won't open (Window 1 stays, blank).
Empty(u8),
/// This window fills a monitor that isn't there: its players play in Window 1.
Unplugged(u8, String),
/// This window fills the monitor Window 1 is on: its players play in Window 1.
OnWindow1(u8),
}
/// The setup screen's arrangement until Apply: the windows, each player's window (by number, P1
/// first) and the shapes two or three players sharing a window take (`place`'s `two`, `three`).
#[derive(Clone, Debug, PartialEq)]
pub struct Draft {
pub windows: Vec<Window>,
pub players: Vec<u8>,
pub two: u8,
pub three: u8,
/// The players (indices) in the order their views fill a window shared with others: first
/// left or on top. Empty, or missing some: P1, P2... (`ordered`).
pub order: Vec<usize>,
}
impl Draft {
/// Everyone in Window 1 (where it is now).
pub fn everyone_here(players: usize, window1: Placement, two: u8, three: u8) -> Self {
Self {
windows: vec![Window {
number: 1,
placement: window1,
}],
players: vec![1; players],
two,
three,
order: Vec::new(),
}
}
/// Players `i` and `j` trade places: their windows and their places in them.
pub fn swap(&mut self, i: usize, j: usize) {
let n = self.players.len();
if i >= n || j >= n || i == j {
return;
}
self.players.swap(i, j);
let mut order = ordered(&self.order, n);
let (a, b) = (
order.iter().position(|&p| p == i),
order.iter().position(|&p| p == j),
);
if let (Some(a), Some(b)) = (a, b) {
order.swap(a, b);
}
self.order = order;
}
/// Player `i` to place `k` among the others in its window (0: before them all; past the
/// last: after them), the others keeping their order: the bumpers' "Window 1 (2 of 3)".
pub fn put_at(&mut self, i: usize, k: usize) {
let n = self.players.len();
if i >= n {
return;
}
let mine = self.players[i];
let mut order: Vec<usize> = ordered(&self.order, n)
.into_iter()
.filter(|&p| p != i)
.collect();
let others: Vec<usize> = order
.iter()
.copied()
.filter(|&p| self.players[p] == mine)
.collect();
let at = match others.get(k) {
Some(j) => order.iter().position(|p| p == j).unwrap_or(order.len()),
None => others
.last()
.and_then(|j| order.iter().position(|p| p == j))
.map_or(order.len(), |p| p + 1),
};
order.insert(at, i);
self.order = order;
}
/// The players but `i` in window `number`, in their order.
pub fn sharing(&self, i: usize, number: u8) -> Vec<usize> {
ordered(&self.order, self.players.len())
.into_iter()
.filter(|&p| p != i && self.players[p] == number)
.collect()
}
/// A new free window, numbered with the lowest number free; its number.
pub fn add_window(&mut self) -> u8 {
self.open(Placement::Free(None))
}
fn open(&mut self, placement: Placement) -> u8 {
let number = (2..=u8::MAX)
.find(|n| self.windows.iter().all(|w| w.number != *n))
.unwrap_or(u8::MAX);
self.windows.push(Window { number, placement });
number
}
/// Player `i` to `spot`: a new window opens for it. A window it leaves empty stays in the
/// draft until `drop_empty` (at Apply), so numbers hold still while players move. A window
/// number the draft doesn't have is ignored.
pub fn place_player(&mut self, i: usize, spot: Spot) {
if i >= self.players.len() {
return;
}
let number = match spot {
Spot::Window(n) if self.windows.iter().any(|w| w.number == n) => n,
Spot::Window(_) => return,
Spot::New => self.add_window(),
Spot::Fill(name) => self.open(Placement::Fill(name)),
};
self.players[i] = number;
}
/// The windows nobody plays in, gone, but Window 1 and those in `keep`. At Apply, all of
/// them; while a draft is edited, the ones it added (the windows open now stay until Apply,
/// so their numbers hold still as players move).
pub fn drop_empty(&mut self, keep: &[u8]) {
let players = &self.players;
self.windows
.retain(|w| w.number == 1 || players.contains(&w.number) || keep.contains(&w.number));
}
/// Whether nobody plays in window `number`.
pub fn empty(&self, number: u8) -> bool {
!self.players.contains(&number)
}
/// What to tell before Apply, in window order.
pub fn warnings(&self, monitors: &[Monitor], main: &str) -> Vec<Warning> {
let mut out = Vec::new();
for w in &self.windows {
if let Placement::Fill(name) = &w.placement
&& !monitors.iter().any(|m| &m.name == name)
{
out.push(Warning::Unplugged(w.number, name.clone()));
} else if w.number != 1 && w.placement == Placement::Fill(main.to_owned()) {
out.push(Warning::OnWindow1(w.number));
} else if !self.players.contains(&w.number) {
out.push(Warning::Empty(w.number));
}
}
out
}
/// Where each window would be: its corner on the desktop (`Monitor::desktop`'s units) and its
/// size in pixels, in `windows` order: free ones where they were (new ones at `main`'s
/// corner), the others over their monitor; a window over a missing monitor or Window 1's
/// (its players play in Window 1, as the game does): `None`. Window 1 always has one: give
/// it its real rect (`Free(Some)`); else it is taken as a new window would open.
pub fn rects(&self, monitors: &[Monitor], main: &str) -> Vec<Option<([i32; 2], [u32; 2])>> {
let home = monitors
.iter()
.find(|m| m.name == main)
.or(monitors.first());
// Centred on Window 1's monitor, as the game opens a new window.
let new = |number: u8| {
let (at, scale, k) = home.map_or(([0.0; 4], 1.0, 1.0), |m| {
let at = m.desktop();
(at, m.scale, at[2] / f64::from(m.size[0].max(1)))
});
let size = NEW_WINDOW.map(|v| (v * scale).round() as u32);
let step = cascade(number) * scale * k;
let x = at[0] + (at[2] - f64::from(size[0]) * k) / 2.0 + step;
let y = at[1] + (at[3] - f64::from(size[1]) * k) / 2.0 + step;
([x as i32, y as i32], size)
};
self.windows
.iter()
.map(|w| {
let rect = match &w.placement {
Placement::Free(Some(rect)) => Some(*rect),
Placement::Free(None) => Some(new(w.number)),
Placement::Fill(name) if w.number != 1 && name == main => None,
Placement::Fill(name) => monitors.iter().find(|m| &m.name == name).map(|m| {
let at = m.desktop();
([at[0] as i32, at[1] as i32], m.size)
}),
};
// Everyone with nowhere else plays in Window 1: it never has no size.
if w.number == 1 {
rect.or_else(|| Some(new(1)))
} else {
rect
}
})
.collect()
}
/// Every player's view as Apply would make it (`place`, as the game tiles them), with
/// `View`'s window an index into `windows`; players of a window over a missing monitor in
/// Window 1's.
pub fn preview(&self, monitors: &[Monitor], main: &str) -> Vec<View> {
let rects = self.rects(monitors, main);
let index = |number: u8| self.windows.iter().position(|w| w.number == number);
let window1 = index(1).unwrap_or(0);
let windows: Vec<usize> = self
.players
.iter()
.map(|&n| index(n).filter(|&i| rects[i].is_some()).unwrap_or(window1))
.collect();
let sizes: Vec<PhysicalSize<u32>> = rects
.iter()
.map(|r| {
r.map_or(PhysicalSize::new(1, 1), |(_, [w, h])| {
PhysicalSize::new(w, h)
})
})
.collect();
place_ordered(&windows, &self.order, &sizes, self.two, self.three)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_1_always_has_a_size_and_unknown_windows_take_nobody() {
let monitors = [Monitor {
name: "Main".into(),
at: [0, 0],
size: [1920, 1080],
scale: 1.0,
}];
let mut d = Draft::everyone_here(2, Placement::Fill("Gone".into()), 0, 0);
assert!(d.rects(&monitors, "Main")[0].is_some());
assert!(d.preview(&monitors, "Main").iter().all(|v| v.2.width > 1));
d.place_player(1, Spot::Window(9));
assert_eq!(d.players, [1, 1], "no Window 9 in the draft");
}
fn size(w: u32, h: u32) -> PhysicalSize<u32> {
PhysicalSize::new(w, h)
}
@@ -272,6 +549,97 @@ mod tests {
)
);
}
let mut draft = here(16);
draft.place_player(15, Spot::New);
let views = draft.preview(&monitors(), "Built-in");
assert_eq!(views.len(), 16);
assert_ne!(views[0].0, views[15].0);
}
fn monitors() -> Vec<Monitor> {
vec![
Monitor {
name: "Built-in".into(),
at: [0, 0],
size: [3024, 1964],
scale: 2.0,
},
Monitor {
name: "TV".into(),
at: [3024, 0],
size: [3840, 2160],
scale: 1.0,
},
]
}
/// P1 in Window 1, everyone else in Window 2 filling the TV.
trait DeskAndTv {
fn desk_and_tv(self, monitors: &[Monitor], main: &str) -> Option<Draft>;
}
impl DeskAndTv for Draft {
fn desk_and_tv(mut self, monitors: &[Monitor], main: &str) -> Option<Draft> {
let tv = monitors.iter().find(|m| m.name != main)?;
self.windows.push(Window {
number: 2,
placement: Placement::Fill(tv.name.clone()),
});
for p in self.players.iter_mut().skip(1) {
*p = 2;
}
Some(self)
}
}
fn here(players: usize) -> Draft {
Draft::everyone_here(
players,
Placement::Free(Some(([100, 100], [2000, 1200]))),
0,
0,
)
}
#[test]
fn presets() {
let m = monitors();
// Everyone here: four quarters of Window 1.
let views = here(4).preview(&m, "Built-in");
assert_eq!(views[3], (0, [1000, 600], size(1000, 600)));
// P1 alone, the others share the TV in rows.
let tv = here(4).desk_and_tv(&m, "Built-in").unwrap();
assert_eq!(tv.players, [1, 2, 2, 2]);
assert_eq!(tv.windows[1].placement, Placement::Fill("TV".into()));
let views = tv.preview(&m, "Built-in");
assert_eq!(views[0], (0, [0, 0], size(2000, 1200)));
assert_eq!(views[1], (1, [0, 0], size(3840, 1080)));
}
#[test]
fn new_windows_cascade_instead_of_stacking() {
let m = monitors();
let mut d = here(3);
d.place_player(1, Spot::New);
d.place_player(2, Spot::New);
let rects = d.rects(&m, "Built-in");
let ((a, _), (b, _)) = (rects[1].unwrap(), rects[2].unwrap());
// Window 3 a step right and down of Window 2, on the same diagonal.
assert!(b[0] > a[0] && b[0] - a[0] == b[1] - a[1]);
assert_eq!((cascade(2), cascade(3), cascade(8)), (0.0, 32.0, 0.0));
}
#[test]
fn put_at_a_place_among_a_window_s_players() {
let mut d = here(3);
d.put_at(2, 0);
assert_eq!(d.order, [2, 0, 1]);
d.put_at(2, 5); // past the last: after them
assert_eq!(d.order, [0, 1, 2]);
// Players in another window keep their places.
d.players = vec![1, 2, 1];
d.put_at(2, 0);
assert_eq!(d.sharing(2, 1), [0]);
assert_eq!(d.order, [2, 0, 1]);
}
#[test]
@@ -293,4 +661,98 @@ mod tests {
let views = place_ordered(&[0, 0, 0], &[2], &wide, 0, 0);
assert_eq!(views[2].2, size(2000, 500));
}
#[test]
fn two_players_swap_in_a_window_and_between_windows() {
let m = monitors();
// In one window: P1 and P2 trade sides.
let mut d = here(2);
d.swap(0, 1);
assert_eq!(d.order, [1, 0]);
let views = d.preview(&m, "Built-in");
assert!(views[1].1[0] < views[0].1[0], "P2 left of P1");
// Between windows: P2 on the TV, P3 in Window 1; they trade windows.
let mut d = here(3);
d.place_player(1, Spot::Fill("TV".into()));
d.swap(1, 2);
assert_eq!(d.players, [1, 1, 2]);
}
#[test]
fn players_placed() {
let m = monitors();
let mut d = here(3);
d.place_player(1, Spot::Fill("TV".into()));
d.place_player(2, Spot::Window(2));
assert_eq!(d.players, [1, 2, 2]);
d.place_player(2, Spot::New);
assert_eq!(d.players, [1, 2, 3]);
assert!(d.warnings(&m, "Built-in").is_empty());
// The window left behind stays while the draft is edited (numbers hold still), and
// closes at Apply; Window 1 never.
d.place_player(1, Spot::Window(1));
let numbers = |d: &Draft| d.windows.iter().map(|w| w.number).collect::<Vec<_>>();
assert_eq!(numbers(&d), [1, 2, 3]);
assert!(d.empty(2));
d.drop_empty(&[]);
assert_eq!(numbers(&d), [1, 3]);
assert!(d.warnings(&m, "Built-in").is_empty());
d.place_player(0, Spot::Window(3));
d.place_player(1, Spot::Window(3));
assert_eq!(d.warnings(&m, "Built-in"), [Warning::Empty(1)]);
}
#[test]
fn windows_numbered_lowest_free() {
let mut d = here(3);
assert_eq!(d.add_window(), 2);
assert_eq!(d.add_window(), 3);
d.windows.retain(|w| w.number != 2);
assert_eq!(d.add_window(), 2);
// Nobody to place: no window opens for it.
d.place_player(9, Spot::New);
assert_eq!(d.windows.len(), 3);
}
#[test]
fn a_window_over_window_1s_monitor() {
let m = monitors();
let mut d = here(2);
d.place_player(1, Spot::Fill("Built-in".into()));
assert_eq!(d.warnings(&m, "Built-in"), [Warning::OnWindow1(2)]);
// As the game does: P2 beside P1 in Window 1.
assert_eq!(d.preview(&m, "Built-in")[1].0, 0);
}
#[test]
fn warnings_and_an_unplugged_monitor() {
let m = monitors();
let mut d = here(2).desk_and_tv(&m, "Built-in").unwrap();
let three = d.add_window();
assert_eq!(d.warnings(&m, "Built-in"), [Warning::Empty(three)]);
// The TV unplugged: its player plays in Window 1 beside P1.
assert_eq!(
d.warnings(&m[..1], "Built-in"),
[Warning::Unplugged(2, "TV".into()), Warning::Empty(three)]
);
let views = d.preview(&m[..1], "Built-in");
assert_eq!(views[1], (0, [1000, 0], size(1000, 1200)));
// P1 elsewhere: Window 1 is blank.
d.players = vec![2, 2];
assert_eq!(d.warnings(&m, "Built-in")[0], Warning::Empty(1));
}
#[test]
fn eight_players_two_windows() {
let m = monitors();
let d = here(8).desk_and_tv(&m, "Built-in").unwrap();
let views = d.preview(&m, "Built-in");
// Seven on the TV: rows of 3, 2 and 2, the whole TV covered.
let area: u64 = views[1..]
.iter()
.map(|v| u64::from(v.2.width) * u64::from(v.2.height))
.sum();
assert_eq!(area, 3840 * 2160);
assert!(views[1..].iter().all(|v| v.0 == 1));
}
}
@@ -0,0 +1,166 @@
//! Project a draft arrangement onto the Setup screen, without opening a window.
use crate::split_layout::{Draft, Monitor};
/// Map area: below the title and above the move row and footer.
pub const TOP: f32 = 30.0;
pub const BOTTOM: f32 = 80.0;
pub type Rect = [f32; 4];
pub type Map = (Vec<Rect>, Vec<(u8, Rect)>, Vec<(u8, Rect)>);
/// Monitor, window and player rectangles in UI coordinates, in their drawing order.
pub fn project(draft: &Draft, monitors: &[Monitor], main: &str, w: f32, h: f32) -> Map {
let desk: Vec<[f64; 4]> = monitors.iter().map(Monitor::desktop).collect();
// A window's size on the desktop: its pixels at the scale of the monitor it is on.
let rects = draft.rects(monitors, main);
let on_desk: Vec<(u8, [f64; 4])> = draft
.windows
.iter()
.zip(&rects)
.filter_map(|(win, rect)| {
let ([x, y], [pw, ph]) = (*rect)?;
let (x, y) = (f64::from(x), f64::from(y));
let scale = monitors
.iter()
.find(|m| {
let [mx, my, mw, mh] = m.desktop();
x >= mx && x < mx + mw && y >= my && y < my + mh
})
.or_else(|| monitors.iter().find(|m| m.name == main))
.map_or(1.0, |m| m.desktop()[2] / f64::from(m.size[0]));
Some((
win.number,
[x, y, f64::from(pw) * scale, f64::from(ph) * scale],
))
})
.collect();
// The monitors, and windows past them (Wayland's, whose place is unknown) still shown.
let all = || desk.iter().chain(on_desk.iter().map(|(_, d)| d));
let x0 = all().map(|d| d[0]).fold(f64::MAX, f64::min);
let y0 = all().map(|d| d[1]).fold(f64::MAX, f64::min);
let x1 = all().map(|d| d[0] + d[2]).fold(f64::MIN, f64::max);
let y1 = all().map(|d| d[1] + d[3]).fold(f64::MIN, f64::max);
if desk.is_empty() || x1 <= x0 || y1 <= y0 {
return Default::default();
}
let (aw, ah) = (
f64::from(w) - 40.0,
// Room under the monitors for their names, and always for the move row: the map
// doesn't jump when a player is picked (a drop back to where its tile was misses).
(f64::from(h - TOP - BOTTOM) - 18.0).max(10.0),
);
let k = (aw / (x1 - x0)).min(ah / (y1 - y0));
let (ox, oy) = (
(f64::from(w) - (x1 - x0) * k) / 2.0,
f64::from(TOP) + 4.0 + (ah - (y1 - y0) * k) / 2.0,
);
let to_map = |[x, y, dw, dh]: [f64; 4]| {
[
(ox + (x - x0) * k) as f32,
(oy + (y - y0) * k) as f32,
(dw * k) as f32,
(dh * k) as f32,
]
};
let mapped_monitors = desk.iter().copied().map(to_map).collect();
let windows: Vec<(u8, [f32; 4])> = on_desk.iter().map(|&(n, d)| (n, to_map(d))).collect();
// In their own order, emptied ones too: a window a player was just dragged out of stays
// where it was until Apply closes it, so the player can be dragged back into it.
let tiles = draft
.preview(monitors, main)
.iter()
.map(|&(i, [vx, vy], size)| {
let number = draft.windows.get(i).map_or(1, |w| w.number);
let Some((_, [wx, wy, ww, wh])) = windows.iter().find(|(n, _)| *n == number) else {
return (number, [0.0; 4]);
};
let [pw, ph] = rects[i].map_or([1, 1], |r| r.1).map(|v| v.max(1) as f32);
(
number,
[
wx + vx as f32 / pw * ww,
wy + vy as f32 / ph * wh,
size.width as f32 / pw * ww,
size.height as f32 / ph * wh,
],
)
})
.collect();
(mapped_monitors, windows, tiles)
}
/// The largest visible side of a tile after later windows cover it.
pub fn uncovered(mut r: [f32; 4], covers: impl Iterator<Item = [f32; 4]>) -> [f32; 4] {
for [cx, cy, cw, ch] in covers {
let [x, y, w, h] = r;
if cx >= x + w || cx + cw <= x || cy >= y + h || cy + ch <= y {
continue;
}
let sides = [
[x, y, cx - x, h],
[cx + cw, y, x + w - (cx + cw), h],
[x, y, w, cy - y],
[x, cy + ch, w, y + h - (cy + ch)],
];
r = sides
.into_iter()
.map(|[sx, sy, sw, sh]| [sx, sy, sw.max(0.0), sh.max(0.0)])
.max_by(|a, b| (a[2] * a[3]).total_cmp(&(b[2] * b[3])))
.unwrap_or([x, y, 0.0, 0.0]);
}
r
}
#[cfg(test)]
mod tests {
use super::*;
use crate::split_layout::{Placement, Spot};
#[test]
fn preview_tiles_share_a_window_and_fallback_when_a_monitor_disappears() {
let monitors = vec![
Monitor {
name: "Desk".into(),
at: [0, 0],
size: [2000, 1200],
scale: 1.0,
},
Monitor {
name: "TV".into(),
at: [-1920, 0],
size: [1920, 1080],
scale: 1.0,
},
];
let mut draft =
Draft::everyone_here(2, Placement::Free(Some(([0, 0], [2000, 1200]))), 0, 0);
let (_, windows, tiles) = project(&draft, &monitors, "Desk", 427.0, 300.0);
assert_eq!(windows.len(), 1);
let (a, b) = (tiles[0].1, tiles[1].1);
assert!((a[2] - b[2]).abs() < 0.01);
assert!((a[0] + a[2] - b[0]).abs() < 0.01);
draft.place_player(1, Spot::Fill("TV".into()));
let (screens, windows, tiles) = project(&draft, &monitors, "Desk", 427.0, 300.0);
assert!(screens[1][0] < screens[0][0]);
assert_eq!(windows.len(), 2);
assert_eq!((tiles[0].0, tiles[1].0), (1, 2));
let (_, windows, tiles) = project(&draft, &monitors[..1], "Desk", 427.0, 300.0);
assert_eq!(windows.len(), 1);
assert_eq!((tiles[0].0, tiles[1].0), (1, 1));
assert!(project(&draft, &[], "Desk", 427.0, 300.0).0.is_empty());
}
#[test]
fn covered_tiles_only_expose_a_visible_side_for_clicks() {
let tile = [0.0, 0.0, 100.0, 100.0];
assert_eq!(
uncovered(tile, [[0.0, 0.0, 60.0, 100.0]].into_iter()),
[60.0, 0.0, 40.0, 100.0]
);
let hidden = uncovered(tile, [tile].into_iter());
assert_eq!(hidden[2] * hidden[3], 0.0);
assert_eq!(
uncovered(tile, [[110.0, 0.0, 50.0, 50.0]].into_iter()),
tile
);
}
}
File diff suppressed because it is too large. Load diff
+421 -229
View File
@@ -6,34 +6,67 @@ use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::section_mask::SectionMask;
use crate::{
chunk::Chunk,
chunk_map::ChunkMembershipDelta,
graphics::LeafCullingMode,
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
world::World,
};
use horizon_core::biome::BiomeZoom;
use horizon_threads::jobs::{Class, PerWorker, Rank, Registration, Source};
use horizon_world::block_render::FaceVisibility;
use horizon_world::worldgen::decoration_order::DecorationOrder;
use std::{
collections::{BinaryHeap, HashSet, VecDeque},
sync::{
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
thread,
sync::{Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError},
};
/// How long a worker waits with nothing to do before it gives back its
/// generator's scratch buffers ([`ChunkGenerator::idle`]).
pub const IDLE_RELEASE: std::time::Duration = std::time::Duration::from_secs(5);
/// Worker CPU time and job counts by kind, for benchmarks
/// (`examples/gen_light_bench.rs`). Off unless enabled: a relaxed load per
/// job then.
pub mod job_stats {
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
pub const KINDS: [&str; 7] = [
"load", "generate", "decorate", "finish", "light", "mesh", "other",
];
static ENABLED: AtomicBool = AtomicBool::new(false);
static NANOS: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
static COUNT: [AtomicU64; 7] = [const { AtomicU64::new(0) }; 7];
pub fn enable(on: bool) {
ENABLED.store(on, Ordering::Relaxed);
}
pub(super) fn enabled() -> bool {
ENABLED.load(Ordering::Relaxed)
}
pub(super) fn add(kind: usize, nanos: u64) {
NANOS[kind].fetch_add(nanos, Ordering::Relaxed);
COUNT[kind].fetch_add(1, Ordering::Relaxed);
}
/// (kind, nanoseconds, count) since the last call; resets them.
pub fn take() -> Vec<(&'static str, u64, u64)> {
(0..KINDS.len())
.map(|i| {
(
KINDS[i],
NANOS[i].swap(0, Ordering::Relaxed),
COUNT[i].swap(0, Ordering::Relaxed),
)
})
.collect()
}
}
/// A chunk under generation, opaque to the streamer.
pub type Proto = Box<dyn std::any::Any + Send>;
/// Chunk generation in vanilla's stages: a neighbour-independent proto chunk
/// (biomes, noise, surface, carvers), decoration of a 3x3 neighbourhood
/// (FEATURES, which may write into neighbours), then conversion of a chunk
/// whose eight neighbours are decorated into a final column. Implementations
/// must be thread-safe; each worker thread owns one `Worker`.
/// must be thread-safe; each worker of the shared pool
/// (`horizon_threads::jobs`) keeps one `Worker` per streamer.
pub trait ChunkGenerator: Send + Sync + 'static {
type Worker: Send + 'static;
fn worker(&self) -> Self::Worker;
@@ -201,6 +234,7 @@ impl<G: ChunkGenerator> Saves for G {
struct MeshSettings {
nether: bool,
shader_pack_lighting: bool,
separate_ao: bool,
zoom: BiomeZoom,
blend_radius: i32,
smooth_lighting: bool,
@@ -276,8 +310,6 @@ impl Ord for Queued {
}
struct Shared {
queue: Mutex<BinaryHeap<Queued>>,
ready: Condvar,
stop: AtomicBool,
/// What meshing reads (block models and sprites), swapped when resource
/// packs reload; each mesh job takes the current one.
context: RwLock<Arc<WorkerContext>>,
@@ -285,11 +317,6 @@ struct Shared {
/// Only workers use this pool; frames never wait for its lock. Retaining
/// cleared buffers avoids racing the renderer for the process heap.
meshes: Mutex<MeshPool>,
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
/// job since: the last of them gives the freed memory back to the OS.
released: AtomicUsize,
/// The workers that started.
workers: AtomicUsize,
}
#[derive(Default)]
struct MeshPool {
@@ -364,7 +391,11 @@ pub struct Streaming {
/// next arrive, though read from the save (`World::loaded_chunks`).
dropped_new: FxHashSet<(i32, i32)>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
threads: Vec<thread::JoinHandle<()>>,
/// This streamer's place in the shared pool (`horizon_threads::jobs`);
/// dropping it waits for its running jobs.
jobs: Registration,
/// The workers its in-flight work is sized for.
workers: usize,
order: u64,
/// Ticks since the columns and protos that fell out of range were swept.
scan_age: u8,
@@ -435,6 +466,9 @@ pub struct Streaming {
/// A shader pack lights the faces: sections mesh without face shading
/// (`Mesher::shader_pack_lighting`).
pub shader_pack_lighting: bool,
/// OptiFine/Iris separate ambient occlusion: vertex RGB has pure tint and alpha holds AO factor
/// (`Mesher::separate_ao`).
pub separate_ao: bool,
/// `BiomeManager`'s zoom seed (the hashed world seed) and `biomeBlendRadius`.
pub biome_zoom: BiomeZoom,
pub biome_blend: i32,
@@ -476,6 +510,14 @@ pub struct Streaming {
/// Snapshot only after a scan found no missing candidates. Public range fields
/// and `World::chunks` can change without going through Streaming, so compare
/// their values and structural membership token rather than a frame timeout.
///
/// A scan also keeps every candidate it found, nearest first (`plan`): while
/// the ranges stay the same and no column leaves (`World::chunks` removals, or
/// `invalidate` for proto chunks dropped), no column becomes wanted, so the
/// next batches are the plan's entries still missing. A full scan looks at
/// every column of the areas (thousands at render distance 32), and one ran
/// whenever a worker finished: it took a quarter of the thread that runs
/// `Streaming::update` while a world loaded.
#[derive(Default)]
struct GenerationScan {
exhausted: bool,
@@ -485,17 +527,52 @@ struct GenerationScan {
forced: HashSet<(i32, i32)>,
band: Option<Band>,
chunk_revision: u64,
plan: Vec<(i64, i32, i32)>,
/// Entries before it are no longer missing.
next: usize,
/// What `plan` was made for: the ranges, the loading screen and
/// decoration; the chunk map's revision is `chunk_revision`.
plan_basis: Option<(bool, bool)>,
}
impl GenerationScan {
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
self.exhausted
&& self.center == ranges.center
fn same_ranges(&self, ranges: &Ranges<'_>) -> bool {
self.center == ranges.center
&& self.radius == ranges.radius
&& self.extra == ranges.extra
&& self.forced == *ranges.forced
&& self.band.as_ref() == ranges.band
}
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
self.exhausted
&& self.same_ranges(ranges)
&& self.chunk_revision == world.chunks.membership_revision()
}
/// A column may be wanted again (a proto chunk was dropped): the next
/// call scans the areas again.
fn invalidate(&mut self) {
self.exhausted = false;
self.plan_basis = None;
}
/// Whether `plan` still holds every candidate: same ranges, and no
/// column left the chunk map since it was made.
fn plan_holds(&mut self, input: &GenerationInputs<'_>) -> bool {
if self.plan_basis != Some((input.loading, input.decorates))
|| !self.same_ranges(&input.ranges)
{
return false;
}
let revision = input.world.chunks.membership_revision();
if revision != self.chunk_revision {
let Some(mut changes) = input.world.chunks.deltas_since(self.chunk_revision) else {
return false;
};
if !changes.all(|(_, change)| matches!(change, ChunkMembershipDelta::Added(_))) {
return false;
}
self.chunk_revision = revision;
}
true
}
fn remember(&mut self, ranges: Ranges<'_>, world: &World) {
self.exhausted = true;
self.center = ranges.center;
@@ -533,6 +610,30 @@ impl GenerationScan {
{
return None;
}
let missing = |&(_, x, z): &(i64, i32, i32)| {
!input.world.chunks.contains_key(&(x, z))
&& !input.generating.contains(&(x, z))
&& !input.protos.contains_key(&(x, z))
&& !input.finishing.contains(&(x, z))
};
if self.plan_holds(&input) {
while self.next < self.plan.len() && !missing(&self.plan[self.next]) {
self.next += 1;
}
let room = capacity - input.generating.len();
let batch: Vec<_> = self.plan[self.next..]
.iter()
.filter(|c| missing(c))
.take(room)
.copied()
.collect();
// Past the plan's end, the full scan below decides whether
// anything is left (and remembers when nothing is).
if !batch.is_empty() {
self.exhausted = false;
return Some(batch);
}
}
{
let mut wanted = Vec::new();
// Each area's square of columns, as one run per row. Players'
@@ -592,28 +693,218 @@ impl GenerationScan {
}
}
}
let ranges = Ranges {
center: input.ranges.center,
radius: input.ranges.radius,
extra: input.ranges.extra,
forced: input.ranges.forced,
cover: input.ranges.cover,
band: input.ranges.band,
};
self.remember(ranges, input.world);
if wanted.is_empty() {
let ranges = Ranges {
center: input.ranges.center,
radius: input.ranges.radius,
extra: input.ranges.extra,
forced: input.ranges.forced,
cover: input.ranges.cover,
band: input.ranges.band,
};
self.remember(ranges, input.world);
self.plan.clear();
self.plan_basis = None;
} else {
// A bounded batch may leave candidates behind. Keep scanning
// as capacity opens, even if no range or membership changed.
// A bounded batch may leave candidates behind: the next calls
// take them from the plan as capacity opens.
self.exhausted = false;
self.plan_basis = Some((input.loading, input.decorates));
}
wanted.sort_unstable();
wanted.dedup();
self.plan.clear();
self.plan.extend_from_slice(&wanted);
self.next = 0;
wanted.truncate(capacity - input.generating.len());
Some(wanted)
}
}
}
/// What a pool worker keeps for one streamer between its jobs: the
/// generator's worker, and the face visibility table and its occlusion
/// cache (like vanilla's per-thread cache: cloning it per job copied every
/// state and face shape) and the mesher's scratch.
struct WorkerLocal<W> {
worker: W,
faces: Option<(Arc<MeshTables>, FaceVisibility)>,
mesh_scratch: MeshScratch,
}
/// A streamer's jobs, run by the process's shared pool (`horizon_threads::jobs`).
struct Jobs<G: ChunkGenerator> {
shared: Arc<Shared>,
generator: Arc<G>,
light: Arc<LightTables>,
locals: PerWorker<WorkerLocal<G::Worker>>,
}
impl<G: ChunkGenerator> Source for Jobs<G> {
fn next(&self) -> Option<Rank> {
let queue = self.shared.queue.lock().unwrap();
queue.peek().map(|q| {
// Jobs without a column (saves, meshes to free) go first.
let class = if q.at.is_none() {
Class::Urgent
} else {
Class::Near
};
Rank::new(class, q.priority)
})
}
fn run(&self, worker: usize) -> bool {
let Some(job) = self.shared.queue.lock().unwrap().pop() else {
return false;
};
let started = job_stats::enabled().then(|| {
let kind = match &job.job {
Job::Generate(..) => 1,
Job::Decorate(..) => 2,
Job::Finish(..) => 3,
Job::Light(..) => 4,
Job::Mesh(..) => 5,
_ => 6,
};
(kind, crate::spans::thread_cpu().unwrap_or_default())
});
let done = self.locals.with(
worker,
|| WorkerLocal {
worker: self.generator.worker(),
faces: None,
mesh_scratch: MeshScratch::default(),
},
|local| self.run_job(job.job, local),
);
if let Some((kind, started)) = started {
// A generation job that found the column saved loaded it.
let kind = if kind == 1 && matches!(done, Some(Done::Generated(_))) {
0
} else {
kind
};
let cpu = crate::spans::thread_cpu().unwrap_or_default();
job_stats::add(kind, cpu.saturating_sub(started).as_nanos() as u64);
}
if let Some(done) = done {
self.shared.done.send(done);
}
true
}
fn idle(&self, worker: usize) {
// A server nobody is on, a client standing still: free the
// generator's buffers (and the mesher's face cache and scratch)
// until the next job needs them.
self.locals.with_existing(worker, |local| {
self.generator.idle(&mut local.worker);
local.faces = None;
local.mesh_scratch = MeshScratch::default();
});
}
fn all_idle(&self) {
// No worker needs retained mesh capacity now. Drop it outside the
// pool lock; the pool then asks the allocator to return freed pages.
let meshes = std::mem::take(&mut *self.shared.meshes.lock().unwrap());
drop(meshes);
}
}
impl<G: ChunkGenerator> Jobs<G> {
fn run_job(&self, job: Job, local: &mut WorkerLocal<G::Worker>) -> Option<Done> {
let (generator, shared, light) = (&self.generator, &self.shared, &self.light);
let WorkerLocal {
worker,
faces,
mesh_scratch,
} = local;
let done = match job {
Job::Generate(x, z) => match generator.load(x, z) {
Some(chunk) => {
generator.prepare_loaded(&mut *worker, &chunk);
Done::Generated(chunk)
}
None => {
let proto = generator.proto(&mut *worker, x, z);
let decorated = generator.is_decorated(&proto);
Done::Proto(x, z, proto, decorated)
}
},
Job::Decorate(x, z, mut region, finals) => {
// The final chunks' proto chunks, made here: a
// generated proto chunk holding their blocks.
for (i, snapshot) in &finals {
if let Some(proto) = generator.proto_beside_final(&mut *worker, snapshot) {
region[*i] = proto;
}
}
generator.decorate(&mut *worker, &mut region);
let mut writes = FinalWrites::default();
for (i, snapshot) in &finals {
let blocks = generator.final_writes(snapshot, &region[*i]);
writes
.block_entities
.extend(generator.final_block_entities(&region[*i], &blocks));
writes.blocks.extend(blocks);
region[*i] = Box::new(());
}
let finals = finals.into_iter().map(|(i, _)| i).collect();
Done::Decorated(x, z, region, finals, writes)
}
Job::Finish(proto) => {
let mut chunk = generator.finish(proto);
chunk.generated = true;
Done::Generated(chunk)
}
Job::Save(protos) => {
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
generator.save_protos(&refs);
Done::Saved(protos)
}
Job::LightView(x, z, proto) => {
let view = generator.light_view(&proto);
Done::LightView(x, z, proto, view)
}
Job::Free(meshes) => {
for (_, mesh) in meshes {
let rejected = shared.meshes.lock().unwrap().put(mesh);
drop(rejected);
}
return None;
}
Job::Light(x, z, region, seq, sky) => {
Done::Lit(x, z, light_chunk(&borrow(&region), light, sky), seq)
}
Job::Mesh(x, sy, z, region, seq, settings) => {
let context = shared.context.read().unwrap().clone();
let visibility = match faces.take() {
Some((tables, visibility)) if Arc::ptr_eq(&tables, &context.tables) => {
visibility
}
_ => context.tables.visibility.clone(),
};
let mut mesher = Mesher {
nether_shading: settings.nether,
shader_pack_lighting: settings.shader_pack_lighting,
separate_ao: settings.separate_ao,
zoom: settings.zoom,
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
cutout_leaves: settings.cutout_leaves,
translucent_layers: &settings.translucent_layers,
leaf_culling: settings.leaf_culling,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
biomes: &context.biomes,
visibility,
};
let recycled = shared.meshes.lock().unwrap().take();
let mut mesh = mesher.mesh_reusing(&borrow(&region), sy, recycled, mesh_scratch);
mesh.sort_solid();
*faces = Some((context.tables.clone(), mesher.visibility));
Done::Meshed(x, sy, z, mesh, seq)
}
};
Some(done)
}
}
pub struct WorkerContext {
pub assets: Arc<crate::assets::Assets>,
pub render: Arc<crate::assets::RenderAssets>,
@@ -629,190 +920,23 @@ impl Streaming {
) -> Self {
let shared = Arc::new(Shared {
queue: Mutex::new(BinaryHeap::new()),
ready: Condvar::new(),
stop: AtomicBool::new(false),
context: RwLock::new(context.clone()),
done: Mailbox::default(),
meshes: Mutex::new(MeshPool::default()),
released: AtomicUsize::new(0),
workers: AtomicUsize::new(0),
});
let light_tables = context.light.clone();
let saves: Arc<dyn Saves> = generator.clone();
let threads = (0..threads.max(1))
.map(|i| {
let shared = shared.clone();
let generator = generator.clone();
let light = context.light.clone();
thread::Builder::new()
.name(format!("horizon-worker-{i}"))
.spawn(move || {
// Ticks and frames come first (horizon_threads).
horizon_threads::behind_server();
let mut worker = generator.worker();
// The face visibility table and its occlusion cache stay with
// the worker across mesh jobs, like vanilla's per-thread cache:
// cloning it per job copied every state and face shape.
let mut faces: Option<(Arc<MeshTables>, FaceVisibility)> = None;
let mut mesh_scratch = MeshScratch::default();
loop {
let job = {
let mut queue = shared.queue.lock().unwrap();
let mut released = false;
loop {
if shared.stop.load(Ordering::Relaxed) {
return;
}
if let Some(job) = queue.pop() {
if released {
shared.released.fetch_sub(1, Ordering::Relaxed);
}
break job.job;
}
if released {
queue = shared.ready.wait(queue).unwrap();
continue;
}
let (woken, wait) =
shared.ready.wait_timeout(queue, IDLE_RELEASE).unwrap();
queue = woken;
if wait.timed_out() && queue.is_empty() {
// A server nobody is on, a client standing still:
// free the generator's buffers (and the mesher's
// face cache and scratch) until the next job
// needs them.
drop(queue);
generator.idle(&mut worker);
faces = None;
mesh_scratch = MeshScratch::default();
released = true;
// The allocator keeps freed pages until memory
// runs short: once every worker has freed its own,
// hand them back.
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
== shared.workers.load(Ordering::Relaxed)
{
// No worker needs retained mesh capacity now.
// Drop it outside the pool lock before asking
// the allocator to return freed pages.
let meshes =
std::mem::take(&mut *shared.meshes.lock().unwrap());
drop(meshes);
horizon_threads::release_free_memory();
}
queue = shared.queue.lock().unwrap();
}
}
};
let done = match job {
Job::Generate(x, z) => match generator.load(x, z) {
Some(chunk) => {
generator.prepare_loaded(&mut worker, &chunk);
Done::Generated(chunk)
}
None => {
let proto = generator.proto(&mut worker, x, z);
let decorated = generator.is_decorated(&proto);
Done::Proto(x, z, proto, decorated)
}
},
Job::Decorate(x, z, mut region, finals) => {
// The final chunks' proto chunks, made here: a
// generated proto chunk holding their blocks.
for (i, snapshot) in &finals {
if let Some(proto) =
generator.proto_beside_final(&mut worker, snapshot)
{
region[*i] = proto;
}
}
generator.decorate(&mut worker, &mut region);
let mut writes = FinalWrites::default();
for (i, snapshot) in &finals {
let blocks = generator.final_writes(snapshot, &region[*i]);
writes.block_entities.extend(
generator.final_block_entities(&region[*i], &blocks),
);
writes.blocks.extend(blocks);
region[*i] = Box::new(());
}
let finals = finals.into_iter().map(|(i, _)| i).collect();
Done::Decorated(x, z, region, finals, writes)
}
Job::Finish(proto) => {
let mut chunk = generator.finish(proto);
chunk.generated = true;
Done::Generated(chunk)
}
Job::Save(protos) => {
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
generator.save_protos(&refs);
Done::Saved(protos)
}
Job::LightView(x, z, proto) => {
let view = generator.light_view(&proto);
Done::LightView(x, z, proto, view)
}
Job::Free(meshes) => {
for (_, mesh) in meshes {
let rejected = shared.meshes.lock().unwrap().put(mesh);
drop(rejected);
}
continue;
}
Job::Light(x, z, region, seq, sky) => {
Done::Lit(x, z, light_chunk(&borrow(&region), &light, sky), seq)
}
Job::Mesh(x, sy, z, region, seq, settings) => {
let context = shared.context.read().unwrap().clone();
let visibility = match faces.take() {
Some((tables, visibility))
if Arc::ptr_eq(&tables, &context.tables) =>
{
visibility
}
_ => context.tables.visibility.clone(),
};
let mut mesher = Mesher {
nether_shading: settings.nether,
shader_pack_lighting: settings.shader_pack_lighting,
zoom: settings.zoom,
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
cutout_leaves: settings.cutout_leaves,
translucent_layers: &settings.translucent_layers,
leaf_culling: settings.leaf_culling,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
biomes: &context.biomes,
visibility,
};
let recycled = shared.meshes.lock().unwrap().take();
let mut mesh = mesher.mesh_reusing(
&borrow(&region),
sy,
recycled,
&mut mesh_scratch,
);
mesh.sort_solid();
faces = Some((context.tables.clone(), mesher.visibility));
Done::Meshed(x, sy, z, mesh, seq)
}
};
shared.done.send(done);
}
})
})
// A thread the system refuses (a container's process limit reached) leaves the pool a
// worker short: a server that panicked here went down with its players' moves unsaved.
.filter_map(|spawned| {
spawned
.inspect_err(|e| log::error!("Horizon: chunk worker not started: {e}"))
.ok()
})
.collect::<Vec<_>>();
shared.workers.store(threads.len(), Ordering::Relaxed);
let pool = horizon_threads::jobs::pool();
let source = Arc::new(Jobs {
shared: shared.clone(),
generator: generator.clone(),
light: context.light.clone(),
locals: horizon_threads::jobs::PerWorker::new(pool.threads()),
});
let jobs = pool.register(source);
// The work kept in flight is sized for the workers this streamer may
// use: the pool's, or fewer when asked (tests, a placeholder).
let workers = threads.clamp(1, pool.threads());
let non_air_sections = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.non_air_sections(p))
@@ -820,7 +944,8 @@ impl Streaming {
Self {
shared,
non_air_sections,
threads,
jobs,
workers,
order: 0,
scan_age: 0,
scan_area: ((i32::MIN, i32::MIN), i32::MIN),
@@ -859,6 +984,7 @@ impl Streaming {
sky_light: true,
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
biome_zoom: BiomeZoom::new(0),
biome_blend: 2,
smooth_lighting: true,
@@ -1019,7 +1145,7 @@ impl Streaming {
order: self.order,
job,
});
self.shared.ready.notify_one();
self.jobs.notify();
}
/// Rank the queued jobs again when what their priorities were computed
/// from changed: the centre, the extra areas, the band, or the loading
@@ -1489,7 +1615,7 @@ impl Streaming {
.map(|(k, _)| *k)
.collect();
if !far.is_empty() {
self.generation_scan.exhausted = false;
self.generation_scan.invalidate();
}
// Those decoration wrote to are saved first (see
// `ChunkGenerator::save_protos`), and dropped once back.
@@ -1538,7 +1664,7 @@ impl Streaming {
// Decorate chunks whose 3x3 proto neighbourhood is available, in
// vanilla's order: decoration is order dependent where write regions
// overlap (see `DecorationOrder`).
let workers = self.threads.len();
let workers = self.workers;
if self.decorates && self.order_area != Some((self.center, self.radius)) {
let (decorated, chunks) = (&self.decorated, &world.chunks);
// The render distance is the server view distance in singleplayer.
@@ -1600,14 +1726,24 @@ impl Streaming {
w.0 = self.decoration_order.rank_or_append(w.2, w.3);
}
wanted.sort_unstable();
// Most wanted chunks are blocked, and the same neighbours are
// asked about for each of them: what `waits_for` says is kept for
// the pass (nothing it reads changes in it but `decorated`, which
// updates the entry). Without it, scheduling at render distance 32
// took most of the thread that runs it while the world loaded.
let waiting: std::cell::RefCell<FxHashMap<(i32, i32), bool>> = Default::default();
for (_, _, x, z) in wanted {
if self.decorating >= workers * 2 {
break;
}
if self
.decoration_order
.blocked(x, z, |nx, nz| waits_for(self, nx, nz))
{
if self.decoration_order.blocked(x, z, |nx, nz| {
if let Some(&known) = waiting.borrow().get(&(nx, nz)) {
return known;
}
let known = waits_for(self, nx, nz);
waiting.borrow_mut().insert((nx, nz), known);
known
}) {
continue;
}
let keys: Vec<(i32, i32)> =
@@ -1627,6 +1763,7 @@ impl Streaming {
.collect();
if !finals.is_empty() && !self.saves_beside_final {
self.decorated.insert((x, z));
waiting.borrow_mut().insert((x, z), false);
continue;
}
if !keys.iter().enumerate().all(|(i, k)| {
@@ -1680,7 +1817,7 @@ impl Streaming {
for key in gone {
world.dirty_light.remove(&key);
}
let room = (self.threads.len() * 4).saturating_sub(self.lighting.len());
let room = (self.workers * 4).saturating_sub(self.lighting.len());
nearest_first(&mut light, room);
for (_, x, z) in light {
let Some(region) = self.light_region(world, x, z) else {
@@ -1766,7 +1903,7 @@ impl Streaming {
// jobs went stale long before a worker reached them (#3629). What
// stays dirty is asked for again, from the current centre, as
// capacity frees; `requeue` then has a small queue to keep close.
let room = (self.threads.len() * 32).saturating_sub(self.meshing.len());
let room = (self.workers * 32).saturating_sub(self.meshing.len());
nearest_first(&mut candidates, room);
for (_, x, z, sections) in candidates {
let Some(region) = Self::region(world, x, z) else {
@@ -1789,6 +1926,7 @@ impl Streaming {
MeshSettings {
nether: self.nether_shading,
shader_pack_lighting: self.shader_pack_lighting,
separate_ao: self.separate_ao,
zoom: self.biome_zoom,
blend_radius: self.biome_blend,
smooth_lighting: self.smooth_lighting,
@@ -2438,11 +2576,8 @@ impl Drop for Streaming {
None => break,
}
}
self.shared.stop.store(true, Ordering::Relaxed);
self.shared.ready.notify_all();
for t in self.threads.drain(..) {
let _ = t.join();
}
// `jobs` leaves the pool after this, once its running jobs are
// done; the jobs still queued go with the queue.
}
}
/// The queued jobs' priorities computed again from each one's column
@@ -2554,14 +2689,7 @@ fn keep_unchanged_sections(
}
/// Whether two sections' packed light (4,096 cells) holds the same values.
fn same_light(a: &crate::chunk::Light, b: &crate::chunk::Light) -> bool {
use crate::chunk::Light;
match (a, b) {
(Light::Uniform(x), Light::Uniform(y)) => x == y,
(Light::Full(x), Light::Full(y)) => x == y,
(Light::Uniform(v), Light::Full(f)) | (Light::Full(f), Light::Uniform(v)) => {
f.iter().all(|c| c == v)
}
}
a.same_as(b)
}
/// Whether two biome containers (64 cells) hold the same values.
fn same_values(
@@ -3143,6 +3271,70 @@ mod tests {
}
}
/// Batches taken from the plan are the full scan's, as jobs finish,
/// columns and proto chunks leave and the centre moves.
#[test]
fn generation_plan_matches_full_scans_as_columns_arrive() {
for decorates in [false, true] {
let mut f = GenerationFixture::new(6, decorates);
let mut gate = GenerationScan::default();
let chunk = Arc::new(Chunk::empty(0, 0));
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let mut rand = |n: u64| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed % n
};
let mut scheduled = 0;
for step in 0..600 {
let capacity = 3 + rand(6) as usize;
let expected = baseline_candidates(f.input(), capacity);
let batch = gate.candidates(f.input(), capacity).unwrap_or_default();
assert_eq!(batch, expected, "step {step}");
for (_, x, z) in batch {
f.generating.insert((x, z));
scheduled += 1;
}
// Some jobs finish: as a proto chunk, or a final column.
let mut done: Vec<_> = f.generating.iter().copied().collect();
done.sort_unstable();
for key in done {
if rand(3) != 0 {
continue;
}
f.generating.remove(&key);
if rand(2) == 0 {
f.protos.insert(key, None);
} else {
f.world.chunks.insert(key, chunk.clone());
}
gate.exhausted = false;
}
// Now and then a column or a proto chunk leaves.
if rand(20) == 0 {
let mut keys: Vec<_> = f.world.chunks.keys().copied().collect();
keys.sort_unstable();
if let Some(key) = keys.get(rand(keys.len().max(1) as u64) as usize) {
f.world.chunks.remove(key);
}
}
if rand(20) == 0 {
let mut keys: Vec<_> = f.protos.keys().copied().collect();
keys.sort_unstable();
if let Some(key) = keys.get(rand(keys.len().max(1) as u64) as usize) {
f.protos.remove(key);
gate.invalidate();
}
}
if step == 300 {
f.center = (3, -2);
}
}
assert!(scheduled > 200);
}
}
#[test]
fn generation_scan_looks_at_overlapping_areas_once() {
// A server's players: many areas around one spot, some apart, one
@@ -510,6 +510,19 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
}
return apply_fog(vec4(color.rgb, emissive), in.spherical, in.cylindrical);
}
// HDR lighting on what's mostly opaque among translucent faces (water), drawn again after
// them with only alpha written: `fs_translucent` blends its alpha as coverage, so a lake's
// value would be diluted. Clear glass leaves what's behind it, light sources included, alone.
@fragment fn fs_translucent_mark(in: TerrainOut) -> @location(0) vec4<f32> {
let dx = dpdx(in.local);
let dy = dpdy(in.local);
hdr_normal = normalize(cross(dy, dx));
if sprite(in.uv, in.layer & 0x7fffu).a < 0.6 { discard; }
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
let scene = mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()), globals.hdr_light.y, fogged);
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(scene));
}
// Water, glass and other see-through faces draw whole as they fade in, the
// layer drawn first in front (gfx.rs's water tiers): dithered, the faded-out
// pixels showed the layer behind through the one in front, twice the water.
+3
View File
@@ -114,6 +114,9 @@ audio = ["horizon-client-render/audio", "horizon-client-lib/audio"]
controllers = ["horizon-client-lib/controllers", "dep:gilrs", "dep:horizon-dualsense", "horizon-client-input/controllers"]
# 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
# malloc (heaptrack, valgrind's massif) see them. For profiling only: mimalloc is faster.
system-allocator = []
# Compatibility with earlier experiment commands: main already uses mimalloc.
windows-mimalloc = []
# Opt-in Windows heap experiment; see docs/windows-allocators.md.
@@ -35,16 +35,16 @@
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.pages.split_settings": "Splitscreen Settings",
"horizon.pages.split_setup": "Splitscreen",
"horizon.pages.split_profiles": "Who's playing?",
"horizon.pages.split_setup": "Splitscreen Setup",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
"horizon.pages.graphics_advanced.tooltip": "Every graphics setting, Horizon's included, with what each costs.",
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
"horizon.pages.split_setup.tooltip": "Players on this computer: controllers can join your world.",
"horizon.pages.split_profiles.tooltip": "Who plays: a profile kept in this game folder.",
"horizon.pages.split_setup.tooltip": "The windows and monitors, and who plays where.",
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
"horizon.pages.open.graphics": "Graphics...",
@@ -306,6 +306,55 @@
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play.",
"horizon.splitscreen.setup.monitor": "Monitor %s",
"horizon.splitscreen.setup.monitor_unknown": "a monitor",
"horizon.splitscreen.setup.window": "Window %s",
"horizon.splitscreen.setup.new_window": "New window",
"horizon.splitscreen.setup.fill_monitor": "Fill %s",
"horizon.splitscreen.setup.done": "Done",
"horizon.splitscreen.setup.switch": "%s: %s",
"horizon.splitscreen.setup.on": "On",
"horizon.splitscreen.setup.off": "Off",
"horizon.splitscreen.setup.player": "Player %s",
"horizon.splitscreen.setup.revert": "Revert",
"horizon.splitscreen.setup.apply": "Apply",
"horizon.splitscreen.setup.warning.empty_main": "Nobody plays in Window 1: it hides",
"horizon.splitscreen.setup.warning.empty_close": "Nobody plays in Window %s: it closes on Apply",
"horizon.splitscreen.setup.warning.empty_new": "Nobody plays in Window %s: it won't open",
"horizon.splitscreen.setup.warning.on_main": "Window %s fills Window 1's monitor: its players play in Window 1",
"horizon.splitscreen.setup.warning.unplugged": "Window %s's monitor is not connected: its players play in Window 1",
"horizon.splitscreen.setup.status.off": "Splitscreen is off.",
"horizon.splitscreen.setup.status.enable_hint": "Turn it on, then press a button on another controller in a world.",
"horizon.splitscreen.setup.status.join_hint": "Press A to join and pick a profile.",
"horizon.splitscreen.setup.status.idle": "Splitscreen idle",
"horizon.splitscreen.setup.status.alone": "No other local players.",
"horizon.splitscreen.setup.status.solo_controls": "Solo controls and controller feedback are unchanged.",
"horizon.splitscreen.setup.window_empty": "Window %s: empty",
"horizon.splitscreen.setup.note.select": "Click a player, or drag it to a window or a monitor.",
"horizon.splitscreen.setup.note.apply": "Apply to play this way, Revert to undo.",
"horizon.splitscreen.setup.note.place": "Where should %s play?",
"horizon.splitscreen.setup.this_player": "this player",
"horizon.splitscreen.setup.switch_disabled": "Splitscreen turns off once the others have left (their pause menu).",
"horizon.splitscreen.setup.window_place": "Window %s (%s of %s)",
"horizon.splitscreen.setup.names": "Names",
"horizon.splitscreen.setup.identify": "Identify",
"horizon.splitscreen.setup.layout.two": "2 sharing: %s",
"horizon.splitscreen.setup.layout.three": "3 sharing: %s",
"horizon.splitscreen.setup.layout.two.0": "Auto",
"horizon.splitscreen.setup.layout.two.1": "Side by side",
"horizon.splitscreen.setup.layout.two.2": "Top and bottom",
"horizon.splitscreen.setup.layout.three.0": "One wide on top",
"horizon.splitscreen.setup.layout.three.1": "One tall on the left",
"horizon.splitscreen.setup.layout.three.2": "Side by side",
"horizon.splitscreen.setup.help.player": "Click or drag %s to move them.",
"horizon.splitscreen.setup.help.layout_auto": "%s (side by side when the window is wide). Press to change.",
"horizon.splitscreen.setup.help.layout": "%s. Press to change.",
"horizon.splitscreen.setup.help.switch": "Splitscreen for this computer, on or off.",
"horizon.splitscreen.setup.help.identify": "Each player's view shows which window it is in.",
"horizon.splitscreen.setup.help.names": "Each player's name in the corner of their view.",
"horizon.splitscreen.setup.help.revert": "Undo the changes not applied yet.",
"horizon.splitscreen.setup.help.apply": "Play the way the map shows.",
"horizon.splitscreen.setup.help.unchanged": "Nothing changed yet.",
"Culling": "Culling",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
"Leaf Culling": "Leaf Culling",
@@ -336,5 +385,20 @@
"horizon.agent.off": "Off: agents can't reach this game.",
"horizon.agent.session_opened": "Opened for this session by the program that started the game.",
"horizon.agent.socket": "Socket: %s (copy the details for its full path)",
"horizon.agent.local_only": "Local only: agents on this computer, never the network."
"horizon.agent.local_only": "Local only: agents on this computer, never the network.",
"%s nits": "%s nits",
"Auto (%s nits)": "Auto (%s nits)",
"%.2f nits": "%.2f nits",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.",
"horizon.extras.repro_keys": "Recording shortcuts",
"horizon.extras.repro_keys.tooltip": "F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world.",
"horizon.agent.record_session": "Start Recording Session",
"horizon.agent.record_stop": "Stop Recording Session",
"horizon.agent.export_repro": "Export Repro Bundle",
"horizon.repro.started": "Recording started; F3+R stops it and writes the bundle.",
"horizon.repro.stopped": "Recording stopped: %s",
"horizon.repro.bundle": "Repro bundle: %s",
"horizon.repro.error": "Repro: %s",
"horizon.repro.no_recording": "There is no recording to export yet.",
"horizon.repro.replay_started": "Replay started: %s"
}
@@ -289,6 +289,60 @@
"horizon.multiplayer.invalid_address": "Adresse de serveur invalide : %s",
"horizon.multiplayer.connected_friend_direct": "Connecté directement à votre ami...",
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais...",
"horizon.splitscreen.setup.monitor": "Écran %s",
"horizon.splitscreen.setup.monitor_unknown": "un écran",
"horizon.splitscreen.setup.window": "Fenêtre %s",
"horizon.splitscreen.setup.new_window": "Nouvelle fenêtre",
"horizon.splitscreen.setup.fill_monitor": "Remplir %s",
"horizon.splitscreen.setup.done": "Terminé",
"horizon.splitscreen.setup.switch": "%s : %s",
"horizon.splitscreen.setup.on": "Activé",
"horizon.splitscreen.setup.off": "Désactivé",
"horizon.splitscreen.setup.player": "Joueur %s",
"horizon.splitscreen.setup.revert": "Rétablir",
"horizon.splitscreen.setup.apply": "Appliquer",
"horizon.splitscreen.setup.warning.empty_main": "La fenêtre 1 se masque si personne n'y joue.",
"horizon.splitscreen.setup.warning.empty_close": "Personne ne joue dans la fenêtre %s : elle se fermera après application.",
"horizon.splitscreen.setup.warning.empty_new": "Personne ne joue dans la fenêtre %s : elle ne s'ouvrira pas.",
"horizon.splitscreen.setup.warning.on_main": "La fenêtre %s occupe l'écran de la fenêtre 1 : ses joueurs restent dans la fenêtre 1.",
"horizon.splitscreen.setup.warning.unplugged": "L'écran de la fenêtre %s est débranché : ses joueurs restent dans la fenêtre 1.",
"horizon.splitscreen.setup.status.off": "L'écran partagé est désactivé.",
"horizon.splitscreen.setup.status.enable_hint": "Activez-le, puis appuyez sur un bouton d'une autre manette dans un monde.",
"horizon.splitscreen.setup.status.join_hint": "Appuyez sur A pour rejoindre et choisir un profil.",
"horizon.splitscreen.setup.status.idle": "Écran partagé en attente",
"horizon.splitscreen.setup.status.alone": "Aucun autre joueur local.",
"horizon.splitscreen.setup.status.solo_controls": "Les commandes et les vibrations en solo restent inchangées.",
"horizon.splitscreen.setup.window_empty": "Fenêtre %s : vide",
"horizon.splitscreen.setup.note.select": "Cliquez sur un joueur, ou faites-le glisser vers une fenêtre ou un écran.",
"horizon.splitscreen.setup.note.apply": "Appliquez les modifications ou annulez-les.",
"horizon.splitscreen.setup.note.place": "Où %s doit-il jouer ?",
"horizon.splitscreen.setup.this_player": "ce joueur",
"horizon.splitscreen.setup.switch_disabled": "L'écran partagé peut être désactivé après le départ des autres joueurs depuis leur menu pause.",
"horizon.pages.split_setup": "Configuration de l'écran partagé",
"horizon.pages.split_setup.tooltip": "Les fenêtres, les écrans et la place de chaque joueur.",
"horizon.pages.open.split_setup": "Écran partagé...",
"horizon.pages.split_settings": "Options de l'écran partagé",
"horizon.extras.splitscreen": "Écran partagé",
"horizon.splitscreen.setup.window_place": "Fenêtre %s (%s sur %s)",
"horizon.splitscreen.setup.names": "Noms",
"horizon.splitscreen.setup.identify": "Identifier",
"horizon.splitscreen.setup.layout.two": "2 par fenêtre : %s",
"horizon.splitscreen.setup.layout.three": "3 par fenêtre : %s",
"horizon.splitscreen.setup.layout.two.0": "Auto",
"horizon.splitscreen.setup.layout.two.1": "Côte à côte",
"horizon.splitscreen.setup.layout.two.2": "Haut et bas",
"horizon.splitscreen.setup.layout.three.0": "Un large en haut",
"horizon.splitscreen.setup.layout.three.1": "Un haut à gauche",
"horizon.splitscreen.setup.layout.three.2": "Côte à côte",
"horizon.splitscreen.setup.help.player": "Cliquez sur %s ou faites-le glisser pour le déplacer.",
"horizon.splitscreen.setup.help.layout_auto": "%s (côte à côte si la fenêtre est large). Appuyez pour changer.",
"horizon.splitscreen.setup.help.layout": "%s. Appuyez pour changer.",
"horizon.splitscreen.setup.help.switch": "L'écran partagé sur cet ordinateur, activé ou désactivé.",
"horizon.splitscreen.setup.help.identify": "La vue de chaque joueur indique sa fenêtre.",
"horizon.splitscreen.setup.help.names": "Le nom de chaque joueur dans le coin de sa vue.",
"horizon.splitscreen.setup.help.revert": "Annuler les modifications pas encore appliquées.",
"horizon.splitscreen.setup.help.apply": "Jouer comme le montre la carte.",
"horizon.splitscreen.setup.help.unchanged": "Rien n'a encore changé.",
"Culling": "Occlusion",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Masque les modèles derrière les blocs opaques. Vérification en arrière-plan ; les entités continuent.",
"Leaf Culling": "Occlusion feuillage",
@@ -329,12 +383,7 @@
"horizon.extras.header.sound": "Son",
"horizon.extras.hide_block_particles": "Masquer particules",
"horizon.extras.hide_block_particles.tooltip": "Masque les débris qui jaillissent d'un bloc cassé et s'effritent de celui que vous minez. Les autres particules restent.",
"horizon.extras.splitscreen": "Écran partagé",
"horizon.extras.splitscreen.tooltip": "Dans un monde, une autre manette peut rejoindre en tant que nouveau joueur : appuyez sur A quand c'est demandé, puis choisissez un profil et où jouer.",
"horizon.extras.ui_sound_reserve": "Canaux pour sons UI",
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu.",
"horizon.pages.open.split_setup": "Écran partagé...",
"horizon.pages.split_settings": "Options de l'écran partagé",
"horizon.pages.split_setup": "Écran partagé",
"horizon.pages.split_setup.tooltip": "Joueurs sur cet ordinateur : des manettes peuvent rejoindre votre monde."
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu."
}
+9 -1
View File
@@ -24,7 +24,15 @@ fn main() {
resource.set_manifest_file(&manifest);
}
if let Err(e) = resource.compile() {
panic!("embedding Windows resources: {e}");
// The GNU toolchain's `windres` (the launcher's mod builds use it) can't take a path
// with a space, and a Windows user name often has one: such a build goes on without
// the icon. Release builds run in CI, where the icon stays required.
if std::env::var_os("CI").is_some() {
panic!("embedding Windows resources: {e}");
}
println!(
"cargo:warning=building without Horizon's icon: embedding Windows resources failed: {e}"
);
}
}
}
@@ -89,8 +89,9 @@ fn main() -> Result<(), Error> {
});
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
// Generation and light, on the game's worker pool. Meshing is left out
// here and measured on its own below.
// Generation and light, on the game's worker pool (`threads` workers).
// Meshing is left out here and measured on its own below.
horizon_threads::jobs::configure(threads);
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
streaming.mesh = false;
streaming.center = center;
@@ -218,6 +219,7 @@ fn mesher<'a>(context: &'a WorkerContext, seed: i64) -> Mesher<'a> {
Mesher {
nether_shading: false,
shader_pack_lighting: false,
separate_ao: false,
zoom: BiomeZoom::new(obfuscate_seed(seed)),
blend_radius: 2,
smooth_lighting: true,
@@ -0,0 +1,356 @@
//! Generation and light throughput of the game's worker pool, and how it
//! scales with the number of workers: the columns around a point go through
//! the whole pipeline (proto chunk, decoration, final column, light) as when a
//! world loads, once per worker count, each on a freshly seeded generator.
//!
//! cargo run --release -p horizon-client-lib --example gen_light_bench -- \
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 12]
//! [--threads 1,2,4,8] [--period-ms 2] [--loading] [--repeat 1] [--json]
//!
//! For each run it prints the wall time until every column within the radius
//! (the area Streaming finishes) was lit, columns finished and lit per second, the process's CPU seconds per
//! column, the coordinator's (`Streaming::update`) share, and the workers' CPU
//! time by job kind (`streaming::job_stats`). The shared worker pool is sized
//! once per process (`HORIZON_JOB_THREADS`), so each worker count runs in a
//! process of its own, started by this one. The checksum
//! covers the blocks and light of the columns within the radius: an A/B pair of
//! builds, and every worker count, must print the same one.
//! `--period-ms` is how long the coordinator sleeps between updates (the
//! server thread runs one per 50 ms tick and pumps between ticks); `--loading`
//! orders work ring by ring, as under the level loading screen.
//!
//! `--simulate PROTO,DECORATE,FINISH` (milliseconds) swaps the generator for
//! one whose jobs sleep that long and make empty columns: what is left is the
//! pipeline itself (the coordinator, the in-flight limits, decoration order,
//! neighbour waits). Sleeping workers don't compete for cores, so this shows
//! how the pipeline scales to many workers even on a busy machine.
extern crate horizon_client_lib as horizon_client;
use horizon_client::{
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
chunk::Chunk,
light::LightTables,
mesher::MeshTables,
models,
spans::thread_cpu,
streaming::{ChunkGenerator, Proto, Streaming, WorkerContext, job_stats},
vanillagen::VanillaTerrain,
world::World,
};
use horizon_world::registry::BlockStateId;
use std::{
hash::{DefaultHasher, Hash, Hasher},
path::PathBuf,
process::Command,
sync::Arc,
thread,
time::{Duration, Instant},
};
type Error = Box<dyn std::error::Error>;
fn flag<T: std::str::FromStr>(args: &[String], name: &str, default: T) -> T {
args.iter()
.position(|a| a == name)
.and_then(|i| args.get(i + 1)?.parse().ok())
.unwrap_or(default)
}
fn process_cpu() -> Duration {
#[cfg(unix)]
{
let t = rustix::time::clock_gettime(rustix::time::ClockId::ProcessCPUTime);
Duration::new(t.tv_sec as u64, t.tv_nsec as u32)
}
#[cfg(not(unix))]
Duration::ZERO
}
/// Jobs that take a fixed time and make empty columns (`--simulate`).
struct Simulated {
proto: Duration,
decorate: Duration,
finish: Duration,
}
impl ChunkGenerator for Simulated {
type Worker = ();
fn worker(&self) {}
fn proto(&self, _worker: &mut (), x: i32, z: i32) -> Proto {
thread::sleep(self.proto);
Box::new((x, z))
}
fn decorates(&self) -> bool {
true
}
fn decorate(&self, _worker: &mut (), _region: &mut [Proto; 9]) {
thread::sleep(self.decorate);
}
fn finish(&self, proto: Proto) -> Chunk {
thread::sleep(self.finish);
let (x, z) = *proto.downcast::<(i32, i32)>().expect("simulated proto");
Chunk::empty(x, z)
}
fn light_view(&self, proto: &Proto) -> Option<Chunk> {
let &(x, z) = proto.downcast_ref::<(i32, i32)>()?;
Some(Chunk::empty(x, z))
}
}
struct Run {
wall: f64,
columns: usize,
lit: usize,
cpu: f64,
coordinator: f64,
updates: usize,
jobs: Vec<(&'static str, u64, u64)>,
checksum: u64,
half: f64,
}
#[allow(clippy::too_many_arguments)]
fn run<G: ChunkGenerator>(
generator: G,
context: &Arc<WorkerContext>,
center: (i32, i32),
radius: i32,
threads: usize,
period: Duration,
loading: bool,
) -> Result<Run, Error> {
let mut streaming = Streaming::new(Arc::new(generator), context.clone(), threads);
streaming.mesh = false;
streaming.center = center;
streaming.radius = radius;
streaming.loading = loading;
let mut world = World::new(BlockStateId(0), 0);
let view: Vec<(i32, i32)> = (center.0 - radius - 1..=center.0 + radius + 1)
.flat_map(|x| (center.1 - radius - 1..=center.1 + radius + 1).map(move |z| (x, z)))
// The columns Streaming finishes (`Ranges::in_main_range` with a
// margin of 1). `in_view_distance` reaches a few columns past them.
.filter(|&(x, z)| {
let (dx, dz) = (i64::from(x - center.0), i64::from(z - center.1));
dx * dx + dz * dz <= i64::from(radius + 1).pow(2)
})
.collect();
job_stats::enable(true);
job_stats::take();
let (cpu0, started) = (process_cpu(), Instant::now());
let mut coordinator = Duration::ZERO;
let mut updates = 0;
let mut half = None;
let trace = std::env::var_os("GEN_LIGHT_TRACE").is_some();
let mut next_trace = Duration::from_secs(2);
loop {
let t = thread_cpu().unwrap_or_default();
let _ = streaming.update(&mut world);
coordinator += thread_cpu().unwrap_or_default().saturating_sub(t);
updates += 1;
let lit = view
.iter()
.filter(|k| world.chunks.get(k).is_some_and(|c| c.lit))
.count();
if half.is_none() && lit * 2 >= view.len() {
half = Some(started.elapsed().as_secs_f64());
}
if lit == view.len() {
break;
}
if trace && started.elapsed() >= next_trace {
next_trace += Duration::from_secs(2);
let missing: Vec<_> = view
.iter()
.filter(|k| !world.chunks.get(k).is_some_and(|c| c.lit))
.take(4)
.collect();
eprintln!(
" {:.0} s: {lit}/{} lit, {} columns, {:?}, waiting e.g. {missing:?}",
started.elapsed().as_secs_f64(),
view.len(),
world.chunks.len(),
streaming.stages()
);
}
if started.elapsed() > Duration::from_secs(1800) {
return Err("timed out".into());
}
streaming.wait(period);
}
let wall = started.elapsed().as_secs_f64();
let cpu = (process_cpu() - cpu0).as_secs_f64();
let jobs = job_stats::take();
job_stats::enable(false);
let mut hash = DefaultHasher::new();
let mut ids = Box::new([0u32; 4096]);
for key in &view {
let chunk = &world.chunks[key];
key.hash(&mut hash);
for section in &chunk.sections {
section.unpack(&mut ids);
ids.hash(&mut hash);
(0..4096)
.map(|i| section.light.get(i))
.for_each(|v| v.hash(&mut hash));
}
}
let lit = world.chunks.values().filter(|c| c.lit).count();
Ok(Run {
wall,
columns: world.chunks.len(),
lit,
cpu,
coordinator: coordinator.as_secs_f64(),
updates,
jobs,
checksum: hash.finish(),
half: half.unwrap_or(wall),
})
}
fn main() -> Result<(), Error> {
let args: Vec<String> = std::env::args().collect();
let root = PathBuf::from(args.get(1).ok_or("usage: gen_light_bench <resources>")?);
let seed: i64 = flag(&args, "--seed", 1234);
let radius: i32 = flag(&args, "--radius", 12);
let repeat: usize = flag(&args, "--repeat", 1);
let period = Duration::from_millis(flag(&args, "--period-ms", 2));
let loading = args.iter().any(|a| a == "--loading");
let json = args.iter().any(|a| a == "--json");
let simulate: Option<Vec<f64>> = args
.iter()
.position(|a| a == "--simulate")
.map(|i| -> Result<Vec<f64>, Error> {
let ms: Vec<f64> = args
.get(i + 1)
.ok_or("--simulate PROTO,DECORATE,FINISH")?
.split(',')
.map(str::parse)
.collect::<Result<_, _>>()?;
(ms.len() == 3)
.then_some(ms)
.ok_or_else(|| "--simulate PROTO,DECORATE,FINISH".into())
})
.transpose()?;
let threads: Vec<usize> = flag(&args, "--threads", String::from("1,2,4,8"))
.split(',')
.map(str::parse)
.collect::<Result<_, _>>()?;
let center = args
.iter()
.position(|a| a == "--center")
.map(|i| -> Result<(i32, i32), Error> {
Ok((
args.get(i + 1).ok_or("--center CX CZ")?.parse()?,
args.get(i + 2).ok_or("--center CX CZ")?.parse()?,
))
})
.transpose()?
.unwrap_or((-60, 10));
// The pool is made once per process with its size: one process per
// worker count and repeat, each given the same flags but one count.
if threads.len() > 1 || repeat > 1 {
let exe = std::env::current_exe()?;
let mut rest = Vec::new();
let mut i = 1;
while i < args.len() {
if args[i] == "--threads" || args[i] == "--repeat" {
i += 2;
continue;
}
rest.push(args[i].clone());
i += 1;
}
for _ in 0..repeat.max(1) {
for &n in &threads {
let status = Command::new(&exe)
.args(&rest)
.args(["--threads", &n.to_string()])
.env("HORIZON_JOB_THREADS", n.to_string())
.status()?;
if !status.success() {
return Err(format!("the run with {n} workers failed: {status}").into());
}
}
}
return Ok(());
}
let n = threads[0];
horizon_threads::jobs::configure(n);
let assets = Arc::new(Assets::load(&root)?);
let render = Arc::new(assets.reload(&root)?);
let facts = BlockFacts::load(&root, assets.registry)?;
let visibility = models::load_visibility(&root, assets.registry)?;
let biomes = Arc::new(Biomes::load(&root)?);
let context = Arc::new(WorkerContext {
tables: Arc::new(MeshTables::new(&assets, &render, &facts, visibility)?),
light: Arc::new(LightTables::load(&root, assets.registry, &facts)?),
biomes: biomes.clone(),
assets: assets.clone(),
render,
});
let terrain = || VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed);
let go = |center, radius, n, loading| -> Result<Run, Error> {
match &simulate {
Some(ms) => {
let ms = |i: usize| Duration::from_secs_f64(ms[i] / 1e3);
let generator = Simulated {
proto: ms(0),
decorate: ms(1),
finish: ms(2),
};
run(generator, &context, center, radius, n, period, loading)
}
None => run(terrain()?, &context, center, radius, n, period, loading),
}
};
// Warm the shared generator data and the code away from the measured area.
go((center.0 + 4000, center.1), 2, n, false)?;
let pool = horizon_threads::jobs::pool().threads();
if pool != n {
eprintln!("the worker pool has {pool} threads, not {n} (HORIZON_JOB_THREADS?)");
}
let r = go(center, radius, n, loading)?;
let busy: f64 = r.jobs.iter().map(|j| j.1 as f64).sum::<f64>() / 1e9;
println!(
"workers {n:2}: {:.2} s ({:.2} s to half), {} columns ({} lit), {:.1} columns/s, {:.1} lit/s, \
{:.1} ms CPU/column, coordinator {:.2} s CPU in {} updates, workers busy {:.0}%, checksum {:016x}",
r.wall,
r.half,
r.columns,
r.lit,
r.columns as f64 / r.wall,
r.lit as f64 / r.wall,
r.cpu * 1e3 / r.columns.max(1) as f64,
r.coordinator,
r.updates,
busy * 100.0 / (r.wall * n as f64),
r.checksum,
);
let line: Vec<String> = r
.jobs
.iter()
.filter(|j| j.2 > 0)
.map(|(kind, ns, count)| {
format!(
"{kind} {count} x {:.2} ms = {:.2} s",
*ns as f64 / 1e6 / *count as f64,
*ns as f64 / 1e9
)
})
.collect();
println!(" {}", line.join(", "));
if json {
println!(
"BENCH {}",
serde_json::json!({
"workers": n, "wall_s": r.wall, "half_s": r.half, "columns": r.columns,
"lit": r.lit, "cpu_s": r.cpu, "coordinator_s": r.coordinator,
"updates": r.updates, "checksum": format!("{:016x}", r.checksum),
"jobs": r.jobs.iter().map(|(k, ns, c)| serde_json::json!({"kind": k, "s": *ns as f64 / 1e9, "count": c})).collect::<Vec<_>>(),
})
);
}
Ok(())
}
@@ -129,6 +129,7 @@ fn scene(
let mut mesher = Mesher {
nether_shading: row["nether"].as_bool().unwrap_or(false),
shader_pack_lighting: false,
separate_ao: false,
assets,
render,
tables,
+90 -1
View File
@@ -116,6 +116,39 @@ impl Control {
}
}
/// The wait before a scene's flight, spent timing Horizon's own render passes on the GPU:
/// `gpu_pass.<scene>.<pass>_ms`, where a slow GPU spends its frame. The timing is off again for
/// the flight, whose frames it would slow. False, having waited for nothing, on Metal: its passes
/// overlap on the GPU, so their times add up to more than the frame.
fn time_passes(control: &Control, scene: &str, report: &mut Report) -> Result<bool, String> {
if report
.hardware
.gpu
.as_ref()
.is_some_and(|g| g.backend == "metal")
{
return Ok(false);
}
control.call("gpu-passes on")?;
let held = control.call(&format!("bench {}", SETTLE.as_secs_f64()));
control.call("gpu-passes off")?;
for (pass, ms) in held?["gpu_passes"].as_object().into_iter().flatten() {
let pass: String = pass
.trim()
.chars()
.map(|c| match c {
'a'..='z' | '0'..='9' => c,
'A'..='Z' => c.to_ascii_lowercase(),
_ => '_',
})
.collect();
if let Some(ms) = ms.as_f64() {
report.metric(&format!("gpu_pass.{scene}.{pass}_ms"), &[ms]);
}
}
Ok(true)
}
fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
let scenes =
scenes(&setup.scenario).ok_or_else(|| format!("unknown scenario {}", setup.scenario))?;
@@ -204,7 +237,9 @@ fn run(control: &Control, setup: Setup) -> Result<PathBuf, String> {
&[chunks / load.as_secs_f64()],
);
}
thread::sleep(SETTLE);
if !time_passes(control, name, &mut report)? {
thread::sleep(SETTLE);
}
let [ex, ey, ez] = end;
let begin = Instant::now();
let flight = control.call(&format!(
@@ -499,6 +534,60 @@ mod tests {
let _ = server.join();
}
/// The wait before a flight times the passes and turns the timing off again before the
/// flight; the report gets one metric per pass. Not on Metal.
#[test]
fn the_wait_times_the_passes_but_not_the_flight() {
let time_on = |backend: &str| {
let (tx, rx) = mpsc::channel::<Request>();
let server = thread::spawn(move || {
let mut lines = Vec::new();
while let Ok(request) = rx.recv() {
let reply = if request.line.starts_with("bench ") {
serde_json::json!({
"frames": 120,
"gpu_passes": {"World": 1.5, "World after culling": 0.25, "HUD": 0.01},
})
} else {
serde_json::json!({ "ok": true })
};
let _ = request.reply.send(reply.to_string());
lines.push(request.line);
}
lines
});
let hardware = horizon_bench::Hardware {
gpu: Some(horizon_bench::Gpu {
backend: backend.into(),
..Default::default()
}),
..Default::default()
};
let mut report = Report::with_hardware(Kind::Game, App::default(), hardware);
let control = Control(tx);
let timed = time_passes(&control, "forest", &mut report).expect("the wait runs");
drop(control);
(timed, server.join().unwrap(), report.metrics)
};
let (timed, lines, metrics) = time_on("vulkan");
assert!(timed);
assert_eq!(lines, ["gpu-passes on", "bench 2", "gpu-passes off"]);
let mean = |name: &str| metrics.get(name).map(|s| s.mean);
assert_eq!(mean("gpu_pass.forest.world_ms"), Some(1.5));
assert_eq!(mean("gpu_pass.forest.world_after_culling_ms"), Some(0.25));
assert_eq!(mean("gpu_pass.forest.hud_ms"), Some(0.01));
assert_eq!(metrics.len(), 3);
let (timed, lines, metrics) = time_on("metal");
assert!(!timed);
assert!(
lines.is_empty(),
"Metal's overlapping passes are not timed: {lines:?}"
);
assert!(metrics.is_empty());
}
#[test]
fn yaw_matches_vanilla() {
// South (+Z) is 0, west (-X) 90, east (+X) -90.
@@ -204,6 +204,9 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("events", Observe, "events [--since SEQ]", "what happened since SEQ, oldest first: screens, chat, deaths, respawns, items gained or lost, dimensions, worlds",
"Every event has seq and tick; pass the reply's last as --since next time. The game keeps the newest 256.",
["horizon-ctl events", "horizon-ctl events --since 42"], &[flag("since", Ty::Int, "only events after this seq")], mcp: true, local: false),
cmd!("input-log", Observe, "input-log [--since SEQ] [--limit N] [--origin user|agent|game]", "recent input and who caused it: a person, an agent or the game",
"Every event has seq, tick and origin. `user` is the window's own keyboard, mouse or pad; `agent` is the control socket (this tool); `game` is input the game made up. A `user-input` event in `events` means a person stepped in while an agent was driving. The game keeps the newest 512.",
["horizon-ctl input-log", "horizon-ctl input-log --origin user --limit 20", "horizon-ctl input-log --since 120"], &[flag("since", Ty::Int, "only events after this seq"), flag("limit", Ty::Int, "newest N events"), flag("origin", Ty::Enum(&["user", "agent", "game"]), "user, agent or game")], mcp: true, local: false),
cmd!("wait-ready", Session, "wait-ready [SECONDS]", "wait until the player has spawned and no chunks are pending",
"Polls status every 250 ms; replies with the status, or an error after SECONDS (default 120).",
["horizon-ctl wait-ready 300"], &[opt("seconds", Ty::Num, "how long to wait (default 120)")], mcp: true, local: true),
@@ -217,6 +220,8 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("hello", Session, "hello", "the control protocol's version, the game's release and the command groups it serves",
"Replies protocol (MAJOR.MINOR.PATCH of the control protocol, docs/api/stability.md), release (the game's version, the string MCP serverInfo carries too) and features (the command groups the game answers, so a script can test for one before calling it).",
["horizon-ctl hello"], &[], mcp: true, local: false),
cmd!("memory", Session, "memory", "what holds the game's memory: resident set, allocator, client and server columns by part, renderer, GPU memory by resource",
"Columns count blocks, biomes, light, heightmaps and records, each shared section once; server_columns are the single-player server thread's own levels. GPU memory by label needs Vulkan or DirectX 12. What the census doesn't reach is the resident set less its parts.", ["horizon-ctl memory"], &[], mcp: true, local: false),
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
"folder is what open, world-rename, world-backup and world-delete take.", ["horizon-ctl worlds"], &[], mcp: true, local: false),
cmd!("world-rename", Session, "world-rename FOLDER NAME", "rename a world, as Edit World does (its folder stays)",
@@ -452,6 +457,23 @@ pub const COMMANDS: &[Cmd] = &[
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
["horizon-ctl fly 0 100 0 500 100 0 20"], &[], mcp: false, local: false),
// Repro recordings
cmd!("record-start", Scripting, "record-start [NAME] [--no-world]", "record input, state and the world for a bug repro",
"Writes recordings/<stamp>-NAME/: inputs.jsonl (who caused each input: user, agent or game), state.jsonl (the player and world every second), events.jsonl and, in single player, a copy of the world. The recording starts from a fresh save. Replies once the world copy is done.",
["horizon-ctl record-start repro-bug", "horizon-ctl record-start --no-world"], &[opt("name", Ty::Str, "folder name after the timestamp (default session)"), flag("no-world", Ty::Bool, "do not copy the world (a server session)")], mcp: true, local: false),
cmd!("record-stop", Scripting, "record-stop [--export]", "stop the recording (and write the repro zip)",
"Without --export only the recording folder is closed; repro-export zips it later. With --export the reply carries the zip path.",
["horizon-ctl record-stop", "horizon-ctl record-stop --export"], &[flag("export", Ty::Bool, "write the repro bundle too")], mcp: true, local: false),
cmd!("record-status", Scripting, "record-status", "the running recording and replay, if any", "",
["horizon-ctl record-status"], &[], mcp: true, local: false),
cmd!("record-list", Scripting, "record-list", "the recordings and repro bundles on disk", "", ["horizon-ctl record-list"], &[], mcp: true, local: false),
cmd!("record-replay", Scripting, "record-replay NAME [--stop] [--in-place] [--keep-world] [--timeout S]", "replay a recording, best effort",
"Loads the recording's world copy into saves/, opens it and feeds the recorded input back at its ticks. Only input and movement commands replay (never world, network or account ones). --in-place feeds the input into the world already open (a server recording needs it); --keep-world leaves the copied world behind when it ends. --stop ends a running replay. Same build and machine only; state.jsonl is the oracle, not a promise.",
["horizon-ctl record-replay repro-bug", "horizon-ctl record-replay repro-bug --stop", "horizon-ctl record-replay server-fight --in-place"],
&[opt("name", Ty::Str, "recording from record-list"), flag("stop", Ty::Bool, "stop the running replay"), flag("in-place", Ty::Bool, "feed the open world instead of opening the copy"), flag("keep-world", Ty::Bool, "leave the copied world in saves/"), flag("timeout", Ty::Int, "give up after this many seconds (default 300)")], mcp: true, local: false),
cmd!("repro-export", Scripting, "repro-export [NAME] [PATH] [--no-world]", "zip a recording with the logs, crash report and status",
"Writes repro/<stamp>-NAME.zip: the recording, logs/latest.log, the newest crash report, a status.json and a README. The player attaches it to the bug report; nothing is uploaded. Stops a running recording first.",
["horizon-ctl repro-export", "horizon-ctl repro-export repro-bug out.zip"], &[opt("name", Ty::Str, "recording from record-list (default the newest)"), opt("path", Ty::Str, "where to write the zip"), flag("no-world", Ty::Bool, "leave the world out")], mcp: true, local: false),
];
pub fn find(name: &str) -> Option<&'static Cmd> {
@@ -314,13 +314,26 @@ fn usage(name: &str) -> String {
commands::find(name).map_or_else(|| name.to_owned(), |c| format!("horizon-ctl {}", c.usage))
}
/// Screenshot paths are written by the game process: make them absolute here.
/// Paths are written by the game process: make them absolute here.
fn absolute_paths(words: &mut [String]) {
if words.first().map(String::as_str) == Some("screenshot")
&& let Some(path) = words.get_mut(1)
&& let Ok(cwd) = std::env::current_dir()
{
*path = cwd.join(&*path).display().to_string();
let Ok(cwd) = std::env::current_dir() else {
return;
};
match words.first().map(String::as_str) {
Some("screenshot") => {
if let Some(path) = words.get_mut(1) {
*path = cwd.join(&*path).display().to_string();
}
}
// `repro-export [NAME] [PATH]`: the second positional is the zip.
Some("repro-export") => {
if let Some(path) = words.get_mut(2)
&& (path.contains('/') || path.ends_with(".zip"))
{
*path = cwd.join(&*path).display().to_string();
}
}
_ => {}
}
}
@@ -4,20 +4,14 @@
//! shared table, run through the same `Session` as the CLI.
use crate::{Session, commands};
use serde_json::{Value, json};
use std::io::{BufRead, Write};
/// Revisions this server implements. Older ones are not claimed: only the revision actually
/// implemented is advertised (no JSON-RPC batches, no other capability than `tools`).
const SUPPORTED: &[&str] = &["2025-06-18"];
/// What an agent reads before its first call.
const INSTRUCTIONS: &str = "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).";
/// MCP lifecycle: answer with the client's version if supported, otherwise one we support.
fn negotiate_version(params: &Value) -> &'static str {
let wanted = params.get("protocolVersion").and_then(Value::as_str);
SUPPORTED
.iter()
.copied()
.find(|v| Some(*v) == wanted)
.unwrap_or(SUPPORTED[0])
/// The revisions this server advertises: `horizon_mcp` serves them (`2026-07-28` stateless,
/// `2025-06-18` for the clients that still open with `initialize`).
fn server() -> horizon_mcp::Server<'static> {
horizon_mcp::Server::new("horizon-ctl", horizon_core::release::VERSION, INSTRUCTIONS)
}
fn tools() -> Vec<Value> {
@@ -143,15 +137,10 @@ impl Session {
}
}
/// The two tool methods; `horizon_mcp` answers the protocol's own (`initialize`,
/// `server/discover`) and frames these results for the revision the request speaks.
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => Ok(json!({
"protocolVersion": negotiate_version(params),
"capabilities": { "tools": {} },
"serverInfo": { "name": "horizon-ctl", "version": horizon_core::release::VERSION },
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
})),
"ping" => Ok(json!({})),
"tools/list" => Ok(json!({ "tools": tools() })),
"tools/call" => {
let name = params
@@ -171,52 +160,13 @@ impl Session {
}
pub fn serve(session: &mut Session) -> Result<(), String> {
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
let reply = match serde_json::from_str::<Value>(&line) {
Err(e) => Some(
json!({ "jsonrpc": "2.0", "id": null, "error": { "code": -32700, "message": e.to_string() } }),
),
Ok(msg) => {
let method = msg
.get("method")
.and_then(Value::as_str)
.unwrap_or_default();
let params = msg.get("params").cloned().unwrap_or(json!({}));
// Notifications (no id) get no reply.
msg.get("id").cloned().map(|id| match session.handle(method, &params) {
Ok(result) => json!({ "jsonrpc": "2.0", "id": id, "result": result }),
Err((code, message)) => json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }),
})
}
};
if let Some(reply) = reply {
writeln!(stdout, "{reply}")
.and_then(|_| stdout.flush())
.map_err(|e| e.to_string())?;
}
}
Ok(())
server().serve(|method, params| session.handle(method, params))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_negotiation() {
let v = |p: Value| negotiate_version(&p);
assert_eq!(v(json!({ "protocolVersion": "2025-06-18" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "2026-07-28" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "1999-01-01" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": 7 })), "2025-06-18");
assert_eq!(v(json!({})), "2025-06-18");
}
#[test]
fn base64_matches_rfc_4648() {
@@ -255,28 +205,66 @@ mod tests {
#[test]
fn protocol_basics() {
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-mcp-test.sock"), None);
let init = s
.handle("initialize", &json!({ "protocolVersion": "2025-06-18" }))
.unwrap();
assert_eq!(init["protocolVersion"], "2025-06-18");
assert!(s.handle("nope", &json!({})).is_err());
let r = s
.handle(
"tools/call",
&json!({ "name": "goto", "arguments": { "x": 1 } }),
let mut app = |method: &str, params: &Value| s.handle(method, params);
let server = server();
let line = |method: &str, params: Value| {
json!({ "jsonrpc": "2.0", "id": 1, "method": method, "params": params }).to_string()
};
// A client that opens with the handshake revision.
let init = server
.handle_line(
&line("initialize", json!({ "protocolVersion": "2025-06-18" })),
&mut app,
)
.unwrap();
assert_eq!(r["isError"], true);
assert_eq!(init["result"]["protocolVersion"], "2025-06-18");
assert_eq!(init["result"]["serverInfo"]["name"], "horizon-ctl");
// A modern client: stateless, and the same tools.
let mut meta = serde_json::Map::new();
meta.insert(
horizon_mcp::VERSION_KEY.to_owned(),
json!(horizon_mcp::MODERN),
);
meta.insert(horizon_mcp::CLIENT_CAPABILITIES_KEY.to_owned(), json!({}));
let params = json!({ "_meta": Value::Object(meta) });
let list = server
.handle_line(&line("tools/list", params), &mut app)
.unwrap();
assert_eq!(list["result"]["resultType"], "complete");
assert!(list["result"]["tools"].as_array().unwrap().len() > 3);
let r = server
.handle_line(
&line(
"tools/call",
json!({ "name": "goto", "arguments": { "x": 1 } }),
),
&mut app,
)
.unwrap();
assert_eq!(r["result"]["isError"], true);
assert!(
r["content"][0]["text"]
r["result"]["content"][0]["text"]
.as_str()
.unwrap()
.contains("y is required")
);
// No game: a connection error, reported as a tool error.
let r = s
.handle("tools/call", &json!({ "name": "state", "arguments": {} }))
let r = server
.handle_line(
&line("tools/call", json!({ "name": "state", "arguments": {} })),
&mut app,
)
.unwrap();
assert_eq!(r["isError"], true);
assert_eq!(r["result"]["isError"], true);
// Methods this server does not have are the protocol's to refuse.
assert_eq!(
server
.handle_line(&line("nope/nope", json!({})), &mut app)
.unwrap()["error"]["code"],
json!(-32601)
);
}
}
+1 -1
View File
@@ -36,7 +36,7 @@ pub fn native_living_breathe(
consume: &mut i32,
refill: &mut i32,
) {
let _ = (entity, kind, can_breathe, consume, refill);
crate::native_client_hooks::living_breathe(entity, kind, can_breathe, consume, refill);
}
/// `vanilla`'s breathing after the mods have had their say.
+4 -3
View File
@@ -18,9 +18,9 @@ pub use horizon_client_render::{
native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen, packs, par,
particles, poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw,
screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins,
sound_engine, sound_events, sound_preview, spans, split_layout, split_profiles, sprites,
streaming, structure_block, tab_list, telemetry, test_block, test_instance_block, testgen,
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
sound_engine, sound_events, sound_preview, spans, split_layout, split_map, split_profiles,
sprites, streaming, structure_block, tab_list, telemetry, test_block, test_instance_block,
testgen, title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
@@ -89,6 +89,7 @@ pub mod mod_content;
pub mod motion;
pub mod multiplayer;
pub mod music;
pub mod native_client_hooks;
pub mod native_data;
pub mod net;
pub mod nether_portal;
+1 -1
View File
@@ -33,7 +33,7 @@ pub fn native_ingredient(
data: &serde_json::Map<String, Value>,
items: &mut Vec<String>,
) {
let _ = (kind, data, items);
crate::native_client_hooks::ingredient(kind, data, items);
}
/// Keep first-seen order, drop the rest.
+17
View File
@@ -28,6 +28,10 @@ pub enum DebugAction {
NetworkCharts,
LightmapTexture,
ImprovedTransparency,
/// F3+R: start a repro recording, or stop it and write its bundle.
RecordSession,
/// F3+E: write the bundle now.
ExportRepro,
}
/// The mappings `handleDebugKeys` tests, in its order.
@@ -69,6 +73,14 @@ pub const ACTIONS: [(&str, DebugAction); 20] = [
),
];
/// Horizon's own debug keys: fixed, so they never enter the options table that
/// vanilla parity compares (`MAPPINGS`). Off until the AI Agent page enables
/// them; `Game` decides that in `debug_action`.
const HORIZON_ACTIONS: [(&str, DebugAction); 2] = [
("key.keyboard.r", DebugAction::RecordSession),
("key.keyboard.e", DebugAction::ExportRepro),
];
/// GLFW's key actions.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KeyAction {
@@ -215,6 +227,11 @@ impl DebugKeys {
did |= host.debug_action(action, shift);
}
}
for (name, action) in HORIZON_ACTIONS {
if key == name {
did |= host.debug_action(action, shift);
}
}
did
}
/// `KeyboardHandler.tick`.
+47
View File
@@ -3725,6 +3725,10 @@ pub fn apply_item_components(m: &mut Mob, stack: &crate::items::ItemStack) {
),
// ZombieNautilus.applyImplicitComponents: the variant only.
crate::mobs::Species::ZombieNautilus => (Some("minecraft:zombie_nautilus/variant"), None),
// Fox and Frog.applyImplicitComponent: `setVariant` from the component
// alone, so a spawn egg carrying one hatches that variant.
crate::mobs::Species::Fox => (Some("minecraft:fox/variant"), None),
crate::mobs::Species::Frog => (Some("minecraft:frog/variant"), None),
_ => (None, None),
};
if let Some(name) = variant.and_then(|id| text(components.other(id))) {
@@ -4581,6 +4585,49 @@ mod item_component_tests {
assert_eq!(nautilus.extra.variant, 1, "minecraft:warm, not temperate");
}
/// `Fox.applyImplicitComponent` and `Frog.applyImplicitComponent`: the
/// variant component alone decides the mob, `setVariant` from the egg.
#[test]
fn a_fox_or_frog_spawn_egg_picks_its_variant_component() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let spawn = |kind: &str, id: u32| {
Mob::new(
id,
Arc::new(data.types[kind].clone()),
Vec3::new(0.0, 4.0, 0.0),
0.0,
)
};
// A red fox, as a spawn in a forest leaves it.
let mut fox = spawn("minecraft:fox", 1);
assert_eq!(fox.extra.variant, 0);
let mut egg = crate::items::ItemStack::new("minecraft:fox_spawn_egg", 1);
egg.data_mut()
.set_other("minecraft:fox/variant", Some(string("minecraft:snow")));
apply_item_components(&mut fox, &egg);
assert_eq!(fox.extra.variant, 1, "Fox.Variant.SNOW, not red");
// A temperate frog, as a swamp spawn leaves it.
let mut frog = spawn("minecraft:frog", 2);
assert_eq!(frog.extra.variant, 0);
let mut egg = crate::items::ItemStack::new("minecraft:frog_spawn_egg", 1);
egg.data_mut()
.set_other("minecraft:frog/variant", Some(string("minecraft:warm")));
apply_item_components(&mut frog, &egg);
assert_eq!(frog.extra.variant, 1, "minecraft:warm, not temperate");
// An unknown name falls back to the codec's default, red.
let mut egg = crate::items::ItemStack::new("minecraft:fox_spawn_egg", 1);
egg.data_mut()
.set_other("minecraft:fox/variant", Some(string("minecraft:temperate")));
apply_item_components(&mut fox, &egg);
assert_eq!(fox.extra.variant, 0, "unknown names read as red");
}
/// `EntityType.updateCustomEntityTag`: the egg's `entity_data` on the mob it
/// spawned, when the type is its own — the mob's compound, the data merged
/// over it and loaded back, as the spawn egg site does it.
+47
View File
@@ -74,12 +74,59 @@ pub fn main_with(mods: crate::mod_api::Mods) {
components,
);
},
loot_function: |kind, data, ctx, stack| {
crate::mod_api::dispatch_loot_function(
crate::mod_api::mods(),
kind,
data,
ctx,
stack,
);
},
loot_condition: |kind, data, ctx, holds| {
crate::mod_api::dispatch_loot_condition(
crate::mod_api::mods(),
kind,
data,
ctx,
holds,
);
},
},
)
.is_err()
{
log::warn!("Native data-load hooks already installed; keeping the first callbacks");
}
if crate::native_client_hooks::install(crate::native_client_hooks::NativeClientHooks {
menu_slot_rules: |menu, slot, stack, may_place, max_stack| {
crate::mod_api::dispatch_menu_slot_rules(
crate::mod_api::mods(),
menu,
slot,
stack,
may_place,
max_stack,
);
},
living_breathe: |entity, kind, can_breathe, consume, refill| {
crate::mod_api::dispatch_living_breathe(
crate::mod_api::mods(),
entity,
kind,
can_breathe,
consume,
refill,
);
},
ingredient: |kind, data, items| {
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
},
})
.is_err()
{
log::warn!("Native client hooks already installed; keeping the first callbacks");
}
raise_open_file_limit();
if let Err(error) = game::run_with() {
// A bad command line is the caller's mistake, not a crash: no report.
File diff suppressed because it is too large. Load diff
+34 -4
View File
@@ -10,6 +10,7 @@ use horizon_client::{
toasts::Slide,
};
use horizon_core::geometry::Vec3;
use horizon_protocol::packets::play::Clientbound as C;
use serde_json::Value;
use std::{
collections::{BTreeMap, BTreeSet, VecDeque},
@@ -626,18 +627,44 @@ impl Game<'_> {
/// After `ServerRecipeBook.addRecipes`: the toast and the
/// `recipe_unlocked` trigger of each recipe now known.
pub(super) fn recipe_unlocked(&mut self, unlocked: &[String]) {
let mut allowed = Vec::with_capacity(unlocked.len());
let mut refused = Vec::new();
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
for recipe in unlocked {
let mut cancel = false;
self.native_player_recipe_discovered(player, &name, recipe, &mut cancel);
if cancel {
refused.push(recipe.clone());
} else {
allowed.push(recipe.clone());
}
}
if !refused.is_empty() {
let removed = self
.level
.recipe_book
.remove_recipes(&world_stations!(self).recipe_index, &refused);
if self.lan_acting.is_some() && !removed.is_empty() {
self.guest_packets.push(C::RecipeBookRemove(
horizon_protocol::packets::recipes::RecipeBookRemove { ids: removed },
));
}
}
} else {
allowed.extend_from_slice(unlocked);
}
if self.lan_acting.is_some() {
// A network player's client gets them in ClientboundRecipeBookAddPacket.
self.adv.unlocked.extend_from_slice(unlocked);
self.adv.unlocked.extend_from_slice(&allowed);
} else {
self.recipe_toast(unlocked);
self.recipe_toast(&allowed);
}
if unlocked.is_empty() || self.remote.is_some() || self.no_player_here() {
if allowed.is_empty() || self.remote.is_some() || self.no_player_here() {
return;
}
self.load_advancements();
let tree = self.adv.tree.clone();
for r in unlocked {
for r in &allowed {
let list = tree.recipe_criteria(r);
self.trigger_criteria(&tree, &list, |c, _| {
c["recipes"].as_str() == Some(r.as_str())
@@ -681,6 +708,9 @@ impl Game<'_> {
fn award(&mut self, index: usize) {
let tree = self.adv.tree.clone();
let a = &tree.list[index];
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_advancement_done(player, &name, &a.id);
}
// AdvancementRewards.grant
if a.rewards.experience > 0 {
self.experience.give(a.rewards.experience);
+31 -8
View File
@@ -466,7 +466,7 @@ impl Game<'_> {
json!({ "kind": kind, "block": block, "entity": entity })
}
fn state_value(&self) -> Value {
pub(super) fn state_value(&self) -> Value {
let player = self.player.as_ref().map(|p| {
json!({
"position": [p.position.x, p.position.y, p.position.z],
@@ -497,10 +497,10 @@ impl Game<'_> {
"effects": self.potions.effects.list.iter().map(|e| json!({ "id": e.id, "duration_ticks": e.duration, "amplifier": e.amplifier })).collect::<Vec<_>>(),
})
});
json!({
let mut state = json!({
"dialog": self.dialog_status(),
"screen": self.screen_label(),
"menu": (self.title.is_some() || self.split_profiles_open()).then(|| self.title_value()),
"menu": (self.title.is_some() || self.split_profiles_open() || self.split_setup_open()).then(|| self.title_value()),
"world": (self.title.is_none()).then(|| match &self.integrated {
Some(server) => server.world_name.clone(),
None => self.level.name.clone(),
@@ -527,7 +527,13 @@ impl Game<'_> {
"looking_at": self.looking_at(),
"busy": self.agent.task.as_ref().map(|t| t.name),
"ticks": self.ticks,
})
});
// Who caused the input, and whether a repro recording runs.
let mut input = self.input_trace.summary();
self.repro
.fill_summary(&mut input, self.input_trace.events());
state["input"] = input;
state
}
/// Splitscreen's profile page, a joining player's (`split_profiles`).
@@ -535,11 +541,18 @@ impl Game<'_> {
self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitProfiles
}
fn split_setup_open(&self) -> bool {
self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitSetup
}
fn title_value(&self) -> Value {
use horizon_client::title::Mode;
if self.split_profiles_open() {
return self.ui.options.split_profiles.state();
}
if self.split_setup_open() {
return json!({ "mode": "splitscreen-setup", "status": self.ui.options.split_setup.status() });
}
let Some(title) = &self.title else {
return Value::Null;
};
@@ -657,7 +670,7 @@ impl Game<'_> {
}
}
fn entities_value(&self, radius: f64, kind: Option<&str>) -> Value {
pub(super) fn entities_value(&self, radius: f64, kind: Option<&str>) -> Value {
let Some(p) = &self.player else {
return json!({ "entities": [] });
};
@@ -711,7 +724,7 @@ impl Game<'_> {
json!({ "entities": list.into_iter().map(|(_, v)| v).collect::<Vec<_>>() })
}
fn inventory_value(&self) -> Value {
pub(super) fn inventory_value(&self) -> Value {
let armor = ["head", "chest", "legs", "feet"];
json!({
"selected": self.selected + 1,
@@ -742,7 +755,7 @@ impl Game<'_> {
v["chat_input"] = json!(chat.input.value);
v["chat_suggestions"] = json!(chat.suggestions_visible());
}
if self.title.is_some() || self.split_profiles_open() {
if self.title.is_some() || self.split_profiles_open() || self.split_setup_open() {
v["menu"] = self.title_value();
}
if self.screen == Screen::Inventory && self.title.is_none() {
@@ -1002,6 +1015,7 @@ impl Game<'_> {
device_id,
position,
},
super::input_trace::InputOrigin::Agent,
);
if i == 0 {
self.handle_window_event(
@@ -1012,6 +1026,7 @@ impl Game<'_> {
state: ElementState::Pressed,
button,
},
super::input_trace::InputOrigin::Agent,
);
}
}
@@ -1023,6 +1038,7 @@ impl Game<'_> {
state: ElementState::Released,
button,
},
super::input_trace::InputOrigin::Agent,
);
Ok(())
}
@@ -1043,6 +1059,11 @@ impl Game<'_> {
Ok(Handled::Deferred)
}
/// A deferred agent action (goto, break, wait...) is still running.
pub(super) fn agent_busy(&self) -> bool {
self.agent.task.is_some()
}
/// Agent commands; `Handled::No` when `words[0]` is not one of them.
pub(super) fn agent_command(
&mut self,
@@ -1582,6 +1603,7 @@ impl Game<'_> {
device_id,
position,
},
super::input_trace::InputOrigin::Agent,
);
for _ in 0..steps.abs() as u32 {
self.handle_window_event(
@@ -1592,6 +1614,7 @@ impl Game<'_> {
delta: MouseScrollDelta::LineDelta(0.0, steps.signum()),
phase: TouchPhase::Moved,
},
super::input_trace::InputOrigin::Agent,
);
}
ok(json!({ "ok": true, "screen": self.screen_label() }))
@@ -1705,7 +1728,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
let chat = server
.call(move |g| g.owner_console_command(&command))
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return ok(json!({ "ok": true, "chat": chat }));
}
if self.remote.is_some() {
@@ -96,11 +96,15 @@ impl Game<'_> {
.to_string_lossy()
.into_owned(),
});
status_lines(
let mut lines = status_lines(
server,
self.agent_last.map(|t| t.elapsed().as_secs()),
self.options.control.is_some() && !self.agent_control_on,
)
);
if let Some(line) = self.repro.agent_line(self.input_trace.events()) {
lines.push(line);
}
lines
}
/// What Copy Connection Details puts on the clipboard: the socket, a horizon-ctl line to try,
@@ -600,6 +600,23 @@ impl Game<'_> {
if held.is_none() && current.is_none() {
return false;
}
// A server's mods hear the swap before it happens, and may stop it.
// A stack of several on an occupied slot goes nowhere (below).
let moves = held
.as_ref()
.is_none_or(|h| h.count <= 1 || current.is_none());
if moves
&& target.is_some()
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
let stand = self.armor_stands[i].id as i32;
let slot_name = ["mainhand", "offhand", "feet", "legs", "chest", "head"][slot];
let mut cancel = false;
self.native_armor_stand_manipulate(player, &name, stand, slot_name, &mut cancel);
if cancel {
return true;
}
}
// ArmorStand.swapItem.
match held {
// A player with infinite materials puts a copy of one on an empty
+5
View File
@@ -130,8 +130,13 @@ impl Game<'_> {
let Some(BlockEntity::Beacon(mut b)) = self.world.block_entities.take(&key) else {
return;
};
let previous = b.levels;
self.tick_beacon(key, &mut b, false);
let current = b.levels;
self.world.block_entities.put(key, BlockEntity::Beacon(b));
if (previous == 0) != (current == 0) {
self.native_beacon_state_change(self.dim.id(), [key.0, key.1, key.2], current > 0);
}
}
/// `ClientLevel.tickBlockEntities` for beacons: a server's chunk data
/// carries no beam sections, so the client builds them too; effects,
+12 -2
View File
@@ -150,8 +150,18 @@ impl Game<'_> {
self.world
.block_entities
.put(key, BlockEntity::Beehive(hive));
if stored && let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
m.removed = true;
if stored {
let kind = self.mobs[i].kind.id.clone();
let block = self
.res
.facts()
.get(self.world.block(at[0], at[1], at[2]))
.name
.clone();
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
m.removed = true;
}
self.native_entity_enter_block(mob, &kind, at, &block);
}
}
/// `BeehiveBlockEntity.emptyAllLivingFromHive` with no player about.
@@ -714,6 +714,16 @@ impl Game<'_> {
}
/// `EndGatewayBlock.entityInside` on the server: whether the gateway at
/// `pos` let the entity in (`triggerCooldown`, with its block event).
pub(super) fn gateway_available(&self, pos: [i32; 3]) -> bool {
self.be_anims
.gateway_servers
.get(&pos)
.copied()
.unwrap_or_else(|| self.gateway_loaded(pos))
.teleport_cooldown
<= 0
}
pub(super) fn gateway_enter(&mut self, pos: [i32; 3]) -> bool {
let initial = self.gateway_loaded(pos);
let entered = self
@@ -215,16 +215,51 @@ impl Game<'_> {
/// `AbstractFurnaceBlockEntity.serverTick` of the furnace at `pos`, and
/// its block's `lit` following it.
fn tick_furnace(&mut self, pos: Pos) -> Result<()> {
let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else {
return Ok(());
// What the hooks need from before the tick, without cloning stacks: the
// fuel's id only while the furnace is out (it can light this tick), the
// input's id (a last item is gone after it smelts) and the result count.
let before = match self.world.block_entities.get(&pos) {
Some(BlockEntity::Furnace(f)) => (
f.cooking_timer,
(f.lit_remaining == 0)
.then(|| f.items[1].as_ref().map(|s| s.item.clone()))
.flatten(),
f.items[0].as_ref().map(|s| s.item.clone()),
f.items[2].as_ref().map_or(0, |s| s.count),
),
_ => return Ok(()),
};
let mut drops = Vec::new();
let lit = f.tick(
world_recipes!(self),
world_items!(self),
self.res.providers(),
&mut drops,
);
let (lit, burning, smelted, smelting) = {
let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else {
return Ok(());
};
let lit = f.tick(
world_recipes!(self),
world_items!(self),
self.res.providers(),
&mut drops,
);
let burning = before.1.filter(|_| f.lit_remaining > 0);
let smelted = f.items[2]
.as_ref()
.filter(|after| after.count > before.3)
.map(|after| (before.2.clone(), after.item.clone()));
let started = before.0 == 0 && f.cooking_timer > 0;
let smelting = started
.then(|| f.items[0].as_ref().map(|item| item.item.clone()))
.flatten();
(lit, burning, smelted, smelting)
};
if let Some(item) = smelting {
self.native_furnace_start_smelt([pos.0, pos.1, pos.2], &item);
}
if let Some(fuel) = burning {
self.native_furnace_burn([pos.0, pos.1, pos.2], &fuel);
}
if let Some((Some(item), result)) = smelted {
self.native_furnace_smelt([pos.0, pos.1, pos.2], &item, &result);
}
for stack in drops {
self.campfire_drop(pos, stack);
}
+1 -1
View File
@@ -76,7 +76,7 @@ impl Game<'_> {
let words: Vec<&str> = owned.iter().map(String::as_str).collect();
g.in_owner_level(|g| g.block_command(&words).map_err(|e| e.to_string()))
})
.ok_or("the integrated server stopped")?
.ok_or(super::integrated::NO_ANSWER)?
.map_err(Into::into);
}
// Another server's world changes only through its own commands, which
+72 -3
View File
@@ -146,10 +146,22 @@ impl Game<'_> {
}
ClickEvent::OpenUrl(url) => self.click_url(url),
// closeContainerOnServer, then clickCommandAction with no screen
// after the command: the book closes and the command runs.
// after the command: the book closes and the command runs. A
// lectern's screen goes with its menu; a book is still the screen
// a confirmation returns to.
ClickEvent::RunCommand(command) => {
let book = if lectern.is_none() {
self.book.take()
} else {
None
};
self.close_book();
self.click_command(&command);
if let Some(book) = book {
self.command_prompt_returns_to(super::remote_chat::Behind::Book(Box::new(
book,
)));
}
}
// Screen.defaultHandleClickEvent: copied, the book stays open.
ClickEvent::CopyToClipboard(value) => {
@@ -334,9 +346,40 @@ impl Game<'_> {
{
return;
}
self.as_guest(host, conn, |g| {
g.write_book(p.slot as usize, p.pages, p.title)
let menu_slot = if p.slot == super::shield::OFFHAND_SLOT as i32 {
45
} else {
36 + p.slot as usize
};
let cancelled = self.as_guest(host, conn, |g| {
let mut cancel = false;
let writable = g
.stack_in(p.slot as usize)
.is_some_and(|stack| &*stack.item == "minecraft:writable_book");
if writable
&& let (Some(player), Some(name)) = (g.lan_acting_uuid, g.lan_acting_name.clone())
{
g.native_player_edit_book(
player,
&name,
p.pages.len() as i32,
p.title.is_some(),
&mut cancel,
);
}
if cancel {
return true;
}
g.write_book(p.slot as usize, p.pages, p.title);
false
});
// A cancelled edit leaves the client's own copy of the book: forget
// what was sent so the next broadcast resends the server's stack.
if cancelled == Some(true)
&& let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
{
g.menu_sent[menu_slot] = None;
}
}
pub(super) fn book_key(&mut self, event: &KeyEvent) {
if event.state != ElementState::Pressed {
@@ -811,6 +854,9 @@ impl Game<'_> {
self.audio
.play("minecraft:item.book.put", Some(at), 1.0, 1.0);
self.container_changed(key);
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_insert_lectern_book(player, &name, pos, &book.item);
}
true
}
/// `LecternBlock.openScreen`: reading the lectern's book.
@@ -874,7 +920,21 @@ impl Game<'_> {
/// lectern, so the screen closes (the server closes the menu once the
/// book is gone; a server's client closes at once rather than wait).
fn take_lectern_book(&mut self, key: (i32, i32, i32)) {
let before = match self.world.block_entities.get(&key) {
Some(BlockEntity::Lectern(l)) => l.book.clone(),
_ => None,
};
self.lectern_button(key, 3);
if let (Some(book), Some(player), Some(name)) = (
before.filter(|_| {
matches!(self.world.block_entities.get(&key),
Some(BlockEntity::Lectern(l)) if l.book.is_none())
}),
self.lan_acting_uuid,
self.lan_acting_name.clone(),
) {
self.native_player_take_lectern_book(player, &name, [key.0, key.1, key.2], &book.item);
}
if self.may_build()
&& matches!(
self.book,
@@ -1023,6 +1083,15 @@ impl Game<'_> {
self.lectern_pulses.push((key, 2));
self.level_event_at(1043, [key.0, key.1, key.2], 0);
self.container_changed(key);
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_lectern_page_change(
player,
&name,
[key.0, key.1, key.2],
menu.data[0],
);
}
}
}
/// The open lectern screen follows its block entity (`LecternScreen`'s
@@ -48,6 +48,7 @@ impl Game<'_> {
_ => false,
};
if placed {
self.native_campfire_start(pos, &held.item);
// ItemStack.consumeAndReturn: creative keeps the item.
self.consume_held();
self.stats.custom("interact_with_campfire", 1);
@@ -102,6 +103,7 @@ impl Game<'_> {
.recipes()
.cook(KIND, &item)
.map_or_else(|| item.clone(), |r| r.result.clone());
self.native_block_cook([pos.0, pos.1, pos.2], &item.item, &result.item);
self.campfire_drop(pos, result);
self.mark_block_entity_updated(pos);
self.post_block_game_event(
+108 -18
View File
@@ -1,7 +1,7 @@
//! The dedicated server's chat filter, game side (docs/chat-filter.md): what the hub refused is
//! written to `logs/chat-filter.log` and told to horizon-server's event stream as `chat_filtered`
//! (never as `chat`), a temporary ban goes into banned-players.json, and operators lift mutes with
//! `/unmute <player>` (or `unmute` on the console).
//! (never as `chat`), a temporary ban goes into banned-players.json, and operators mute and unmute
//! players by hand with `/mute`, `/tempmute`, `/mutelist` and `/unmute` (the same words on the console).
use super::{Game, dedicated, lan::LanHost};
use horizon_chat_filter::Action;
use horizon_client::lan::hub::{HostEvent, chat_filter::Place};
@@ -9,8 +9,11 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
use horizon_protocol::text::{literal, translatable};
use horizon_server_files::user_lists::{BanEntry, NameAndId};
/// The operator level `/unmute` needs, as `/ban`'s.
const UNMUTE_LEVEL: u8 = 3;
/// The operator level the mute commands need, as `/ban`'s.
const MUTE_LEVEL: u8 = 3;
/// The commands of `mute_command`.
pub(super) const MUTE_COMMANDS: [&str; 4] = ["mute", "tempmute", "mutelist", "unmute"];
/// The reason a temporary ban gives at login.
const BAN_REASON: &str = "Hate speech in chat (Horizon chat filter)";
@@ -155,27 +158,114 @@ impl Game<'_> {
allowed
}
/// `/unmute <player>` from a guest: operators of level 3 (`/ban`'s); vanilla's unknown command
/// for the others.
pub(super) fn guest_unmute(&mut self, host: &mut LanHost, conn: u32, words: &[&str]) {
/// A mute command from a guest: operators of level 3 (`/ban`'s); vanilla's unknown command for
/// the others.
pub(super) fn guest_mute_command(&mut self, host: &mut LanHost, conn: u32, words: &[&str]) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let (permission, by) = (g.permission, g.name.clone());
let reply = if permission < UNMUTE_LEVEL {
translatable("command.unknown.command", &[])
} else if words.len() != 2 {
literal("/unmute <player>")
let replies = if permission < MUTE_LEVEL {
vec![translatable("command.unknown.command", &[])]
} else {
literal(&self.unmute(host, words[1], &by))
let words: Vec<&str> = words.iter().copied().filter(|w| !w.is_empty()).collect();
self.mute_command(host, &words, &by)
.iter()
.map(|line| literal(line))
.collect()
};
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content: reply,
overlay: false,
}),
for content in replies {
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content,
overlay: false,
}),
);
}
}
/// `/mute <player> [reason]`, `/tempmute <player> <duration> [reason]`, `/mutelist` and
/// `/unmute <player>` (`words`, the command first) by `by`: the lines to answer.
pub(super) fn mute_command(
&mut self,
host: &mut LanHost,
words: &[&str],
by: &str,
) -> Vec<String> {
let reason = |from: usize| (words.len() > from).then(|| words[from..].join(" "));
match words {
["mutelist"] => {
let now = horizon_chat_filter::now_millis();
let mutes = host.hub.mutes();
if mutes.is_empty() {
return vec!["There are no mutes".into()];
}
let mut lines = vec![format!("There are {} mute(s):", mutes.len())];
for m in mutes {
let by = m.by.as_deref().unwrap_or("the chat filter");
let until = m.until.map_or_else(
|| "until unmuted".into(),
|u| {
let left = horizon_chat_filter::duration_text(u - now);
format!("for {}", left.replacen(' ', " more ", 1))
},
);
let why = m.reason.map(|r| format!(": {r}")).unwrap_or_default();
lines.push(format!("{} was muted by {by} {until}{why}", m.name));
}
lines
}
["unmute", name] => vec![self.unmute(host, name, by)],
["mute", name, ..] => vec![self.mute(host, name, None, reason(2), 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![format!("{time} isn't a duration: 10m, 2h, 1d")],
},
["mute", ..] => vec!["/mute <player> [reason]".into()],
["tempmute", ..] => vec!["/tempmute <player> <duration> [reason]".into()],
["unmute", ..] => vec!["/unmute <player>".into()],
_ => vec!["/mutelist".into()],
}
}
/// Mutes `name` for `time` milliseconds (`None`: until `/unmute`), logged; what to answer.
fn mute(
&mut self,
host: &mut LanHost,
name: &str,
time: Option<i64>,
reason: Option<String>,
by: &str,
) -> String {
// A player here, else one the server has seen (the user cache).
let user = host
.hub
.guests
.iter()
.find(|g| g.name.eq_ignore_ascii_case(name))
.map(|g| (g.uuid, g.name.clone()))
.or_else(|| self.resolve_name(name).map(|u| (u.id, u.name)));
let Some((uuid, name)) = user else {
return format!("No player was found named {name}");
};
let until = time.map(|ms| horizon_chat_filter::now_millis() + ms);
let what = time.map_or_else(
|| "until unmuted".into(),
|ms| format!("for {}", horizon_chat_filter::duration_text(ms)),
);
let why = reason
.as_deref()
.map(|r| format!(": {r}"))
.unwrap_or_default();
host.hub.mute(uuid, &name, until, by, reason);
if self.options.dedicated.is_some() {
dedicated::log(
"INFO",
&format!("[chat-filter] {by} muted {name} {what}{why}"),
);
}
format!("Muted {name} {what}{why}")
}
/// Lifts `name`'s mute, logged; what to answer.
+109 -16
View File
@@ -1469,6 +1469,7 @@ impl Game<'_> {
let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation));
}
fn teleport_to_in_level(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) {
let from = self.target_position(t);
let v = Vec3::new(pos[0], pos[1], pos[2]);
match t {
Target::Guest(_) => {
@@ -1483,6 +1484,10 @@ impl Game<'_> {
if let Some(conn) = self.lan_acting {
self.server_trips.retain(|trip| !trip.is(conn));
}
let from = self
.player
.as_ref()
.map(|p| [p.position.x, p.position.y, p.position.z]);
if let Some(p) = self.player.as_mut() {
p.position = v;
p.velocity = Vec3::new(0.0, 0.0, 0.0);
@@ -1494,6 +1499,24 @@ impl Game<'_> {
self.yaw = yaw;
self.pitch = pitch.clamp(-90.0, 90.0);
}
// A server player's teleport is told to the mods once their
// move is in the hub (`leave_guest`).
if let (Some(conn), Some(uuid), Some(name), Some(from)) = (
self.lan_acting,
self.lan_acting_uuid,
self.lan_acting_name.clone(),
from,
) {
self.teleport_notes
.push(super::server_travel::TeleportNote {
conn,
uuid,
name,
from,
to: pos,
dimension: self.dim.id().to_owned(),
});
}
}
Target::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
@@ -1602,6 +1625,12 @@ impl Game<'_> {
// Wall-mounted entities do not move.
Target::Frame(_) | Target::Painting(_) => {}
}
if self.options.dedicated.is_some()
&& let (Some(entity), Some(from), Some(to)) =
(self.network_entity_id(t), from, self.target_position(t))
{
self.native_entity_teleport(self.dim.id(), entity as u32, from, to);
}
}
/// Teleport the local player into the command source's dimension. Unlike
/// portal travel, `/tp` keeps the coordinates given by the command.
@@ -1875,8 +1904,12 @@ impl Game<'_> {
if total == t {
return Err(err("commands.time.set.already_at_time", &[&clock, &t]));
}
self.world.day_time = t;
self.ui.clock.partial = 0.0;
let mut cancel = false;
self.native_time_skip(self.dim.id(), total, t, "command", &mut cancel);
if !cancel {
self.world.day_time = t;
self.ui.clock.partial = 0.0;
}
self.feedback(Text::tr("commands.time.set.absolute", &[&clock, &t]));
Ok(t as i32)
}
@@ -1896,8 +1929,13 @@ impl Game<'_> {
&[&clock, &marker],
));
}
self.world.day_time = m.resolve(total);
self.ui.clock.partial = 0.0;
let target = m.resolve(total);
let mut cancel = false;
self.native_time_skip(self.dim.id(), total, target, "command", &mut cancel);
if !cancel {
self.world.day_time = target;
self.ui.clock.partial = 0.0;
}
self.feedback(Text::tr(
"commands.time.set.time_marker",
&[&clock, &marker],
@@ -1906,7 +1944,12 @@ impl Game<'_> {
}
"time.add" => {
let t = i64::from(Self::int(ctx, "time").unwrap_or(0));
self.world.day_time = (total + t).max(0);
let target = (total + t).max(0);
let mut cancel = false;
self.native_time_skip(self.dim.id(), total, target, "command", &mut cancel);
if !cancel {
self.world.day_time = target;
}
let now = self.world.day_time;
self.feedback(Text::tr("commands.time.set.absolute", &[&clock, &now]));
Ok((now % i64::from(i32::MAX)) as i32)
@@ -1990,12 +2033,10 @@ impl Game<'_> {
let sample = |min: i32, max: i32, r: &mut horizon_core::random::LegacyRandom| {
min + r.next_int_bound(max - min + 1).unwrap_or(0)
};
let w = &mut self.world.weather;
let r = &mut self.world.random;
let (key, clear, time, raining, thundering) = if ctx.has("clear") {
(
"commands.weather.set.clear",
duration.unwrap_or_else(|| sample(12_000, 180_000, r)),
duration.unwrap_or_else(|| sample(12_000, 180_000, &mut self.world.random)),
0,
false,
false,
@@ -2004,7 +2045,7 @@ impl Game<'_> {
(
"commands.weather.set.rain",
0,
duration.unwrap_or_else(|| sample(12_000, 24_000, r)),
duration.unwrap_or_else(|| sample(12_000, 24_000, &mut self.world.random)),
true,
false,
)
@@ -2012,17 +2053,46 @@ impl Game<'_> {
(
"commands.weather.set.thunder",
0,
duration.unwrap_or_else(|| sample(3_600, 15_600, r)),
duration.unwrap_or_else(|| sample(3_600, 15_600, &mut self.world.random)),
true,
true,
)
};
// ServerLevel.setWeatherParameters.
w.clear_time = clear;
w.rain_time = time;
w.thunder_time = time;
w.raining = raining;
w.thundering = thundering;
let previous = (
self.world.weather.clear_time,
self.world.weather.rain_time,
self.world.weather.thunder_time,
self.world.weather.raining,
self.world.weather.thundering,
);
let mut cancel = false;
self.native_weather_change(self.dim.id(), raining, thundering, &mut cancel);
let old_thundering = self.world.weather.thundering;
let old_thunder_time = self.world.weather.thunder_time;
let mut cancel_thunder = false;
self.native_thunder_change(self.dim.id(), thundering, &mut cancel_thunder);
if cancel {
let w = &mut self.world.weather;
(
w.clear_time,
w.rain_time,
w.thunder_time,
w.raining,
w.thundering,
) = previous;
} else {
// ServerLevel.setWeatherParameters.
let w = &mut self.world.weather;
w.clear_time = clear;
w.rain_time = time;
w.thunder_time = time;
w.raining = raining;
w.thundering = thundering;
if cancel_thunder {
w.thunder_time = old_thunder_time;
w.thundering = old_thundering;
}
}
self.feedback(Text::translate(key, &[]));
Ok(duration.unwrap_or(-1))
}
@@ -2222,6 +2292,14 @@ impl Game<'_> {
name = Text::translate("entity.minecraft.experience_orb", &[]);
}
"lightning_bolt" => {
let block = [
pos[0].floor() as i32,
pos[1].floor() as i32,
pos[2].floor() as i32,
];
if !self.lightning_strike_allowed(self.lan_acting_uuid, block, "command") {
return Err(err("commands.summon.failed", &[]));
}
self.spawn_bolt(pos, false);
name = Text::translate("entity.minecraft.lightning_bolt", &[]);
}
@@ -3050,6 +3128,20 @@ impl Game<'_> {
let level = self.dim;
for &t in &targets {
self.for_player(t, |g| {
let mut cancel = false;
if let (Some(player), Some(name)) = (g.lan_acting_uuid, g.lan_acting_name.clone()) {
g.native_player_spawn_point_set(
player,
&name,
level.id(),
pos,
"command",
&mut cancel,
);
}
if cancel {
return;
}
if level == Dim::Overworld {
g.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
@@ -3106,6 +3198,7 @@ impl Game<'_> {
// LevelData.setRespawnData: the world's spawn (level.dat `spawn`).
let old = self.level.world_spawn.replace(pos);
self.level.world_spawn_angle = rot;
self.native_spawn_change(pos, rot);
// A player whose fallback is the world spawn (no spawnpoint of its
// own; a bed or an anchor that is gone falls back to it) respawns at
// the new one.
@@ -365,6 +365,7 @@ impl Game<'_> {
&[shown],
));
}
self.native_world_border_bounds_changed();
Ok((distance - current) as i32)
}
"worldborder.center" => {
@@ -382,6 +383,7 @@ impl Game<'_> {
));
}
self.border.set_center([x, z]);
self.native_world_border_center_changed();
self.feedback(Text::translate(
"commands.worldborder.center.success",
&[Text::literal(float2(x)), Text::literal(float2(z))],
@@ -59,6 +59,7 @@ impl Game<'_> {
}
if level < 7 {
self.composter_add(pos, &layers);
self.native_compost_item(pos, &stack.item);
self.stats.add("used", &stack.item, 1);
self.consume_held();
}
+46 -12
View File
@@ -157,24 +157,31 @@ impl Game<'_> {
}
horizon_client::widgets::end_frame(self.frames);
self.narrate_screen();
self.poll_repro(event_loop);
self.advance_agent();
for request in self.ui_wait.ready() {
self.answer(request, event_loop);
}
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
// Once per line received, not once per handler: a deferred `ui`
// comes back through `ui_wait` and must not be traced twice.
self.note_socket_line(&request.line);
self.agent_command_seen();
self.answer(request, event_loop);
if self.pending_save.is_some() {
break;
}
}
if self.control.is_some() {
// A recording made from the F3 keys or the Settings page has no socket,
// and still wants its events.
if self.control.is_some() || self.repro.recording_active() {
self.watch_events();
}
if self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::Agent
{
self.ui.options.agent_status = self.agent_status_lines();
self.ui.options.agent_recording = self.repro.recording_active();
}
}
@@ -273,6 +280,15 @@ impl Game<'_> {
result
}
/// A recorded control line fed back by a replay: not new input, so the
/// trace does not see it, and its reply has no one waiting.
pub(super) fn replay_line(&mut self, line: &str, event_loop: &dyn EventLoopCtl) {
let (reply, _) = std::sync::mpsc::channel();
if let Err(e) = self.control_command(line, &reply, event_loop) {
log::warn!("Horizon: replay: {line}: {e}");
}
}
fn control_command_inner(
&mut self,
line: &str,
@@ -301,6 +317,12 @@ impl Game<'_> {
if let Some(value) = self.events_command(&words, reply)? {
return Ok(value);
}
if let Some(value) = self.input_trace.command(&words)? {
return Ok(Some(value));
}
if let Some(value) = self.repro_command(&words, line, reply)? {
return Ok(value);
}
if let Some(value) = self.saves_control(&words, line, reply)? {
return Ok(value);
}
@@ -333,6 +355,8 @@ impl Game<'_> {
.chain(super::events::HELP)
.chain(super::saves_control::HELP)
.chain(super::pad::HELP)
.chain(super::input_trace::HELP)
.chain(super::repro::HELP)
.collect::<Vec<_>>() }))),
"setblock" | "fill" | "particle" | "signedit" => self.render_command(&words),
"section-draws" => {
@@ -377,6 +401,7 @@ impl Game<'_> {
"sculk" => Ok(Some(self.sculk_command(&words)?)),
"hello" => Ok(Some(hello())),
"status" => Ok(Some(self.control_status())),
"memory" => Ok(Some(self.memory_report())),
"stall" => {
let millis: u64 = arg(1)?.parse()?;
if millis > 5000 {
@@ -394,7 +419,7 @@ impl Game<'_> {
json!({ "game_time": g.world.game_time, "projectiles": g.projectiles_status() })
})
})
.ok_or("the integrated server stopped")?
.ok_or(super::integrated::NO_ANSWER)?
} else {
json!({ "game_time": self.world.game_time, "projectiles": self.projectiles_status() })
};
@@ -410,7 +435,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
server
.call(move |g| g.agent.hold_server = held)
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
} else if self.remote.is_some() {
return Err("server-hold requires the world's host".into());
} else {
@@ -428,10 +453,10 @@ impl Game<'_> {
)
.into());
}
// ConfirmScreen.keyPressed: Escape answers no, nothing else goes past it.
// The command confirmation takes the keys; none go past it.
if self.command_prompt.is_some() {
if key == KeyCode::Escape && arg(2)? != "up" {
self.answer_command_prompt(false);
if arg(2)? != "up" {
self.command_prompt_key(key);
}
return ok();
}
@@ -674,7 +699,7 @@ impl Game<'_> {
let command = format!("give @s {item} {count}");
server
.call(move |g| g.owner_console_command(&command))
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return Ok(Some(json!({ "ok": true, "given": count })));
}
let max = info.max_stack;
@@ -727,7 +752,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
server
.call(move |g| g.world.day_time = time)
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
}
ok()
}
@@ -746,7 +771,7 @@ impl Game<'_> {
let center = self.player.as_ref().map_or(Vec3::ZERO, |p| p.position);
let value = server
.call(move |g| g.in_owner_level(|g| g.control_mobs_at(radius, center)))
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return Ok(Some(value));
}
Ok(Some(self.control_mobs(radius)))
@@ -756,7 +781,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
let value = server
.call(|g| g.control_hurt_log())
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return Ok(Some(value));
}
Ok(Some(self.control_hurt_log()))
@@ -986,7 +1011,7 @@ impl Game<'_> {
if let Some(server) = &self.integrated {
server
.call(move |g| g.control_save(all).map_err(|e| e.to_string()))
.ok_or("the integrated server stopped")??;
.ok_or(super::integrated::NO_ANSWER)??;
} else if self.remote.is_none() {
self.begin_control_save(all, reply.clone())?;
return Ok(None);
@@ -1150,7 +1175,7 @@ impl Game<'_> {
json!({ "ok": true, "players": players })
}
fn control_status(&self) -> Value {
pub(super) fn control_status(&self) -> Value {
let player = self.player.as_ref().map(|p| {
json!({
"position": [p.position.x, p.position.y, p.position.z],
@@ -1320,6 +1345,11 @@ impl Game<'_> {
status["border"] = json!({ "center": self.border.center(), "size": self.border.size() });
// The scoreboard sidebar the HUD shows.
status["sidebar"] = self.sidebar_status();
// Who caused the input, and whether a repro recording runs.
let mut input = self.input_trace.summary();
self.repro
.fill_summary(&mut input, self.input_trace.events());
status["input"] = input;
status
}
}
@@ -1350,6 +1380,10 @@ fn released_modifier(line: &str) -> Option<KeyCode> {
fn key_code(name: &str) -> Option<KeyCode> {
let name = name.to_ascii_lowercase();
// The game's own names (`key.keyboard.w`), which a recording writes.
if let Some(code) = horizon_client::keybinds::key_code(&name) {
return Some(code);
}
let letters = [
KeyCode::KeyA,
KeyCode::KeyB,
+192 -24
View File
@@ -3,7 +3,7 @@
//! breeding, conversions, drops), projectiles, player interactions
//! (feeding, shearing, milking, spawn eggs, throwing) and rendering.
use super::{
Dim, Game, InteractTarget, dispenser, native_mob_draw::NativePlayerRender,
Dim, Game, Hand, InteractTarget, dispenser, native_mob_draw::NativePlayerRender,
native_projectiles::Impact, view_vector,
};
use horizon_assets::item_model::DisplayContext;
@@ -618,6 +618,14 @@ impl Game<'_> {
}
self.mobs.push(mob);
self.native_mobs_joined(self.mobs.len() - 1, false);
let transformed_kind = self
.mobs
.last()
.expect("converted mob was just inserted")
.kind
.id
.clone();
self.native_entity_transform(from.id, &from.kind.id, id, &transformed_kind);
Some(id)
}
/// The `summon` command: spawn a mob in front of the player or at a position.
@@ -1691,7 +1699,16 @@ impl Game<'_> {
None => None,
};
}
let player = (p.owner == Some(Who::Player))
.then(|| horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid()));
let name = if player.is_some() {
self.acting_player_name().to_owned()
} else {
String::new()
};
let (id, kind) = (p.id, p.kind.id());
self.creatures.projectiles.push(p);
self.native_projectile_launch(player, &name, id, kind);
}
/// `Entity.sendBubbleColumnParticles` at the top of the column block `pos`: two
/// splashes and two bubbles over it, which the level random places, shown here
@@ -2309,9 +2326,15 @@ impl Game<'_> {
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
self.process_level_events()?;
}
MobEvent::BreakDoor { pos } => {
MobEvent::BreakDoor { mob, pos } => {
// BreakDoorGoal.tick: removeBlock, then level events 1021
// and 2001 with the state that is there now.
let kind = self
.mobs
.iter()
.find(|m| m.id == mob)
.map(super::native_hooks::mob_kind)
.unwrap_or_default();
let air = self.world.air;
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, air, 3));
@@ -2319,6 +2342,7 @@ impl Game<'_> {
self.level_event_at(1021, pos, 0);
let left = self.world.block(pos[0], pos[1], pos[2]);
self.level_event_at(2001, pos, left.0 as i32);
self.native_entity_break_door(mob, &kind, pos);
}
MobEvent::DestroyProgress { mob, pos, progress } => {
self.destroy_block_progress(mob as i32, pos, progress);
@@ -2344,6 +2368,7 @@ impl Game<'_> {
}
MobEvent::LevelEvent { id, pos, data } => self.level_event_at(id, pos, data),
MobEvent::SetBlockName {
mob,
pos,
name,
effect,
@@ -2363,7 +2388,19 @@ impl Game<'_> {
self.level_event_at(2001, pos, state.0 as i32);
}
if let Some(state) = self.pack.assets.registry.default_state(name) {
let old_name = self
.res
.facts()
.get(self.world.block(pos[0], pos[1], pos[2]))
.name
.clone();
self.level_set_with_flags(pos, state, flags)?;
if let Some(mob) = mob
&& let Some(m) = self.mobs.iter().find(|m| m.id == mob)
{
let kind = m.kind.id.clone();
self.native_entity_change_block(mob, &kind, pos, &old_name, name);
}
}
}
MobEvent::Infest {
@@ -2566,7 +2603,12 @@ impl Game<'_> {
parent,
partner,
llama,
} => self.breed(parent, partner, llama),
} => {
let mob_count = self.mobs.len();
self.breed(parent, partner, llama);
let child = self.mobs.get(mob_count).map(|m| m.id);
self.native_entity_breed(parent, partner, child);
}
MobEvent::LockGaze { mob, partner } => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == mob) {
horizon_client::mobs::animal_brain::lock_gaze(m, partner);
@@ -3897,9 +3939,11 @@ impl Game<'_> {
ArrowEvent::BubbleColumnParticles { pos } => self.bubble_column_particles(pos),
ArrowEvent::Block { pos, reaction } => match reaction {
BlockReaction::PrimeTnt => {
self.spawn_primed_tnt(pos, false, None, None);
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
let _ = self.process_level_events();
if self.tnt_prime_allowed(pos) {
self.spawn_primed_tnt(pos, false, None, None);
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
let _ = self.process_level_events();
}
}
BlockReaction::Light(lit) => {
let _ = self.level_set([pos.x, pos.y, pos.z], lit);
@@ -3988,6 +4032,7 @@ impl Game<'_> {
});
if let Some(strength) = hit {
let _ = self.process_level_events();
self.native_target_hit(self.dim.id(), pos, strength);
if p.owner == Some(Who::Player) && (p.guest.is_none() || p.guest == self.lan_acting) {
self.stats.custom("target_hit", 1);
let kind = if p
@@ -4862,6 +4907,34 @@ impl Game<'_> {
if self.name_mob(index) {
return Ok(true);
}
if self
.held()
.is_some_and(|stack| &*stack.item == "minecraft:shears")
{
let mob = &self.mobs[index];
let shearable = match mob.species() {
Species::Sheep => !mob.extra.sheared && !mob.is_baby(),
Species::Mooshroom => !mob.is_baby(),
Species::SnowGolem | Species::Bogged => mob.alive() && !mob.extra.sheared,
_ => false,
};
if shearable
&& let (Some(player), Some(name)) =
(self.lan_acting_uuid, self.lan_acting_name.clone())
{
let mut cancel = false;
self.native_player_shear_entity(
player,
&name,
mob.id as i32,
self.hand == Hand::Off,
&mut cancel,
);
if cancel {
return Ok(true);
}
}
}
if self.spawn_egg_offspring(index) {
self.swing_arm();
return Ok(true);
@@ -4900,9 +4973,10 @@ impl Game<'_> {
self.audio
.play(sound, Some(self.mobs[index].center()), 1.0, 1.0);
self.trigger_item("minecraft:filled_bucket", &filled);
let species = bucket.trim_end_matches("_bucket").to_owned();
let id = self.mobs[index].id as i32;
// Leashable.dropLeash, then discard.
let id = self.mobs[index].id;
self.break_leash(id, true, false);
self.break_leash(id as u32, true, false);
self.mobs[index].removed = true;
if self.creative {
if let Some(rest) = self.give(filled) {
@@ -4911,13 +4985,27 @@ impl Game<'_> {
} else {
*self.held_mut() = Some(filled);
}
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_bucket_entity(player, &name, id, &species);
}
self.swing_arm();
return Ok(true);
}
// AgeableMob.mobInteract: a golden dandelion on a baby freezes its
// growth, or lets it grow again.
if self.mobs[index].alive() && self.age_lock_takes(&self.mobs[index], &item) {
let entity_id = self.mobs[index].id;
self.set_age_locked(index);
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_toggle_entity_age_lock(
player,
&name,
entity_id,
self.mobs[index].extra.age_locked,
);
}
if let Some(stack) = &held {
let target = self.mob_entity(&self.mobs[index]);
self.trigger_item_entity("minecraft:player_interacted_with_entity", stack, &target);
@@ -5037,6 +5125,7 @@ impl Game<'_> {
let mut eat: Option<(&'static str, f32, f32)> = None;
let feet = [m.position.x, m.position.y, m.position.z];
let mut handled = true;
let mut ignited_creeper = false;
let mut drops: Vec<(String, u32)> = Vec::new();
// The shearing table rolled, for the mods (none for a mooshroom).
let mut drops_table = String::new();
@@ -5387,6 +5476,7 @@ impl Game<'_> {
}
Species::Creeper if creeper_igniter => {
m.extra.flag = true;
ignited_creeper = true;
// FIRECHARGE_USE for a fire charge, else FLINTANDSTEEL_USE,
// at the creeper's feet with a pitch from its own random.
let event = if item == "minecraft:fire_charge" {
@@ -5530,8 +5620,11 @@ impl Game<'_> {
// Animal.setInLove: entity event 18 (a baby that
// only grew is not in love).
if self.mobs[index].extra.in_love > 0 {
self.love_cause.insert(self.mobs[index].id, self.lan_acting);
self.mob_event_particles(self.mobs[index].id, 18);
let id = self.mobs[index].id;
self.love_cause.insert(id, self.lan_acting);
let kind = self.mobs[index].kind.id.clone();
self.native_entity_enter_love_mode(id, &kind, self.lan_acting_uuid);
self.mob_event_particles(id, 18);
}
}
// Animal.mobInteract still consumes the client click when
@@ -5544,6 +5637,13 @@ impl Game<'_> {
if !handled {
return Ok(false);
}
if ignited_creeper {
self.native_creeper_ignite(
self.mobs[index].id,
horizon_client::lan::playerdata::ints_uuid(acting),
&item,
);
}
if let Some(trusts) = ocelot_trust {
// `spawnTrustingParticles` on the clients: entity event 41 (hearts) or 40 (smoke).
self.mob_event_particles(self.mobs[index].id, if trusts { 41 } else { 40 });
@@ -5837,6 +5937,11 @@ impl Game<'_> {
*self.held_mut() = None;
}
}
if kind == ProjectileKind::Egg
&& let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
{
self.native_player_egg_throw(player, &name);
}
Ok(true)
}
/// `Mob.checkAndHandleImportantInteractions` with a spawn egg of mob
@@ -6098,6 +6203,19 @@ impl Game<'_> {
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);
// Mob models posed on the GPU (mobs::skin), when the renderer takes them this frame.
let skinning = self
.renderer
.as_ref()
.filter(|r| r.skins_entities())
.and_then(|r| r.skinned_models());
match skinning {
Some(stored) => self
.skin_models
.borrow_mut()
.begin(&mut self.skin_frame.borrow_mut(), stored),
None => self.skin_frame.borrow_mut().clear_draws(),
}
// 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,
@@ -6240,21 +6358,55 @@ impl Game<'_> {
Vec::new()
};
let body = out.len();
let render = if layers_only {
horizon_client::mobs::render::render_layers
// The GPU path: an ordinary mob, no outline, no layers the CPU path changes after
// drawing them (a player's, the HDR tiers below, the sulfur cube's shell).
let posable = skinning.is_some()
&& !is_player
&& !layers_only
&& outline.is_none()
&& 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 {
models: &mut models,
frame: &mut frame,
bounds: None,
};
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 {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
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() {
@@ -6302,7 +6454,23 @@ impl Game<'_> {
if outline.is_none()
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
{
// What the GPU draws of the mob counts by its bounds' corners.
let corners = skinned.and_then(|(_, bounds)| bounds);
if let Some((lo, hi)) = corners {
out.extend([lo, hi].map(|pos| Vertex {
pos: pos.to_array(),
..bytemuck::Zeroable::zeroed()
}));
}
let len = out.len();
self.cull_entity_vertices(out, entity_start);
if out.len() < len {
if let Some((mark, _)) = skinned {
self.skin_frame.borrow_mut().truncate(mark);
}
} else if corners.is_some() {
out.truncate(len - 2);
}
}
// GuardianRenderer: the laser to the locked target.
if let Some((time, scale)) = horizon_client::mobs::guardian::beam(mob, alpha as f32)
@@ -255,6 +255,12 @@ impl Game<'_> {
}
arrow.shoot(&mut self.world.random, dir.to_array(), power, 1.0);
self.add_projectile(arrow);
let (player, name, projectile) = (
horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid()),
self.acting_player_name().to_owned(),
self.next_entity,
);
self.native_entity_shoot_bow(player, &name, projectile, power as f32);
}
self.player_projectile_shot();
let pitch = if index == 0 {
@@ -710,6 +710,18 @@ impl Game<'_> {
self.toggle_debug_options();
true
}
// Horizon's own: a repro recording for a bug report (game/repro.rs).
// Off unless the AI Agent page enables the shortcuts.
DebugAction::RecordSession if !self.settings.extras.repro_keys => false,
DebugAction::ExportRepro if !self.settings.extras.repro_keys => false,
DebugAction::RecordSession => {
self.toggle_recording_key();
true
}
DebugAction::ExportRepro => {
self.export_repro_key();
true
}
// Screens and overlays Horizon does not draw yet (the profiler and
// its chart, the texture dump): the keys are used up as in vanilla.
DebugAction::DumpDynamicTextures
+20 -9
View File
@@ -1236,7 +1236,7 @@ impl Game<'_> {
.iter()
.map(|e| (e.user.id, e.level as u8))
.collect();
host.hub.resend_commands();
self.resend_commands_filtered();
}
/// A player on the server, by name.
@@ -1457,13 +1457,17 @@ impl Game<'_> {
}
"whitelist" => self.console_whitelist(state, &rest),
// The chat filter's (game/chat_filter.rs), on servers that filter.
"unmute"
if rest.len() == 1 && self.lan.as_ref().is_some_and(|h| h.hub.chat_filter_on()) =>
command
if super::chat_filter::MUTE_COMMANDS.contains(&command)
&& self.lan.as_ref().is_some_and(|h| h.hub.chat_filter_on()) =>
{
if let Some(mut host) = self.lan.take() {
let reply = self.unmute(&mut host, rest[0], "Server");
let words: Vec<&str> = [command].into_iter().chain(rest).collect();
let replies = self.mute_command(&mut host, &words, "Server");
self.lan = Some(host);
self.console_reply(lit(&reply));
for reply in replies {
self.console_reply(lit(&reply));
}
}
}
"ban" if !rest.is_empty() => {
@@ -1685,7 +1689,7 @@ impl Game<'_> {
match rest {
["on"] | ["off"] => {
let on = rest[0] == "on";
let mut l = state.lists.lock().unwrap();
let l = state.lists.lock().unwrap();
let turned_on = on && !l.use_whitelist;
let key = match (on, l.use_whitelist) {
(true, true) => "commands.whitelist.alreadyOn",
@@ -1693,9 +1697,8 @@ impl Game<'_> {
(false, false) => "commands.whitelist.alreadyOff",
(false, true) => "commands.whitelist.disabled",
};
l.use_whitelist = on;
drop(l);
self.store_property("white-list", on.to_string());
self.set_using_whitelist(&state.lists, on);
self.console_reply(Text::translate(key, &[]));
if turned_on {
let lists = state.lists.clone();
@@ -1763,8 +1766,16 @@ impl Game<'_> {
/// `DedicatedServer.setUsingWhitelist`: the flag, and `white-list` in
/// server.properties.
pub(super) fn set_using_whitelist(&mut self, lists: &Arc<Mutex<Lists>>, on: bool) {
lists.lock().unwrap().use_whitelist = on;
let changed = {
let mut lists = lists.lock().unwrap();
let changed = lists.use_whitelist != on;
lists.use_whitelist = on;
changed
};
self.store_property("white-list", on.to_string());
if changed {
self.native_whitelist_state_update(on);
}
}
/// DedicatedServerSettings.update: change one key and store the file.
+23
View File
@@ -4,6 +4,7 @@
//! connecting screen in configuration), its inputs, and the actions its
//! buttons send back.
use super::Game;
use super::remote_chat::Behind;
use horizon_client::{
Result,
clipboard::EditShortcut,
@@ -35,6 +36,9 @@ struct Open {
selecting: Option<usize>,
}
/// A dialog a clicked command closed, kept for its confirmation's no.
pub(super) struct ClosedDialog(Box<Open>);
/// A text input's control: `EditBox`, or `MultiLineEditBox` for a multiline one.
enum Field {
Line(ui::EditBox),
@@ -252,6 +256,12 @@ impl Game<'_> {
}
}
/// The confirmation's no: the dialog again, with what was typed in it.
pub(super) fn reopen_dialog(&mut self, dialog: ClosedDialog) {
self.dialogs.open = Some(*dialog.0);
self.dialogs.waiting = None;
}
/// `DialogScreen.runAction`: the click event, then the screen the dialog's
/// `after_action` leaves.
fn run_dialog_action(&mut self, click: Option<Click>, after: AfterAction) -> Result<()> {
@@ -266,6 +276,14 @@ impl Game<'_> {
}
return Ok(());
}
// openSendConfirmationWindow's `currentScreen`: a command that asks
// first still has this dialog to go back to.
let closing = match (&click, &after) {
(Some(Click::RunCommand(_)), AfterAction::Close | AfterAction::WaitForResponse) => {
self.dialogs.open.take()
}
_ => None,
};
match after {
AfterAction::None => {}
AfterAction::Close => self.dialogs.open = None,
@@ -284,6 +302,11 @@ impl Game<'_> {
// In configuration there is nothing to run it (vanilla warns).
if self.player.is_some() {
self.send_unattended_command(&command);
if let Some(open) = closing {
self.command_prompt_returns_to(Behind::Dialog(ClosedDialog(Box::new(
open,
))));
}
} else {
log::warn!(
"Horizon: commands are not supported in configuration, trying to run '{command}'"
+46 -8
View File
@@ -634,6 +634,13 @@ impl Game<'_> {
);
}
if let Some((left, event)) = mod_dispensed(answer) {
if event == FAIL {
self.native_block_failed_dispense(
self.dim.id(),
[pos.0, pos.1, pos.2],
&stack.item,
);
}
// DefaultDispenseItemBehavior.dispense: playSound, then playAnimation;
// a NOOP behaviour (event 0) plays neither.
if event != 0 {
@@ -690,6 +697,9 @@ impl Game<'_> {
}
_ => self.dispense_default_method(pos, facing, stack)?,
};
if event == FAIL {
self.native_block_failed_dispense(self.dim.id(), [pos.0, pos.1, pos.2], &item);
}
self.dispenser_event(event, pos, 0);
self.dispenser_event(2000, pos, data_value(facing));
Ok(left)
@@ -899,6 +909,12 @@ impl Game<'_> {
};
self.player_equipped(name, Some(&one));
self.apply_equipment();
self.native_block_dispense_armor(
self.dim.id(),
[pos.0, pos.1, pos.2],
&stack.item,
"player",
);
return true;
}
Target::Stand(i) => {
@@ -932,6 +948,16 @@ impl Game<'_> {
let ctx = self.who_context(who, None);
self.post_game_event("minecraft:equip", [at.x, at.y, at.z], ctx);
}
self.native_block_dispense_armor(
self.dim.id(),
[pos.0, pos.1, pos.2],
&stack.item,
match target {
Target::Stand(_) => "armor_stand",
Target::Mob(_) => "mob",
Target::Player => "player",
},
);
true
}
/// After `EntityType.spawn` / `MobBucketItem.spawn`: `mob.playAmbientSound()`.
@@ -1247,10 +1273,10 @@ impl Game<'_> {
horizon_client::items::bucket_contents(&item).expect("filled bucket dispatcher");
let target_pos = Self::pos_of(target);
let emptied = self.with_level(|b, level, ticks| {
b.bucket_empty(level, ticks, target_pos, content, sound, false, false)
b.bucket_empty(level, ticks, target_pos, None, content, sound, false, false)
});
self.process_level_events()?;
if !emptied {
if emptied.is_none() {
return Ok((self.dispense_default(pos, facing, stack), CLICK));
}
if self.dim == super::Dim::Nether
@@ -1354,6 +1380,9 @@ impl Game<'_> {
if self
.with_level(|b, level, ticks| b.fire_can_be_placed_at(level, ticks, target_pos, facing))
{
if !self.native_block_ignite_allowed(target_pos, "flint_and_steel") {
return Ok((Some(stack), CLICK));
}
let fire =
self.with_level(|b, level, ticks| b.fire_state_for(level, ticks, target_pos));
self.level_set([target.0, target.1, target.2], fire)?;
@@ -1374,7 +1403,7 @@ impl Game<'_> {
None,
);
} else if &*name == "minecraft:tnt" {
if self.ui.rules.bool("tnt_explodes") {
if self.ui.rules.bool("tnt_explodes") && self.tnt_prime_allowed(target_pos) {
self.spawn_primed_tnt(target_pos, false, None, None);
self.with_level(|b, level, ticks| b.remove_block(level, ticks, target_pos, false));
self.process_level_events()?;
@@ -1435,9 +1464,13 @@ impl Game<'_> {
if self.sulfur_cube_swallow(front, &stack) {
return (shrink(stack), optional_event("tnt"));
}
let target = Self::pos_of(front);
if !self.tnt_prime_allowed(target) {
set_success("tnt", false);
return (Some(stack), FAIL);
}
set_success("tnt", true);
// new PrimedTnt(level, x + 0.5, y, z + 0.5, null), its sound and ENTITY_PLACE.
let target = Self::pos_of(front);
let rot = self.world.random.next_double() * f64::from(std::f32::consts::TAU);
self.add_primed_tnt(target, rot, 80, None, None);
self.audio.play(
@@ -1746,10 +1779,7 @@ impl Game<'_> {
let random = self.rand_sequences.get(&sequence, self.level.seed);
world_items!(self).loot_table_from(table, false, held, random)
}
None => self
.res
.items()
.loot_table(table, false, held, &mut self.world.random),
None => world_items!(self).loot_table(table, false, held, &mut self.world.random),
}
}
/// `Entity.spawnAtLocation(level, stack, offset)`: an item at the entity's
@@ -1818,6 +1848,8 @@ impl Game<'_> {
continue;
}
let at = m.position;
let entity_id = m.id;
let kind = m.kind.id.clone();
let sound_at = Some([at.x, at.y, at.z]);
match m.species() {
Species::SulfurCube if sulfur_cube::has_body(m) => {
@@ -1936,6 +1968,12 @@ impl Game<'_> {
}
_ => continue,
}
self.native_block_shear_entity(
self.dim.id(),
[target.0, target.1, target.2],
entity_id,
&kind,
);
self.post_block_game_event(
"minecraft:shear",
[target.0, target.1, target.2],
+296 -57
View File
@@ -17,6 +17,7 @@ use horizon_client::{
projectiles::ProjectileKind,
text::Text,
};
use horizon_core::position::BlockPos;
use horizon_core::{
geometry::{Aabb, Vec3},
random::{LegacyRandom, RandomSource},
@@ -24,6 +25,42 @@ use horizon_core::{
use horizon_protocol::entity_data::Value;
use horizon_world::nbt::Tag;
/// `HealOrHarmMobEffect.applyInstantaneousEffect` at an `AreaEffectCloud`'s
/// 0.5 scale: `(int) (scale * (6 << amplifier) + 0.5)`.
fn harm_amount(amplifier: u32) -> f32 {
(0.5 * f64::from(6u32 << amplifier) + 0.5) as i32 as f32
}
/// Whether a cloud at `at` (its floor) reaches a living entity: its box is
/// `radius * 2` wide and 0.5 tall, and the entity's position must lie within
/// the radius horizontally.
fn cloud_hits(at: [f64; 3], radius: f32, bounds: Aabb, pos: Vec3) -> bool {
let r = f64::from(radius);
let area = Aabb::new(
Vec3::new(at[0] - r, at[1], at[2] - r),
Vec3::new(at[0] + r, at[1] + 0.5, at[2] + r),
);
let (dx, dz) = (pos.x - at[0], pos.z - at[2]);
bounds.intersects(area) && dx * dx + dz * dz <= f64::from(radius * radius)
}
/// `DragonFireball.onHit`: the cloud moves to the first living entity in the
/// fireball's box inflated by (4, 2, 4) that is within 4 blocks of it.
/// `feet` is the fireball's position (the bottom of its 1x1x1 box).
fn fireball_cloud_origin(feet: [f64; 3], targets: &[(Aabb, Vec3)]) -> [f64; 3] {
let area = Aabb::new(
Vec3::new(feet[0] - 4.5, feet[1] - 2.0, feet[2] - 4.5),
Vec3::new(feet[0] + 4.5, feet[1] + 3.0, feet[2] + 4.5),
);
for (bounds, pos) in targets {
let (dx, dy, dz) = (pos.x - feet[0], pos.y - feet[1], pos.z - feet[2]);
if bounds.intersects(area) && dx * dx + dy * dy + dz * dz < 16.0 {
return [pos.x, pos.y, pos.z];
}
}
feet
}
/// Mob ids from here on are dragon parts (`SPECIAL + part`) and crystals
/// (`SPECIAL + 16 + crystal`), so projectiles can target them.
pub(super) const SPECIAL: u32 = u32::MAX - 4096;
@@ -1897,6 +1934,17 @@ impl Game<'_> {
};
super::view_vector(d.yaw, pitch).map(|v| v)
}
/// `getHeadLookVector(1).normalize()`, which the phases' `doClientTick`s
/// take (a look the dragon has never turned from stays the zero vector).
fn dragon_look_normalised(&self) -> [f64; 3] {
let look = self.dragon_look();
let norm = (look[0] * look[0] + look[1] * look[1] + look[2] * look[2]).sqrt();
if norm < 1.0e-5 {
[0.0; 3]
} else {
[look[0] / norm, look[1] / norm, look[2] / norm]
}
}
/// `EnderDragon.aiStep` (and `tickDeath`) for the dragon.
fn tick_dragon(&mut self) {
let Some(d) = self.dragon.as_mut() else {
@@ -2272,7 +2320,17 @@ impl Game<'_> {
f.full_collision || f.legacy_solid
})
.map_or(64, |y| y + 1);
let _ = self.level_set([0, y, 0], egg);
let pos = BlockPos::new(0, y, 0);
let allowed = self.with_level(|_, level, _| {
let mut cancel = false;
if level.native_authoritative {
level.native_dragon_egg_form(level.dimension, pos, &mut cancel);
}
!cancel
});
if allowed {
let _ = self.level_set([0, y, 0], egg);
}
}
self.fight.previously_killed = true;
self.fight.killed = true;
@@ -2312,6 +2370,13 @@ impl Game<'_> {
.filter(|p| p.alive && !p.spectator)
.map(|p| p.bounds)
.collect();
// DragonFireball.onHit snaps to any living entity (mobs not ported here).
let snap_targets: Vec<(Aabb, Vec3)> = self
.fight_players()
.iter()
.filter(|p| p.alive)
.map(|p| (p.bounds, p.position))
.collect();
balls.retain_mut(|b| {
b.previous = b.position;
b.age += 1;
@@ -2341,9 +2406,13 @@ impl Game<'_> {
});
if hit_block || hit_player || b.age > 600 {
let id = self.take_entity_id();
// The cloud starts at the fireball's feet; DragonFireball.onHit
// then moves it to the first living entity within 4 blocks.
let feet = [next.x, next.y - 0.5, next.z];
let at = fireball_cloud_origin(feet, &snap_targets);
self.breath_clouds.push(Breath {
id,
at: [next.x, next.y, next.z],
at,
radius: 3.0,
age: 0,
duration: 600,
@@ -2425,13 +2494,11 @@ impl Game<'_> {
}
// AreaEffectCloud: after its 20-tick wait, every 20 ticks per entity.
if c.age >= 20 && c.age % 20 == 0 {
// Instant damage: I (6) from the breath, II (12) from a fireball.
let amount = if c.from_dragon { 6.0 } else { 12.0 };
// Instant damage I from the flame cloud, II from a fireball's,
// both at the cloud's 0.5 scale.
let amount = harm_amount(u32::from(!c.from_dragon));
for p in &players {
let pp = p.position;
if (pp.x - c.at[0]).hypot(pp.z - c.at[2]) <= f64::from(r)
&& (pp.y - c.at[1]).abs() < 1.0
{
if cloud_hits(c.at, r, p.bounds, p.position) {
harmed.push((p.who, amount));
}
}
@@ -2571,6 +2638,67 @@ impl DragonFireball {
);
}
}
/// `DragonLandingPhase.doClientTick` and `DragonSittingFlamingPhase.doClientTick`:
/// the dragon-breath particles one tick of `doClientTick` spawns at the head, as
/// (position, velocity) pairs. `look` is `EnderDragon.getHeadLookVector(1)`
/// normalised, `head` the head part's centre (`getY(0.5)`), `motion` the
/// dragon's `getDeltaMovement`, and `random` the stream the jitter comes from.
/// Both phases rotate `look` by a quarter turn and throw the result away, so
/// every puff of a fan leaves along the same line; a flaming dragon walks down
/// that line in six steps.
fn breath_puffs(
phase: &Phase,
look: [f64; 3],
head: [f64; 3],
motion: [f64; 3],
random: &mut LegacyRandom,
) -> Vec<([f64; 3], [f64; 3])> {
// A puff's start: the head's centre jittered by `nextGaussian() / 2`.
let jitter = |random: &mut LegacyRandom| {
[
head[0] + random.next_gaussian() / 2.0,
head[1] + random.next_gaussian() / 2.0,
head[2] + random.next_gaussian() / 2.0,
]
};
match phase {
// Eight a tick, along `-look * 0.08F` (0.3 for the vertical part) plus
// the dragon's own motion.
Phase::Landing { .. } => (0..8)
.map(|_| {
(
jitter(random),
[
-look[0] * f64::from(0.08_f32) + motion[0],
-look[1] * f64::from(0.3_f32) + motion[1],
-look[2] * f64::from(0.08_f32) + motion[2],
],
)
})
.collect(),
// Every second tick of the first ten: eight starts, each a fan of six
// puffs down the look line, the vertical part of the speed once.
Phase::Flaming { ticks } if ticks % 2 == 0 && *ticks < 10 => {
let mut puffs = Vec::with_capacity(48);
for _ in 0..8 {
let at = jitter(random);
for j in 0..6 {
let j = f64::from(j);
puffs.push((
at,
[
-look[0] * f64::from(0.08_f32) * j,
-look[1] * f64::from(0.6_f32),
-look[2] * f64::from(0.08_f32) * j,
],
));
}
}
puffs
}
_ => Vec::new(),
}
}
pub(super) struct Breath {
/// Its entity id once a server's players track it (0 until then).
pub(super) id: u32,
@@ -3768,13 +3896,7 @@ impl Game<'_> {
/// away in vanilla, so every puff leaves along the same line), the third
/// puffs an explosion every 10 ticks and the last growls on every tick.
fn remote_dragon_phase_tick(&mut self) {
let look = self.dragon_look();
let norm = (look[0] * look[0] + look[1] * look[1] + look[2] * look[2]).sqrt();
let look = if norm < 1.0e-5 {
[0.0; 3]
} else {
[look[0] / norm, look[1] / norm, look[2] / norm]
};
let look = self.dragon_look_normalised();
let Some(d) = self.dragon.as_mut() else {
return;
};
@@ -3782,51 +3904,20 @@ impl Game<'_> {
let head_y = head.y + PART_SIZES[0].1 * 0.5;
let motion = d.velocity;
let at = [d.position.x, d.position.y, d.position.z];
let mut puffs: Vec<([f64; 3], [f64; 3])> = Vec::new();
// `flameTicks`: the client copy's own counter, advanced before it is read.
if let Phase::Flaming { ticks } = &mut d.phase {
*ticks += 1;
}
let puffs = breath_puffs(
&d.phase,
look,
[head.x, head_y, head.z],
[motion.x, motion.y, motion.z],
&mut d.random,
);
let mut emitter = None;
let mut growl = None;
match &mut d.phase {
Phase::Landing { .. } => {
for _ in 0..8 {
let r = &mut d.random;
let p = [
head.x + r.next_gaussian() / 2.0,
head_y + r.next_gaussian() / 2.0,
head.z + r.next_gaussian() / 2.0,
];
puffs.push((
p,
[
-look[0] * f64::from(0.08_f32) + motion.x,
-look[1] * f64::from(0.3_f32) + motion.y,
-look[2] * f64::from(0.08_f32) + motion.z,
],
));
}
}
Phase::Flaming { ticks } => {
*ticks += 1;
if *ticks % 2 == 0 && *ticks < 10 {
for _ in 0..8 {
let r = &mut d.random;
let p = [
head.x + r.next_gaussian() / 2.0,
head_y + r.next_gaussian() / 2.0,
head.z + r.next_gaussian() / 2.0,
];
for j in 0..6 {
puffs.push((
p,
[
-look[0] * f64::from(0.08_f32) * f64::from(j),
-look[1] * f64::from(0.6_f32),
-look[2] * f64::from(0.08_f32) * f64::from(j),
],
));
}
}
}
}
Phase::Dying { time, .. } => {
if *time % 10 == 0 {
let r = &mut d.random;
@@ -3975,6 +4066,53 @@ fn push_seen_with_hurt(
mod tests {
use super::*;
fn player_box(feet: [f64; 3]) -> (Aabb, Vec3) {
(
Aabb::new(
Vec3::new(feet[0] - 0.3, feet[1], feet[2] - 0.3),
Vec3::new(feet[0] + 0.3, feet[1] + 1.8, feet[2] + 0.3),
),
Vec3::new(feet[0], feet[1], feet[2]),
)
}
#[test]
fn instant_damage_at_cloud_scale() {
assert_eq!(harm_amount(0), 3.0);
assert_eq!(harm_amount(1), 6.0);
}
#[test]
fn cloud_box_is_half_a_block_tall_from_its_floor() {
let at = [0.0, 64.0, 0.0];
let hit = |feet: [f64; 3], r: f32| {
let (b, p) = player_box(feet);
cloud_hits(at, r, b, p)
};
assert!(hit([0.0, 64.0, 0.0], 3.0));
// Feet above the 0.5 box: the old |dy| < 1 test hit here.
assert!(!hit([0.0, 64.6, 0.0], 3.0));
// Feet below, head reaching into the box.
assert!(hit([0.0, 63.0, 0.0], 3.0));
// Box overlaps but the centre is outside the radius.
assert!(!hit([3.2, 64.0, 0.0], 3.0));
assert!(hit([3.0, 64.0, 0.0], 3.0));
}
#[test]
fn fireball_cloud_snaps_to_a_player_within_four() {
let (b, p) = player_box([2.0, 70.0, 1.0]);
assert_eq!(
fireball_cloud_origin([0.0, 70.0, 0.0], &[(b, p)]),
[2.0, 70.0, 1.0]
);
let (b, p) = player_box([4.5, 70.0, 0.0]);
assert_eq!(
fireball_cloud_origin([0.0, 70.0, 0.0], &[(b, p)]),
[0.0, 70.0, 0.0]
);
}
#[test]
fn dying_dragons_keep_their_flight_pose_between_frames() {
let mut dragon = Dragon::new(Vec3::new(0.0, 80.0, 0.0), 0.0, 432);
@@ -4094,6 +4232,107 @@ mod tests {
}
}
/// `DragonLandingPhase.doClientTick`: eight puffs a tick from the head,
/// along `getHeadLookVector(1)` (a quarter turn of it is thrown away in
/// vanilla) plus the dragon's own motion.
#[test]
fn a_landing_dragon_breathes_eight_puffs_from_its_head() {
let look = [0.0, -0.6, 0.8];
let head = [10.0, 70.5, -3.0];
let motion = [0.25, -0.05, 0.5];
let mut random = LegacyRandom::new(7);
let puffs = breath_puffs(
&Phase::Landing { target: None },
look,
head,
motion,
&mut random,
);
assert_eq!(puffs.len(), 8);
let speed = [
-look[0] * f64::from(0.08_f32) + motion[0],
-look[1] * f64::from(0.3_f32) + motion[1],
-look[2] * f64::from(0.08_f32) + motion[2],
];
for (at, v) in puffs {
assert_eq!(v, speed);
// The head's centre, jittered by `nextGaussian() / 2`.
for a in 0..3 {
assert!(
(at[a] - head[a]).abs() < 2.0,
"puff {} off the head: {at:?}",
a
);
}
}
}
/// `DragonSittingFlamingPhase.doClientTick`: on the even ticks of the first
/// ten, eight starts each breathe a fan of six puffs down the look line.
#[test]
fn a_flaming_dragon_breathes_a_fan_on_every_second_tick() {
let look = [0.6, -0.0, 0.8];
let head = [0.0, 65.5, 4.0];
for (tick, want) in [(1, 0), (2, 48), (3, 0), (4, 48), (8, 48), (9, 0), (10, 0)] {
let mut random = LegacyRandom::new(11);
let puffs = breath_puffs(
&Phase::Flaming { ticks: tick },
look,
head,
[0.0; 3],
&mut random,
);
assert_eq!(puffs.len(), want, "flame tick {tick}");
}
let mut random = LegacyRandom::new(11);
let puffs = breath_puffs(
&Phase::Flaming { ticks: 2 },
look,
head,
[0.0; 3],
&mut random,
);
for (i, (at, v)) in puffs.iter().enumerate() {
let j = (i % 6) as f64;
// Six puffs leave from each of the eight starts.
assert_eq!(at, &puffs[i - i % 6].0);
assert_eq!(
*v,
[
-look[0] * f64::from(0.08_f32) * j,
-look[1] * f64::from(0.6_f32),
-look[2] * f64::from(0.08_f32) * j,
]
);
}
}
/// No other phase has a `doClientTick` that breathes.
#[test]
fn only_the_landing_and_flaming_phases_breathe() {
let mut random = LegacyRandom::new(3);
for phase in [
flying(),
Phase::Scanning { time: 5 },
Phase::Hover { target: None },
Phase::Takeoff {
first: false,
path: None,
target: None,
},
Phase::Attacking { ticks: 3 },
Phase::Dying {
target: None,
time: 0,
},
] {
assert!(
breath_puffs(&phase, [0.0, -1.0, 0.0], [0.0; 3], [0.0; 3], &mut random).is_empty(),
"{phase:?}"
);
}
}
#[test]
fn a_perched_dragon_shrugs_arrows_off() {
// AbstractDragonSittingPhase.onHurt: an arrow does nothing, melee does.
@@ -584,9 +584,21 @@ impl Game<'_> {
return amount;
}
current.damage -= repaired;
let item = current.item.to_string();
let player = (self.serving.is_some() || self.options.dedicated.is_some())
.then(|| self.native_acting_id());
let remaining = amount.saturating_sub(
(u64::from(repaired) * u64::from(amount) / u64::from(to_repair_xp)) as u32,
);
if let Some(player) = player
&& let Some(name) = self
.native_server_players()
.into_iter()
.find(|p| p.uuid == player)
.map(|p| p.name)
{
self.native_player_item_mend(player, &name, &item, repaired, amount);
}
if remaining == 0 {
return 0;
}
+3 -3
View File
@@ -706,6 +706,7 @@ impl Game<'_> {
}
}
}
self.native_portal_create("minecraft:the_end", origin);
// Direction.WEST.toYRot().
return Ok(Some((target, Some(90.0))));
}
@@ -1302,8 +1303,8 @@ impl Game<'_> {
target
};
let survive = self.world.random.next_int_bound(5).unwrap_or(0) > 0;
self.next_entity += 1;
let id = self.next_entity;
let at = [p.position.x, p.position.y, p.position.z];
let id = self.take_entity_id();
self.end.eyes.push(EyeOfEnder {
id,
position: start,
@@ -1315,7 +1316,6 @@ impl Game<'_> {
});
let r = self.world.random.next_float();
let pitch = 0.33 + r * (0.5 - 0.33);
let at = [p.position.x, p.position.y, p.position.z];
self.audio
.play("minecraft:entity.ender_eye.launch", Some(at), 1.0, pitch);
self.consume_held();
@@ -712,6 +712,17 @@ mod tests {
assert_eq!(range, 48.0);
}
/// `Entity.shouldRenderAtSqrDistance` for a dropped item (`ItemEntity`
/// keeps the default and its box is 0.25 on each side): 24 blocks at
/// render distance 12, 32 at 16, 40 from 20 on; never 64.
#[test]
fn a_dropped_item_is_drawn_sixteen_blocks_times_the_view_scale() {
let item = glam::DVec3::splat(0.25);
assert_eq!(default_range(item, entity_view_scale(12, 1.0)), 24.0);
assert_eq!(default_range(item, entity_view_scale(16, 1.0)), 32.0);
assert_eq!(default_range(item, entity_view_scale(32, 1.0)), 40.0);
}
/// `Entity.shouldRenderAtSqrDistance` on a broken box: the size falls
/// back to one block.
#[test]
+10 -1
View File
@@ -64,6 +64,14 @@ impl Events {
}
self.log.push_back(event);
}
/// An event from outside the once-a-turn comparison (`user-input`).
pub(super) fn note(&mut self, tick: u64, event: Value) {
self.push(tick, event);
}
/// Whether a `wait-for` is waiting: its agent is still watching.
pub(super) fn has_waits(&self) -> bool {
!self.waits.is_empty()
}
fn since(&self, seq: u64) -> impl Iterator<Item = &Value> {
self.log
.iter()
@@ -181,7 +189,7 @@ impl Game<'_> {
}
}
fn loaded(&self) -> bool {
pub(super) fn loaded(&self) -> bool {
self.title.is_none()
&& self.player.is_some()
&& self.streaming.pending() + self.pending_uploads.len() == 0
@@ -207,6 +215,7 @@ impl Game<'_> {
None => self.ui.chat.recent_lines(new_chat),
};
for event in diff(before, &now, &lines) {
self.repro.note_event(self.ticks, &event);
self.events.push(self.ticks, event);
}
}
+7 -3
View File
@@ -224,10 +224,10 @@ impl Game<'_> {
let lifetime = 10 * (1 + i32::from(flight))
+ r.next_int_bound(6).unwrap_or(0)
+ r.next_int_bound(7).unwrap_or(0);
// An id from the entity counter at once, so a command can name it.
self.next_entity += 1;
// Entity.<init>: the id comes from the one counter at construction.
let id = self.take_entity_id();
Rocket {
id: self.next_entity,
id,
position: at,
previous: at,
velocity,
@@ -345,6 +345,9 @@ impl Game<'_> {
self.consume_held();
}
self.swing_arm();
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_elytra_boost(player, &name);
}
true
}
pub(super) fn tick_rockets(&mut self) {
@@ -517,6 +520,7 @@ impl Game<'_> {
let v = [r.velocity.x, r.velocity.y, r.velocity.z];
self.particles
.fireworks(at, v, r.explosions.clone(), camera);
self.native_firework_explode(r.id, at, r.explosions.len());
}
fn rocket_hurt_mobs(&mut self, hits: &[(u32, f32)], attacker: Option<Who>, at: [f64; 3]) {
for &(id, amount) in hits {
+15
View File
@@ -210,6 +210,8 @@ impl Game<'_> {
} else if let Some(item) = self.dropped.iter_mut().find(|i| i.id == id) {
item.velocity = pulled(item.velocity);
hooked_item = true;
} else if let Some(t) = self.tnts.iter_mut().find(|t| t.id != 0 && t.id == id) {
t.velocity = pulled(t.velocity);
} else if self.creatures.hook_targets.iter().any(|t| t.id == id) {
// A player: the hub moves them when the tick ends.
self.creatures
@@ -607,6 +609,13 @@ impl Game<'_> {
.iter()
.find(|i| touches(i.bounds()))
.map(|i| i.id)
})
// A lit TNT is pickable too (`PrimedTnt.isPickable`).
.or_else(|| {
self.tnts
.iter()
.find(|t| t.id != 0 && touches(t.bounds()))
.map(|t| t.id)
});
if let Some(id) = hit {
// Projectile.onHit on an entity: PROJECTILE_LAND.
@@ -674,6 +683,12 @@ impl Game<'_> {
.iter()
.find(|i| i.id == id)
.map(|i| (i.position, horizon_client::entities::ItemEntity::WIDTH))
})
.or_else(|| {
self.tnts
.iter()
.find(|t| t.id != 0 && t.id == id)
.map(|t| (t.position, super::tnt::PrimedTnt::SIZE))
});
match hooked {
Some((at, height)) => h.position = Vec3::new(at.x, at.y + height * 0.8, at.z),
+48
View File
@@ -125,6 +125,17 @@ impl Game<'_> {
.iter()
.any(|o| o.id != f.id && o.facing == f.facing && overlaps(o.bounds()))
}
/// Asks the mods whether the acting guest may place a hanging entity
/// of type `kind` at `pos` (`native_hanging_place`): true when one
/// cancelled it. A game's own player is not asked.
pub(super) fn hanging_place_cancelled(&mut self, kind: &str, pos: [i32; 3]) -> bool {
let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) else {
return false;
};
let mut cancel = false;
self.native_hanging_place(uuid, &name, kind, pos, &mut cancel);
cancel
}
/// `ItemFrameItem.useOn`.
pub(super) fn place_item_frame(&mut self, hit: &Hit, stack: &ItemStack) -> bool {
let glow = match &*stack.item {
@@ -162,6 +173,14 @@ impl Game<'_> {
if !self.frame_survives(&frame) {
return false;
}
let kind = if glow {
"minecraft:glow_item_frame"
} else {
"minecraft:item_frame"
};
if self.hanging_place_cancelled(kind, pos) {
return true;
}
self.next_entity += 1;
self.audio
.play(&sound(&frame, "place"), Some(center(&frame)), 1.0, 1.0);
@@ -224,11 +243,14 @@ impl Game<'_> {
return false;
}
let at = center(&self.item_frames[i]);
let entity_id = self.item_frames[i].id;
let action;
if self.item_frames[i].item.is_none() {
let Some(held) = self.held().cloned() else {
return false;
};
self.item_frames[i].item = Some(ItemStack { count: 1, ..held });
action = "put";
self.audio
.play(&sound(&self.item_frames[i], "add_item"), Some(at), 1.0, 1.0);
if !self.creative {
@@ -237,12 +259,21 @@ impl Game<'_> {
} else {
let f = &mut self.item_frames[i];
f.rotation = (f.rotation + 1) % 8;
action = "rotate";
let s = sound(f, "rotate_item");
self.audio.play(&s, Some(at), 1.0, 1.0);
}
let p = self.item_frames[i].pos;
self.world.modified.insert((p[0] >> 4, p[2] >> 4));
self.frame_output_changed(p);
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
let frame = self.item_frames.iter().find(|f| f.id == entity_id);
let item = frame
.and_then(|f| f.item.as_ref())
.map_or_else(String::new, |s| s.item.to_string());
let rotation = frame.map_or(0, |f| f.rotation);
self.native_player_item_frame_change(player, &name, entity_id, action, &item, rotation);
}
self.swing_arm();
true
}
@@ -264,6 +295,13 @@ impl Game<'_> {
return true;
}
let with_frame = self.item_frames[i].item.is_none();
let entity_id = self.item_frames[i].id;
let old_item = self.item_frames[i]
.item
.as_ref()
.map_or("", |s| &*s.item)
.to_owned();
let rotation = self.item_frames[i].rotation;
let at = center(&self.item_frames[i]);
let f = if with_frame {
self.item_frames.remove(i)
@@ -284,6 +322,16 @@ impl Game<'_> {
if f.item.is_some() {
self.frame_output_changed(f.pos);
}
if let (Some(player), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone()) {
self.native_player_item_frame_change(
player,
&name,
entity_id,
if with_frame { "break" } else { "remove_item" },
if with_frame { "" } else { &old_item },
rotation,
);
}
true
}
/// `ItemFrame.setItem` / `setRotation`: `updateNeighbourForOutputSignal`, so
@@ -0,0 +1,675 @@
//! Who caused an input event: a person at the keyboard (`user`), an agent on the
//! control socket (`agent`), or the game itself (`game`, made-up input no one
//! asked for). Agents read this with `input-log` and the `input` section of
//! `state` and `status`, so a script can tell that a person took over instead of
//! mistaking their keystrokes for its own. A `user-input` event also lands in
//! `events` (and so in `wait-for event user-input`) when a person steps in
//! while an agent was driving.
//!
//! The events are also what a repro recording writes to `inputs.jsonl`
//! (game/repro.rs).
use super::{Game, Screen};
use horizon_client::{Result, keybinds};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::{
collections::VecDeque,
sync::mpsc::Sender,
time::{Duration, Instant},
};
use winit::{
event::{ElementState, MouseScrollDelta, WindowEvent},
keyboard::PhysicalKey,
};
pub(super) const HELP: &[&str] = &[
"input-log [--since SEQ] [--limit N] [--origin user|agent|game] recent input and who caused it",
];
/// Input events kept for `input-log`.
const KEPT: usize = 512;
/// A person's input this soon after an agent command is an intrusion.
const INTRUSION_GAP: Duration = Duration::from_secs(5);
/// At most one `user-input` event per this long, so holding a key does not flood.
const INTRUSION_QUIET: Duration = Duration::from_secs(2);
/// Who caused an input event.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(super) enum InputOrigin {
/// The window's own events: someone at this computer's keyboard, mouse or pad.
User,
/// The control socket: `horizon-ctl`, an MCP client, or a script.
Agent,
/// Neither: input the game made up itself (pad menus, UI tasks).
Game,
}
impl InputOrigin {
pub(super) fn name(self) -> &'static str {
match self {
InputOrigin::User => "user",
InputOrigin::Agent => "agent",
InputOrigin::Game => "game",
}
}
fn index(self) -> usize {
self as usize
}
fn parse(name: &str) -> Option<Self> {
match name {
"user" => Some(InputOrigin::User),
"agent" => Some(InputOrigin::Agent),
"game" => Some(InputOrigin::Game),
_ => None,
}
}
}
/// One input event, as `input-log` shows it and the recording writes it.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub(super) struct TraceEvent {
pub seq: u64,
pub tick: u64,
/// Milliseconds since the first event this run; the clock a replay keeps.
pub ms: u64,
pub origin: InputOrigin,
#[serde(flatten)]
pub kind: TraceKind,
}
/// What was pressed, clicked or typed. Key and button names are the game's own
/// (`key.keyboard.w`, `key.mouse.left`), so a recording can feed them back.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub(super) enum TraceKind {
Key {
key: String,
/// `down`, `up` or `repeat` (the system's auto-repeat).
action: String,
},
Mouse {
button: String,
action: String,
},
Wheel {
x: f64,
y: f64,
/// The window gave lines (a notched wheel) rather than pixels.
lines: bool,
},
/// The pointer's screen position, kept while a screen is open: GUI clicks
/// are replayed at it.
Cursor {
x: f64,
y: f64,
},
/// Relative mouse motion, which turns the view.
Motion {
x: f64,
y: f64,
},
Pad {
button: String,
action: String,
},
/// A control-socket line: `key`, `mouse`, `goto`, anything an agent ran.
Command {
line: String,
},
}
impl TraceKind {
pub(super) fn name(&self) -> &'static str {
match self {
TraceKind::Key { .. } => "key",
TraceKind::Mouse { .. } => "mouse",
TraceKind::Wheel { .. } => "wheel",
TraceKind::Cursor { .. } => "cursor",
TraceKind::Motion { .. } => "motion",
TraceKind::Pad { .. } => "pad",
TraceKind::Command { .. } => "command",
}
}
}
/// Commands that only read. Polling them must not flood the ring or make an
/// agent that is merely watching look like it acted.
const READ_ONLY: &[&str] = &[
"state",
"status",
"events",
"wait-for",
"input-log",
"record-status",
"record-list",
"looking-at",
"entities",
"block",
"blocks",
"light",
"inventory",
"screen",
"menu",
"messages",
"worlds",
"servers",
"options-get",
"stats",
"logs",
"ui",
];
/// Commands that steer the session rather than the game: recording them as
/// input would put `record-stop` in a replay it must not run.
const SESSION: &[&str] = &[
"record-start",
"record-stop",
"record-replay",
"repro-export",
"quit",
"launch",
"mcp",
"bench",
"sleep",
];
/// Lines whose arguments can carry someone's data: the trace keeps the command
/// name and drops the rest.
const PRIVATE_ARGUMENTS: &[&str] = &[
"chat", "command", "type", "signedit", "connect", "book", "publish",
];
/// Whether `line` only reads.
fn is_read_only(line: &str) -> bool {
line.split_whitespace()
.next()
.is_some_and(|name| READ_ONLY.contains(&name))
}
/// Whether `line` steers the session, not the game.
fn is_session_command(line: &str) -> bool {
line.split_whitespace()
.next()
.is_some_and(|name| SESSION.contains(&name))
}
/// The line as the trace keeps it: a command whose arguments are private loses
/// them (`chat <redacted>`).
fn redact_command(line: &str) -> String {
match line.split_whitespace().next() {
Some(name) if PRIVATE_ARGUMENTS.contains(&name) => format!("{name} <redacted>"),
_ => line.to_owned(),
}
}
/// The recent input, the counters and the recording's line writer.
#[derive(Default)]
pub(super) struct InputTrace {
seq: u64,
log: VecDeque<TraceEvent>,
counts: [u64; 3],
last: Option<InputOrigin>,
last_user: Option<Instant>,
last_agent: Option<Instant>,
/// The person's events since the last agent command.
user_since_agent: u64,
quiet_until: Option<Instant>,
/// The first event's wall clock and tick.
started: Option<(Instant, u64)>,
/// The recording's `inputs.jsonl` writer; `None` when nothing records.
writer: Option<Sender<String>>,
written: u64,
/// A replay feeds events back: they are not new input and are not recorded.
suppress: bool,
}
impl InputTrace {
/// Record one event; whether it is a person intruding on an agent's run.
/// `agent_active`: an agent is watching or waiting, not only recently heard.
fn record(
&mut self,
tick: u64,
origin: InputOrigin,
kind: TraceKind,
agent_active: bool,
) -> bool {
if self.suppress {
return false;
}
let now = Instant::now();
let started = self.started.get_or_insert((now, tick)).0;
let ms = now.duration_since(started).as_millis() as u64;
let intrusion = origin == InputOrigin::User
&& (agent_active
|| self
.last_agent
.is_some_and(|t| now.duration_since(t) < INTRUSION_GAP))
&& self.quiet_until.is_none_or(|t| now >= t);
if intrusion {
self.quiet_until = Some(now + INTRUSION_QUIET);
}
self.seq += 1;
let event = TraceEvent {
seq: self.seq,
tick,
ms,
origin,
kind,
};
if self.writer.is_some() {
let mut failed = false;
match serde_json::to_string(&event) {
Ok(line) => {
if let Some(writer) = &self.writer {
failed = writer.send(line).is_err();
}
}
Err(e) => log::warn!("Horizon: input trace: {e}"),
}
if failed {
self.writer = None;
log::warn!("Horizon: input trace stopped: its writer is gone");
} else {
self.written += 1;
}
}
if self.log.len() == KEPT {
self.log.pop_front();
}
self.log.push_back(event);
self.counts[origin.index()] += 1;
self.last = Some(origin);
match origin {
InputOrigin::User => {
self.last_user = Some(now);
self.user_since_agent += 1;
}
InputOrigin::Agent => {
self.last_agent = Some(now);
self.user_since_agent = 0;
}
InputOrigin::Game => {}
}
intrusion
}
/// An agent line that is not input (a poll, a wait): it still means an
/// agent is watching, so a person's next key is an intrusion.
pub(super) fn agent_activity(&mut self) {
if self.suppress {
return;
}
self.last_agent = Some(Instant::now());
self.user_since_agent = 0;
}
/// The `input` section of `state` and `status`.
pub(super) fn summary(&self) -> Value {
json!({
"last_origin": self.last.map(InputOrigin::name),
"counts": {
"user": self.counts[InputOrigin::User.index()],
"agent": self.counts[InputOrigin::Agent.index()],
"game": self.counts[InputOrigin::Game.index()],
},
"user_since_agent": self.user_since_agent,
"last_user_age_s": self
.last_user
.map(|t| (t.elapsed().as_secs_f64() * 10.0).round() / 10.0),
"events": self.seq,
})
}
/// Start writing the events to a recording.
pub(super) fn start_file(&mut self, writer: Sender<String>) {
self.writer = Some(writer);
self.written = 0;
}
/// Stop writing; how many events the recording received.
pub(super) fn stop_file(&mut self) -> u64 {
self.writer = None;
std::mem::take(&mut self.written)
}
/// How many events this run has seen.
pub(super) fn events(&self) -> u64 {
self.seq
}
/// A replay's own events are not new input.
pub(super) fn set_suppress(&mut self, suppress: bool) {
self.suppress = suppress;
}
/// `input-log`.
pub(super) fn command(&self, words: &[&str]) -> Result<Option<Value>> {
if words[0] != "input-log" {
return Ok(None);
}
let mut since = 0;
let mut limit = usize::MAX;
let mut origin = None;
let mut i = 1;
while i < words.len() {
let value = words
.get(i + 1)
.ok_or_else(|| format!("{} takes a value", words[i]))?;
match words[i] {
"--since" => since = value.parse().map_err(|_| "--since takes a number")?,
"--limit" => limit = value.parse().map_err(|_| "--limit takes a number")?,
"--origin" => {
origin = Some(
InputOrigin::parse(value).ok_or("--origin takes user, agent or game")?,
)
}
other => return Err(format!("input-log: unknown argument {other}").into()),
}
i += 2;
}
let mut events: Vec<&TraceEvent> = self
.log
.iter()
.filter(|e| e.seq > since && origin.is_none_or(|o| e.origin == o))
.collect();
if events.len() > limit {
events.drain(..events.len() - limit);
}
Ok(Some(json!({
"events": events,
"last": self.seq,
"input": self.summary(),
})))
}
}
impl Game<'_> {
/// An agent's control line: input unless it only reads, which still counts
/// as the agent watching.
pub(super) fn note_socket_line(&mut self, line: &str) {
if is_read_only(line) || is_session_command(line) {
self.input_trace.agent_activity();
return;
}
self.note_input(
InputOrigin::Agent,
TraceKind::Command {
line: redact_command(line),
},
);
}
/// Whether anything can read the trace: the control socket (agent control
/// on), the repro shortcuts, or a recording under way. Otherwise every key
/// and mouse motion skips it, so a player who uses none of them pays
/// nothing per input event.
fn tracing_input(&self) -> bool {
self.control.is_some() || self.settings.extras.repro_keys || self.repro.recording_active()
}
/// Record an input event and, when a person steps into an agent's run, tell
/// every watcher once.
pub(super) fn note_input(&mut self, origin: InputOrigin, kind: TraceKind) {
if !self.tracing_input() {
return;
}
let kind_name = kind.name();
// An agent waiting on a task or a wait, or a client still connected, is
// watching even when its last line was minutes ago.
let agent_active = self.control.as_ref().is_some_and(|c| c.clients() > 0)
|| self.agent_busy()
|| self.events.has_waits();
if self
.input_trace
.record(self.ticks, origin, kind, agent_active)
{
let event = json!({ "event": "user-input", "input": kind_name });
self.repro.note_event(self.ticks, &event);
self.events.note(self.ticks, event);
log::info!(
"Horizon: {kind_name} input from a person while an agent was driving \
(input-log has the run)"
);
}
}
/// Keep the window events that are input. Agent-made window events are left
/// out: the control-socket line that caused them is recorded instead.
pub(super) fn trace_window_event(&mut self, origin: InputOrigin, event: &WindowEvent) {
if origin == InputOrigin::Agent || !self.tracing_input() {
return;
}
match event {
WindowEvent::KeyboardInput {
event,
is_synthetic,
..
} => {
let PhysicalKey::Code(code) = event.physical_key else {
return;
};
let Some(name) = keybinds::key_name(code) else {
return;
};
let action = match (event.state, event.repeat) {
(ElementState::Released, _) => "up",
(_, true) => "repeat",
(ElementState::Pressed, false) => "down",
};
// The platform made this one up (an IME's dead key): not a person.
let origin = if *is_synthetic {
InputOrigin::Game
} else {
origin
};
self.note_input(
origin,
TraceKind::Key {
key: name.to_owned(),
action: action.to_owned(),
},
);
}
WindowEvent::MouseInput { state, button, .. } => self.note_input(
origin,
TraceKind::Mouse {
button: keybinds::mouse_name(*button),
action: match state {
ElementState::Pressed => "down".to_owned(),
ElementState::Released => "up".to_owned(),
},
},
),
WindowEvent::MouseWheel { delta, .. } => {
let (x, y, lines) = match delta {
MouseScrollDelta::LineDelta(x, y) => (f64::from(*x), f64::from(*y), true),
MouseScrollDelta::PixelDelta(p) => (p.x, p.y, false),
};
self.note_input(origin, TraceKind::Wheel { x, y, lines });
}
// GUI clicks land where the pointer is; in the world the pointer is
// grabbed and turns the view through `device_event` instead.
WindowEvent::CursorMoved { position, .. } if self.screen != Screen::Playing => {
self.note_input(
origin,
TraceKind::Cursor {
x: position.x,
y: position.y,
},
);
}
_ => {}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn key(name: &str) -> TraceKind {
TraceKind::Key {
key: name.to_owned(),
action: "down".to_owned(),
}
}
#[test]
fn origins_round_trip() {
for origin in [InputOrigin::User, InputOrigin::Agent, InputOrigin::Game] {
assert_eq!(InputOrigin::parse(origin.name()), Some(origin));
}
assert_eq!(InputOrigin::parse("nobody"), None);
}
#[test]
fn the_ring_keeps_the_newest_and_counts_every_origin() {
let mut trace = InputTrace::default();
for i in 0..=(KEPT as u64 + 10) {
trace.record(i, InputOrigin::User, key("key.keyboard.w"), false);
}
assert_eq!(trace.log.len(), KEPT);
assert_eq!(trace.seq, KEPT as u64 + 11);
assert_eq!(trace.log.front().unwrap().seq, 12);
assert_eq!(trace.counts[InputOrigin::User.index()], KEPT as u64 + 11);
}
#[test]
fn a_person_after_an_agent_is_an_intrusion_once() {
let mut trace = InputTrace::default();
trace.record(1, InputOrigin::Agent, key("key.keyboard.w"), false);
assert!(trace.record(2, InputOrigin::User, key("key.keyboard.a"), false));
// The quiet period keeps a held key from flooding the event log.
assert!(!trace.record(3, InputOrigin::User, key("key.keyboard.a"), false));
assert_eq!(trace.user_since_agent, 2);
// An agent command resets the count.
trace.record(4, InputOrigin::Agent, key("key.keyboard.w"), false);
assert_eq!(trace.user_since_agent, 0);
}
#[test]
fn no_intrusion_without_an_agent() {
let mut trace = InputTrace::default();
assert!(!trace.record(1, InputOrigin::User, key("key.keyboard.w"), false));
// The game's own input is not a person either.
trace.record(2, InputOrigin::Agent, key("key"), false);
assert!(!trace.record(3, InputOrigin::Game, key("key.keyboard.w"), false));
}
#[test]
fn input_log_filters_since_origin_and_limit() {
let mut trace = InputTrace::default();
for (i, origin) in [
InputOrigin::Agent,
InputOrigin::User,
InputOrigin::Game,
InputOrigin::User,
]
.into_iter()
.enumerate()
{
trace.record(i as u64, origin, key("key.keyboard.w"), false);
}
let all = trace.command(&["input-log"]).unwrap().unwrap();
assert_eq!(all["events"].as_array().unwrap().len(), 4);
assert_eq!(all["last"], 4);
let users = trace
.command(&["input-log", "--origin", "user"])
.unwrap()
.unwrap();
assert_eq!(users["events"].as_array().unwrap().len(), 2);
assert!(
users["events"]
.as_array()
.unwrap()
.iter()
.all(|e| e["origin"] == "user")
);
let newest = trace
.command(&["input-log", "--since", "2", "--limit", "1"])
.unwrap()
.unwrap();
let events = newest["events"].as_array().unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0]["seq"], 4);
}
#[test]
fn the_recording_writer_gets_one_json_line_per_event() {
let (tx, rx) = std::sync::mpsc::channel();
let mut trace = InputTrace::default();
trace.start_file(tx);
trace.record(7, InputOrigin::User, key("key.keyboard.w"), false);
trace.record(8, InputOrigin::Agent, key("key.keyboard.s"), false);
assert_eq!(trace.stop_file(), 2);
let lines: Vec<String> = rx.try_iter().collect();
assert_eq!(lines.len(), 2);
let first: Value = serde_json::from_str(&lines[0]).unwrap();
assert_eq!(first["tick"], 7);
assert_eq!(first["origin"], "user");
assert_eq!(first["kind"], "key");
assert_eq!(first["key"], "key.keyboard.w");
}
#[test]
fn private_arguments_are_dropped() {
assert_eq!(redact_command("chat /login hunter2"), "chat <redacted>");
assert_eq!(
redact_command("connect friend:ABCD-1234"),
"connect <redacted>"
);
assert_eq!(
redact_command("key key.keyboard.w down"),
"key key.keyboard.w down"
);
}
#[test]
fn polls_are_read_only() {
for line in [
"state",
"wait-for event death",
"record-status",
"input-log --origin user",
"ui",
] {
assert!(is_read_only(line), "{line}");
}
for line in ["key w down", "record-start x", "mouse left click"] {
assert!(!is_read_only(line), "{line}");
}
}
#[test]
fn session_commands_are_not_input() {
for line in [
"record-start x",
"record-stop --export",
"record-replay x",
"repro-export x",
"quit",
] {
assert!(is_session_command(line), "{line}");
}
assert!(!is_session_command("key w down"));
}
#[test]
fn an_agent_still_watching_makes_a_later_person_an_intrusion() {
let mut trace = InputTrace::default();
trace.record(1, InputOrigin::Agent, key("key"), false);
// The agent is waiting on a task or a wait: no five-second window.
assert!(trace.record(2, InputOrigin::User, key("key.keyboard.a"), true));
}
#[test]
fn a_suppressed_replay_records_nothing() {
let mut trace = InputTrace::default();
trace.set_suppress(true);
assert!(!trace.record(1, InputOrigin::User, key("key.keyboard.w"), false));
assert_eq!(trace.seq, 0);
assert!(trace.summary()["last_origin"].is_null());
}
}
+26 -3
View File
@@ -32,12 +32,19 @@ use std::{
sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicI32, AtomicU16, AtomicU32, Ordering},
mpsc::{Receiver, Sender, channel, sync_channel},
mpsc::{Receiver, RecvTimeoutError, Sender, channel, sync_channel},
},
thread::JoinHandle,
time::{Duration, Instant},
};
/// How long the window waits for the server thread to answer a `call`.
const CALL_TIMEOUT: Duration = Duration::from_secs(10);
/// The error of a `call` without an answer: the server stopped, or it was
/// busy past `CALL_TIMEOUT` (the log says which).
pub(super) const NO_ANSWER: &str =
"the integrated server stopped or did not answer within 10 s (the game log says which)";
/// `LevelLoadProgressTracker.EXPECTED_PLAYER_CHUNKS`: the 7x7 chunks around the spawn.
const PLAYER_CHUNKS: usize = 49;
@@ -177,7 +184,9 @@ impl Integrated {
Some(self.flags.published.load(Ordering::Relaxed)).filter(|&p| p != 0)
}
/// `executeBlocking`: run `f` on the server thread between two ticks and
/// wait for its answer; `None` once the server has stopped.
/// wait for its answer; `None` once the server has stopped, or when it has
/// not answered within `CALL_TIMEOUT` (`f` still runs there later). The log
/// tells the two apart.
pub fn call<T: Send + 'static>(
&self,
f: impl FnOnce(&mut Game<'_>) -> T + Send + 'static,
@@ -188,7 +197,21 @@ impl Integrated {
let _ = tx.send(f(g));
}))
.ok()?;
rx.recv_timeout(Duration::from_secs(10)).ok()
match rx.recv_timeout(CALL_TIMEOUT) {
Ok(answer) => Some(answer),
Err(RecvTimeoutError::Timeout) => {
log::warn!(
"Horizon: the server thread did not answer within {} s (running: {}, \
autosaving: {}, smoothed tick {:.1} ms)",
CALL_TIMEOUT.as_secs(),
!self.stopped(),
self.is_saving(),
self.smoothed_tick_ms()
);
None
}
Err(RecvTimeoutError::Disconnected) => None,
}
}
/// `IntegratedServer.halt(true)`: returns once the world is saved and let go.
pub fn stop(mut self) -> Result<()> {
+11 -1
View File
@@ -483,12 +483,22 @@ impl Game<'_> {
}
_ => None,
});
// Entity.shouldRenderAtSqrDistance for the item's 0.25 box: 16 blocks
// times the view scale (32 at render distance 16), from its current
// position. A pickup particle is not an entity and keeps 64 blocks.
let range =
super::entity_culling::default_range(glam::DVec3::splat(0.25), self.view_scale());
for (item, at, partial, entity) in live.chain(taken) {
let entity_start = out.len();
self.mark_motion(entity_start, (2 << 32) | u64::from(item.id));
let q = item.position;
let pos = glam::DVec3::from_array(at) - camera;
if pos.length_squared() > 64.0 * 64.0 {
let far = if entity {
(glam::DVec3::new(q.x, q.y, q.z) - camera).length_squared() >= range * range
} else {
pos.length_squared() > 64.0 * 64.0
};
if far {
continue;
}
// EntityRenderer.shouldRender: the item's 0.25 box, grown by 0.5,
+44 -4
View File
@@ -1133,7 +1133,7 @@ impl Game<'_> {
// The hub is the server thread's.
let status = server
.call(|g| g.lan_status())
.ok_or("the integrated server stopped")?;
.ok_or(super::integrated::NO_ANSWER)?;
return Ok(Some(status));
}
match words.get(1).copied() {
@@ -1225,7 +1225,7 @@ impl Game<'_> {
// ShareToLanScreen: IntegratedServer.publishServer.
server
.call(move |g| g.publish(settings))
.ok_or("the integrated server stopped")??
.ok_or(super::integrated::NO_ANSWER)??
} else if self.serving() {
return self.publish(settings).map(drop).map_err(Into::into);
} else {
@@ -1708,8 +1708,8 @@ impl Game<'_> {
host.hub.config.guest_commands = effective;
host.hub.config.force_game_mode = force;
enforce_guest_game_mode(host, &self.world, self.collision);
host.hub.resend_commands();
}
self.resend_commands_filtered();
}
pub(super) fn lan_port(&self) -> Option<u16> {
@@ -1884,6 +1884,27 @@ impl Game<'_> {
}
}
HostEvent::Joined(conn) => {
// The mods may refuse the login before the player is placed.
if let Some((uuid, name)) =
host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
{
let mut cancel = false;
let mut reason = None;
self.native_player_login(uuid, &name, &mut cancel, &mut reason);
if cancel {
let reason = match reason {
Some(text) => {
horizon_client::lan::data::component_nbt(&text.component())
}
None => horizon_protocol::text::translatable(
"multiplayer.disconnect.generic",
&[],
),
};
host.hub.kick(conn, reason);
continue;
}
}
self.lan_guest_joined(&mut host, conn, state, entities, spawn);
if self.options.dedicated.is_some()
&& let Some(g) = host.hub.guest(conn)
@@ -1996,6 +2017,18 @@ impl Game<'_> {
HostEvent::Flight { conn, flying } => {
self.lan_flight_toggled(&mut host, conn, flying);
}
HostEvent::MainHandChanged(conn, left) => {
if let Some(g) = host.hub.guest(conn) {
let (uuid, name) = (g.uuid, g.name.clone());
self.native_player_change_main_hand(uuid, &name, left);
}
}
HostEvent::LocaleChanged(conn, locale) => {
if let Some(g) = host.hub.guest(conn) {
let (uuid, name) = (g.uuid, g.name.clone());
self.native_player_locale_change(uuid, &name, &locale);
}
}
HostEvent::Packet(conn, packet) => {
self.lan_guest_packet(&mut host, conn, *packet);
if !host.commands.is_empty() {
@@ -2381,6 +2414,8 @@ impl Game<'_> {
g.dimension = in_place;
}
self.load_guest_records(host, conn);
// The mods say which commands the newcomer's tree keeps.
self.native_filter_commands(host, Some(conn));
let mut theirs = States::default();
let entities = if elsewhere {
if let Err(e) = self.enter_level(Some(host), level) {
@@ -2857,7 +2892,12 @@ impl Game<'_> {
.map(|s| s.as_ref().map(|s| s.item.clone()));
m.extra.armor = g.armor.clone();
m.extra.crouching = g.sneaking;
m.extra.avatar_pose = g.pose();
let from_pose = m.extra.avatar_pose;
let to_pose = g.pose();
m.extra.avatar_pose = to_pose;
if from_pose != to_pose {
self.native_entity_pose_change(g.uuid, &g.name, from_pose.name(), to_pose.name());
}
m.extra.sprinting = g.sprinting;
// Entity.DATA_POSE and the shared and living flags the others
// get (hub::guest_body): gliding, swimming, Riptide's spin.
+252 -15
View File
@@ -663,6 +663,28 @@ impl Game<'_> {
}
// A totem that saved the guest this turn (its hurt set it off).
let totem = self.apply_totem();
// The mods hear the food level the turn changed (eating, hunger,
// starving...), once for the change it made as a whole, with the
// guest's state still swapped in; they may change or undo it before it
// reaches the guest.
if let Some(now) = self.player.as_ref().map(|p| p.health.food.food)
&& let Some(g) = host.hub.guests.get(swap.i)
&& g.food != now
{
let (uuid, name, from, saturation) = (g.uuid, g.name.clone(), g.food, g.saturation);
let (mut to, mut cancel) = (now, false);
self.native_food_level_changed(uuid, &name, from, &mut to, &mut cancel);
if let Some(p) = self.player.as_mut() {
if cancel {
p.health.food.food = from;
p.health.food.saturation = saturation;
} else if to != now {
p.health.food.food = to.clamp(0, 20);
p.health.food.saturation =
p.health.food.saturation.min(p.health.food.food as f32);
}
}
}
let GuestSwap {
conn,
i,
@@ -1025,6 +1047,8 @@ impl Game<'_> {
if let Some(mob) = guest_mount {
self.lan_open_mount(host, conn, mob);
}
// The turn's teleports are in the hub now: the mods hear them.
self.flush_teleport_notes(host);
}
/// `ServerLevel.broadcastDamageEvent` for a guest that took an unblocked hit.
@@ -1098,6 +1122,31 @@ impl Game<'_> {
self.creative = self.mode == GameMode::Creative;
}
/// Whether the mods let the guest open the menu `kind` (`native_container_opened`),
/// heard with the guest's state swapped in. Asked before the menu that
/// was open closes.
pub(super) fn guest_may_open(
&mut self,
host: &mut LanHost,
conn: u32,
kind: &str,
pos: Option<[i32; 3]>,
) -> bool {
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
return true;
};
let mut cancel = false;
let mut hook =
|game: &mut Self| game.native_container_opened(uuid, &name, kind, pos, &mut cancel);
// The guest whose turn it is is in place already.
if self.lan_acting == Some(conn) {
hook(self);
} else {
self.as_guest(host, conn, hook);
}
!cancel
}
/// Hand a container the guest opened over (ServerPlayer.openMenu);
/// false when it is not one a guest can open.
fn lan_open_container(&mut self, host: &mut LanHost, conn: u32, pos: (i32, i32, i32)) -> bool {
@@ -1202,10 +1251,15 @@ impl Game<'_> {
Slots::Block(k) => [Some(*k), None],
_ => [None, None],
};
let locked = halves
.iter()
.flatten()
.any(|&k| self.container_locked_for(k, spectator, hand.as_ref()));
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
return false;
};
let mut locked = false;
for key in halves.iter().flatten().copied() {
let mut check = self.container_locked_for(key, spectator, hand.as_ref());
self.native_block_lock_check(uuid, &name, [key.0, key.1, key.2], &mut check);
locked |= check;
}
if locked {
host.hub.send_to(
conn,
@@ -1246,6 +1300,15 @@ impl Game<'_> {
if !horizon_client::containers::menu_reaches(brand, menu) {
return false;
}
// The mods may stop the menu; the click is spent.
if !self.guest_may_open(host, conn, menu, Some([pos.0, pos.1, pos.2])) {
return true;
}
let mut cancel = false;
self.native_inventory_block_start(uuid, &name, [pos.0, pos.1, pos.2], &mut cancel);
if cancel {
return false;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let hidden = self.native_hidden_slots(pos, items.len());
@@ -1294,6 +1357,10 @@ impl Game<'_> {
else {
return false;
};
// The mods may stop the menu; the click is spent.
if !self.guest_may_open(host, conn, menu, None) {
return true;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
host.hub
@@ -1784,6 +1851,25 @@ impl Game<'_> {
// SWAP_ITEM_WITH_OFFHAND: the hands swap and the item in use stops
// (ServerGamePacketListenerImpl.handlePlayerAction), not for spectators.
7 if !spectator => {
// The mods hear the swap first, and may stop it: the
// client, which swapped at once, gets its slots back.
let (uuid, name) = {
let g = &host.hub.guests[gi];
(g.uuid, g.name.clone())
};
// With the player's state swapped in, so that the helpers
// reach them.
let cancel = self
.as_guest(host, conn, |g| {
let mut cancel = false;
g.native_player_swap_hands(uuid, &name, &mut cancel);
cancel
})
.unwrap_or(false);
if cancel {
host.hub.send_inventory(gi);
return;
}
let g = &mut host.hub.guests[gi];
let slot = g.selected;
std::mem::swap(&mut g.inventory[slot], &mut g.offhand);
@@ -1796,6 +1882,8 @@ impl Game<'_> {
g.player_on_equip("offhand", main.as_ref(), offhand.as_ref());
g.player_on_equip("mainhand", offhand.as_ref(), main.as_ref());
});
let guest = &host.hub.guests[gi];
self.native_player_swap_with_equipment_slot(guest.uuid, &guest.name, "offhand");
}
// STAB: `PiercingWeapon.attack` of a left click with a spear.
8 if !spectator => self.guest_jab(host, conn),
@@ -1803,11 +1891,37 @@ impl Game<'_> {
},
// handleSetCarriedItem: a new slot stops a main-hand use.
S::SetCarriedItem(p) => {
let g = &mut host.hub.guests[gi];
if !(0..9).contains(&p.slot) {
eprintln!("{} tried to set an invalid carried item", g.name);
eprintln!(
"{} tried to set an invalid carried item",
host.hub.guests[gi].name
);
return;
}
let (uuid, name, old_slot) = {
let g = &host.hub.guests[gi];
(g.uuid, g.name.clone(), g.selected)
};
if old_slot != p.slot as usize {
let mut cancel = false;
self.native_player_item_held(
uuid,
&name,
old_slot as i32,
i32::from(p.slot),
&mut cancel,
);
if cancel {
host.hub.send_to(
conn,
&C::SetHeldSlot(play::SetHeldSlot {
slot: old_slot as i32,
}),
);
return;
}
}
let g = &mut host.hub.guests[gi];
let changed = g.selected != p.slot as usize;
g.selected = p.slot as usize;
if let Some(play) = host.play.get_mut(&conn)
@@ -1824,10 +1938,15 @@ impl Game<'_> {
// click on nothing), seen by the players around, and the attack
// strength starts again.
S::Punch(_) => {
let (player, name) = {
let g = &host.hub.guests[gi];
(g.uuid, g.name.clone())
};
self.as_guest(host, conn, |g| {
g.swing_hand(Hand::Main, SwingAnimation::DEFAULT);
g.reset_attack_strength();
});
self.native_player_attack_cooldown_reset(player, &name);
}
S::UseItemOn(p) => {
host.hub.ack(conn, p.sequence);
@@ -2034,6 +2153,9 @@ impl Game<'_> {
if !self.creative_stack_valid(host, gi, &stack) {
return;
}
if !self.guest_may_set_creative_slot(host, conn, i32::from(p.slot), Some(&stack)) {
return;
}
let play = host.play.entry(conn).or_default();
if play.creative_drops >= 1480 {
eprintln!(
@@ -2058,6 +2180,12 @@ impl Game<'_> {
{
return;
}
if !self.guest_may_set_creative_slot(host, conn, i32::from(p.slot), item.as_ref()) {
// The creative client already holds the stack it sent: treat that as
// what it was told, so the next broadcast sends the real slot back.
host.hub.guests[gi].menu_sent[p.slot as usize] = item;
return;
}
let slot = p.slot as usize;
let g = &mut host.hub.guests[gi];
let old = g.menu_slot(slot);
@@ -2096,6 +2224,22 @@ impl Game<'_> {
});
}
S::PlayerCommand(p) if p.action == 6 => self.lan_start_gliding(host, conn),
// ServerboundResourcePackPacket in play: the server has nothing to
// do with it (a declined required pack is handled on the connection).
S::ResourcePack(r) => {
let (uuid, name) = (host.hub.guests[gi].uuid, host.hub.guests[gi].name.clone());
self.as_guest(host, conn, |g| {
g.native_resource_pack_status(uuid, &name, r.action);
});
}
// START_SPRINTING and STOP_SPRINTING: the hub has set the sprint
// and sends the packet on when it changed it.
S::PlayerCommand(p) if matches!(p.action, 1 | 2) => {
let (uuid, name) = (host.hub.guests[gi].uuid, host.hub.guests[gi].name.clone());
self.as_guest(host, conn, |g| {
g.native_player_sprint(uuid, &name, p.action == 1);
});
}
S::ContainerButtonClick(p) => {
self.lan_station_button(host, conn, p.container, p.button);
}
@@ -2241,6 +2385,13 @@ impl Game<'_> {
S::SetGameRule(p) if host.hub.guests[gi].permission >= 2 => {
for entry in p.entries {
if self.ui.rules.set_string(&entry.name, &entry.value) {
let guest = &host.hub.guests[gi];
self.native_world_game_rule_change(
guest.uuid,
&guest.name,
&entry.name,
&entry.value,
);
let message = horizon_client::text::Text::tr(
"commands.gamerule.set",
&[&entry.name, &entry.value],
@@ -2364,7 +2515,7 @@ impl Game<'_> {
// handlePickItemFromBlock: a block within the block reach plus 1, its
// data only with infinite materials.
S::PickItemFromBlock(p) => {
self.guest_pick_item(host, conn, |g| {
self.guest_pick_item(host, conn, false, p.pos, 0, |g| {
// handlePickItemFromBlock: within reach and loaded.
if !g.world.loaded(p.pos[0], p.pos[2]) {
return None;
@@ -2385,7 +2536,7 @@ impl Game<'_> {
}
// handlePickItemFromEntity.
S::PickItemFromEntity(p) => {
self.guest_pick_item(host, conn, |g| {
self.guest_pick_item(host, conn, true, [0; 3], p.id, |g| {
g.within_entity_range(p.id)
.then(|| g.entity_pick_result(p.id))
.flatten()
@@ -2427,11 +2578,22 @@ impl Game<'_> {
&mut self,
host: &mut LanHost,
conn: u32,
from_entity: bool,
pos: [i32; 3],
entity_id: i32,
stack: impl FnOnce(&mut Self) -> Option<ItemStack>,
) {
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
return;
};
let picked = self.as_guest(host, conn, |g| {
let stack = stack(g)?;
let infinite = g.creative;
let mut cancel = false;
g.native_player_pick_item(uuid, &name, from_entity, pos, entity_id, &mut cancel);
if cancel {
return None;
}
horizon_client::pick_item::pick(
&mut g.inventory[..36],
&mut g.selected,
@@ -2452,6 +2614,32 @@ impl Game<'_> {
}
}
/// Whether the mods let a creative player set `slot` to `stack`
/// (`native_creative_slot`), heard with the guest's state swapped in.
fn guest_may_set_creative_slot(
&mut self,
host: &mut LanHost,
conn: u32,
slot: i32,
stack: Option<&ItemStack>,
) -> bool {
let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) else {
return true;
};
let (item, count) = stack.map_or(("minecraft:air", 0), |s| (&*s.item, s.count as i32));
let mut cancel = false;
let mut hook = |game: &mut Self| {
game.native_creative_slot(uuid, &name, slot, item, count, &mut cancel)
};
// The guest whose turn it is is in place already.
if self.lan_acting == Some(conn) {
hook(self);
} else {
self.as_guest(host, conn, hook);
}
!cancel
}
/// What ServerboundSetCreativeModeSlotPacket asks of a stack. Its codec
/// (`ItemStack.validatedStreamCodec`) encodes the stack with `ItemStack.CODEC`, whose count is
/// 1 to 99: more fails the packet and disconnects the player, as vanilla's decoder error does.
@@ -2927,9 +3115,9 @@ impl Game<'_> {
);
return;
}
// The chat filter's `/unmute` (game/chat_filter.rs), on servers that filter.
if words[0] == "unmute" && host.hub.chat_filter_on() {
self.guest_unmute(host, conn, &words);
// The chat filter's mute commands (game/chat_filter.rs), on servers that filter.
if super::chat_filter::MUTE_COMMANDS.contains(&words[0]) && host.hub.chat_filter_on() {
self.guest_mute_command(host, conn, &words);
return;
}
// A native mod's command, for every player its level allows,
@@ -3100,6 +3288,11 @@ impl Game<'_> {
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
return;
};
let Some((player_uuid, player_name)) =
host.hub.guest(conn).map(|g| (g.uuid, g.name.clone()))
else {
return;
};
let skip = usize::from(text.starts_with('/'));
let command = &text[skip..];
let d = vanilla::game_dispatcher();
@@ -3146,6 +3339,13 @@ impl Game<'_> {
let parse = d.parse(command, &self.ui.registries);
let ((start, end), mut suggestions) =
d.suggestions(&parse, command.len(), &self.ui.registries);
self.native_tab_complete(
player_uuid,
&player_name,
command,
[start, end],
&mut suggestions,
);
let reg = &mut self.ui.registries;
reg.permission = level;
reg.players = own_players;
@@ -3619,6 +3819,11 @@ impl Game<'_> {
/// teleport the client there.
fn wake_guest_from(&mut self, host: &mut LanHost, gi: usize, head: [i32; 3]) {
let yaw = host.hub.guests[gi].yaw;
let (player, name) = {
let g = &host.hub.guests[gi];
(g.uuid, g.name.clone())
};
self.native_player_bed_leave(player, &name, head);
let Some((stand, yaw)) = self.bed_wake_pose(head, yaw) else {
return;
};
@@ -3664,7 +3869,10 @@ impl Game<'_> {
/// sleeper who died or whose bed is gone.
fn guest_sleep_tick(&mut self, host: &mut LanHost, gi: usize) {
let g = &mut host.hub.guests[gi];
let was_deep = g.sleep_timer >= 100;
g.sleep_timer = super::tick_sleep_timer(g.sleep_timer, g.bed.is_some());
let deep_sleep = !was_deep && g.sleep_timer >= 100 && g.bed.is_some();
let identity = deep_sleep.then(|| (g.uuid, g.name.clone()));
if let Some(head) = g.bed {
let block = self.world.block(head[0], head[1], head[2]);
let bed = self.res.facts().get(block).name.ends_with("_bed");
@@ -3678,6 +3886,9 @@ impl Game<'_> {
}
}
}
if let Some((player, name)) = identity {
self.native_player_deep_sleep(player, &name);
}
}
/// `SleepStatus.update`'s counts over the host's player and the guests:
@@ -4117,6 +4328,7 @@ impl Game<'_> {
}
}
if let (Some(v), Some(g)) = (marked, host.hub.guest(conn)) {
self.native_player_velocity(g.uuid, &g.name, [v.x, v.y, v.z]);
g.send(&C::SetEntityMotion(play::SetEntityMotion {
id: g.entity_id,
velocity: [v.x, v.y, v.z],
@@ -4139,8 +4351,16 @@ impl Game<'_> {
.is_some_and(|p| p.fluids.was_touching_water || p.is_in_lava())
})
.unwrap_or(true);
let mut changed = None;
if let Some(g) = host.hub.guest_mut(conn) {
g.fall_flying = start && !in_liquid;
let gliding = start && !in_liquid;
if g.fall_flying != gliding {
g.fall_flying = gliding;
changed = Some((g.uuid, g.name.clone(), gliding));
}
}
if let Some((uuid, name, gliding)) = changed {
self.native_entity_toggle_glide(uuid, &name, gliding);
}
}
@@ -4315,6 +4535,8 @@ impl Game<'_> {
let swinging = play.swing.swinging();
if progress != play.last_progress && progress >= 0 {
play.last_progress = progress;
let guest = &host.hub.guests[gi];
self.native_block_break_progress_update(guest.uuid, &guest.name, pos, progress);
// The host player sees it too (on the server thread its window is a guest).
self.destroy_block_progress(host.hub.guests[gi].entity_id, pos, progress);
host.hub.broadcast_near(
@@ -4456,7 +4678,7 @@ impl Game<'_> {
/// (`handleTakeItemEntity`).
fn guest_arrows(&mut self, host: &mut LanHost, gi: usize) {
use horizon_client::arrows::Touch;
let (conn, id, at, creative, unable, dims) = {
let (conn, id, at, creative, unable, dims, player, name) = {
let g = &host.hub.guests[gi];
(
g.conn,
@@ -4465,6 +4687,8 @@ impl Game<'_> {
g.creative(),
g.spectator() || g.dead || g.health <= 0.0,
g.dimensions(),
g.uuid,
g.name.clone(),
)
};
if unable || self.creatures.projectiles.is_empty() {
@@ -4508,6 +4732,9 @@ impl Game<'_> {
});
self.creatures.projectiles = projectiles;
for shot in picked {
if let Some(arrow) = shot.arrow.as_ref() {
self.native_player_pickup_arrow(player, &name, shot.id, &arrow.pickup_item.item);
}
host.hub.take_item(shot.id as i32, id, 1, at);
if let Some(target) = super::pickups::guest_target(host, id as u32) {
self.add_pickup_toward(
@@ -4526,11 +4753,13 @@ impl Game<'_> {
fn guest_orbs(&mut self, host: &mut LanHost, gi: usize) {
let g = &host.hub.guests[gi];
let height = f64::from(g.dimensions().height);
let (conn, id, at) = (g.conn, g.entity_id, g.position);
let (conn, id, at, player, name) =
(g.conn, g.entity_id, g.position, g.uuid, g.name.clone());
let unable = g.spectator() || g.dead || g.health <= 0.0;
let play = host.play.entry(conn).or_default();
if play.xp_delay > 0 {
play.xp_delay -= 1;
self.native_player_exp_cooldown_change(player, &name, play.xp_delay);
}
if unable || play.xp_delay > 0 {
return;
@@ -4598,6 +4827,7 @@ impl Game<'_> {
self.orbs[index].count -= 1;
}
host.play.entry(conn).or_default().xp_delay = 2;
self.native_player_exp_cooldown_change(player, &name, 2);
host.hub.take_item(orb.id as i32, id, 1, at);
// ClientPacketListener.handleTakeItemEntity's sound, at the orb.
let pitch = (self.world.random.next_float() - self.world.random.next_float()) * 0.35 + 0.9;
@@ -4620,7 +4850,14 @@ impl Game<'_> {
self.as_guest(host, conn, |game| {
let rest = game.mending(value);
if rest > 0 {
game.give_orb_points(rest);
let (Some(player), Some(name)) =
(game.lan_acting_uuid, game.lan_acting_name.clone())
else {
return;
};
let mut amount = rest.min(i32::MAX as u32) as i32;
game.native_player_exp_change(player, &name, &mut amount);
game.give_orb_points(amount.max(0) as u32);
}
});
}
Loaded 100 of 474 files, more files were not shown because too many files have changed in this diff. Show more