Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 b7a6df3a8b Block states: facts.json reads properties straight into shared strings
The registry's states were read into a BTreeMap of owned strings, then
shared: two allocations and a hash per string, 50 ms of the start. The
facts.json decoder now shares each name and value as it reads it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:32:38 +01:00
Ludovic MarieandClaude Opus 5.5 5cc12e9973 Block states: a palette entry's properties collect in one pass
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:32:38 +01:00
Ludovic Marie d1a54c150a bench: heap counting only with SP2_COUNT 2026-11-01 10:58:32 +01:00
Ludovic Marie 4f22b0bc66 bench: heap per core table (SP2_SERIAL) 2026-11-01 10:58:32 +01:00
Ludovic Marie 28c6e890ac bench: stage timestamps for the dedicated start 2026-11-01 10:58:32 +01:00
Ludovic MarieandClaude Opus 5.5 8babf37afd bench: per-loader peak and kept bytes (SP2_SERIAL)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:58:32 +01:00
Ludovic MarieandClaude Opus 5.5 e1e5fac7cb bench: release after start (SP2_RELEASE)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:58:32 +01:00
Ludovic MarieandClaude Opus 5.5 027abe75ed bench: live heap bytes per load stage
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:58:32 +01:00
Ludovic MarieandClaude Opus 5.5 e7f99e7ce1 Block states: compact properties with shared strings
A block state's properties were a BTreeMap<String, String>: a 544-byte
leaf and two allocations per property for each of the registry's 35,723
states (about 30 MB of a dedicated server's live heap), for 237 distinct
strings. BlockProperties keeps them as a sorted slice of pairs, read like
the map (get, insert, iter, indexing, order, equality and hashing pair by
pair); the registry shares its states' strings through one Arc each.
States made elsewhere (chunk palettes, commands) own theirs, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:58:14 +01:00
Ludovic Marie 7e5e3d1ada Merge pull request #5799 from horizon-rust/perf/ticks-fx-hash
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: tick_blocks 5.35→5.06 ms (−5.5 %), MSPT 25.91→25.46. Tick sets only looked up (keys sorted where listed). fmt + clippy -D warnings + horizon-core tests pass (Mac mini); 955 render+core tests pass (playhorizon); its 2 Python failures are its VSCREEN env.
2026-11-01 09:19:12 +01:00
Ludovic Marie 5edb339a76 Merge pull request #5793 from horizon-rust/perf/chunk-encode-palette-ids
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: tick_lan 4.22→3.96 ms (−6 %), MSPT 25.65→25.32. Same bytes (new test vs from_values). fmt + clippy -D warnings pass (Mac mini); workspace tests 3982 pass (playhorizon); the 2 Python failures there are its VSCREEN env, they pass on the Mac mini.
2026-11-01 08:55:26 +01:00
Ludovic MarieandClaude Opus 5.5 d16d57b197 Scheduled ticks: FxHash for the per-tick position sets
LevelTicks and ChunkTicks hash a (type, position) key at every schedule,
poll and dedup check, and the chunk map at every lookup, with std's SipHash.
They are only looked up, never iterated in order (the keys of an area are
sorted), so they now use the Fx hasher, which moves from
horizon-client-render to horizon-core (re-exported under the old path).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:35:53 +01:00
Ludovic Marie 78043f09a9 Merge pull request #5787 from horizon-rust/fix/client-input-auto-jump
🤖 Agent: #5786. Auto-jump now in ServerboundPlayerInputPacket as vanilla's keyPresses. Staircase walk with autoJump on, same server: 75 prediction flags with main's client, 0 with this one. pr_ready --quick all green on 7a8a97dd5.
2026-11-01 08:33:08 +01:00
Ludovic MarieandClaude Opus 5.5 17410e0123 Client: the input packet carries the auto-jump, as vanilla's does
LocalPlayer.sendChanges sends input.keyPresses, which an auto-jump's makeJump
sets to jump for that tick. Horizon sent the keys as held, so with Auto-Jump on
the server saw jumps its keys didn't allow: the anticheat's prediction flagged
every auto-jump and the 0.4 a tick it left (75 flags on a staircase walk, none
with this change). The official server's log has the same lines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:32:09 +01:00
Ludovic Marie 07f5e6164d Merge pull request #5781 from horizon-rust/fix/anticheat-prediction-one-flag-per-slip
🤖 Agent: #5725. One prediction flag per slip across instead of 10-20 (87% of live lines continue an earlier slip). Strict cheat tests unchanged; new tests for a missed push (counts once) and a kept-up 0.05 speed lie (35 of 40 ticks count). pr_ready --quick all green on 7a8a97dd5.
2026-11-01 08:14:18 +01:00
Ludovic MarieandClaude Opus 5.5 82888ca49d Prediction: a slip across counts once, not on every tick its velocity lasts
87% of the official server's prediction flags (6,002 of 6,900 lines on
2026-11-01) continue a divergence that started on an earlier tick: once the
replay's velocity is off (a hit it missed), the offset dies out by the air drag
(0.91) or ground friction each tick, and each of those ticks flagged again.

The first tick off after ten that agreed now has its follow-up tick try the
velocity the client was left with (across only, at most 0.5), as another
candidate. A single slip then counts once; a blink leaves the velocity alone; a
tick that agrees only thanks to it never becomes the setback point. Not up and
down: the vertical drag (0.98) would let a slow fly pass for a slip.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:04:40 +01:00
Ludovic Marie 7a8a97dd5f Merge pull request #5776 from horizon-rust/fix/server-thread-limit-crash
🤖 Agent: #5775. Official server crash 05:15Z (spawn worker WouldBlock, no save). Worker spawn failure non-fatal; connection threads capped at max_players+64. New flood test fails without the cap, passes with it; pr_ready --quick green apart from one ENOSPC test failure that passes alone.
2026-11-01 07:55:38 +01:00
Ludovic MarieandClaude Opus 5.5 76ae6b7763 Server: a refused thread no longer crashes it; connection threads are capped
A dedicated server crashed on the official server (2026-11-01 05:15Z) when the
system refused a new thread: Streaming::new's .expect("spawn worker") panicked
and the process died without saving. Worker spawn failures are now logged and
the pool runs with the workers that started.

The server starts a thread per accepted socket (vanilla's Netty uses a few for
all): past max_players + 64 live connection threads, a new socket is closed at
once, so a flood of silent sockets can't use up a container's thread limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:54:08 +01:00
Ludovic MarieandClaude Opus 5.5 6d035f2fa1 Chunk sending: encode a section from its stored palette ids
A section's first encoding unpacked its 4,096 block states, then built a
palette from them again (Paletted::from_values). For a list palette the
stored ids are now renumbered in first-seen order instead: the same palette,
indices and bytes, without the values. Global palettes keep the old path.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:52:40 +01:00
Ludovic Marie 04029578c5 Merge pull request #5774 from horizon-rust/perf/mob-tick-no-copy
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: tick_creature_entities 4.95→4.32 ms (−13 %), MSPT 26.59→25.78 (−3 %). pr_ready --quick pass (1655 horizon-client tests, 511 Python). Tick order and RNG unchanged; 34 harnesses in CI.
2026-11-01 07:48:35 +01:00
Ludovic Marie 26747867df Merge pull request #5714 from TiggerTakkyTTT/fix/native-mod-list
🤖 Agent: Fixes #5690 (no Build button with only GitHub mods); verify + launcher green; approved as code owner. Queued.
2026-11-01 07:17:47 +01:00
Ludovic MarieandClaude Opus 5.5 ab376df4e0 Mob ticks: no copy of every mob each tick; a fast hasher for the per-mob sets
tick_creature_entities takes the mob list, ticks it, and appended it back
into the emptied list: a new buffer and a copy of every Mob each tick (and
the old buffer freed). With no mob added meanwhile the list now goes back as
it is. The dead-mob set and the guest bindings, looked up per mob each tick
and never iterated, use FxHash instead of SipHash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:13:44 +01:00
Ludovic Marie 87e5ca82de Merge pull request #5767 from horizon-rust/fix/anticheat-prediction-item-use
🤖 Agent: #5725 step 2. Guest item use (eating, bow, shield) now slows the replay as it slows the client. Vanilla 26.3 client walking/sprinting while drawing a bow 3 min: 503 prediction flags → 2 (0.045). pr_ready --quick all green on 3bc251ea5.
2026-11-01 07:11:40 +01:00
Ludovic Marie c624e9c1d8 Merge pull request #5765 from horizon-rust/feat/server-low-memory
🤖 Agent: [engine] low_memory (off by default) sets MIMALLOC_ALLOW_THP=0 for the game. Measured playhorizon 20 bots: idle RSS −60 MB, −190 MB at 20 players, MSPT +1.2 % (5 rounds). pr_ready --quick pass (98 horizon-server tests incl. the new env test, 511 Python); server reference regenerated.
2026-11-01 07:04:41 +01:00
Ludovic MarieandClaude Opus 5.5 16559f6668 Anticheat prediction: play a guest's item use as its client does
An item in use (food, a bow, a shield) slows the client to its
use_effects speed (0.2 for most) and stops its sprint. The replay plays a
guest on the server's copy of its body, which takes the item use from
Game.sync_player_body, and that runs only for the window's own player: a
guest's copy never had one. So the replay walked a guest who was eating
or drawing a bow at full speed. That put it 0.8 of a walk (0.173 blocks a
tick) or of a sprint (0.224) off, every tick of it. 0.173 was the official
server's most common prediction flag.

guest_replay now sets the copy's item use speed and its no-sprint flag
from the item the guest is using, as it already did for the glider. A
vanilla 26.3 client sprinting and walking while drawing a bow for 3
minutes: 503 flags before, 2 after (0.045 blocks).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:02:51 +01:00
Ludovic MarieandClaude Opus 5.5 8e692458eb horizon-server: [engine] low_memory turns transparent huge pages off
mimalloc backs the game's memory with transparent huge pages where Linux
allows them; many are only partly used. low_memory = true starts the game
with MIMALLOC_ALLOW_THP=0: 60 MB less idle and 190 MB less at 20 players,
for ~1.2 % more tick time (playhorizon, 20 bots, 5 rounds). Off by default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 07:01:01 +01:00
Ludovic Marie 3bc251ea5a Merge pull request #5751 from horizon-rust/perf/light-views-on-workers
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: streaming 4.53→3.46 ms (−24 %), MSPT median 26.59→26.37. pr_ready --quick pass (831 render tests, 511 Python); forced_chunks/generation_saves/streaming_meshes pass. Light output unchanged; 117 harnesses in CI.
2026-11-01 06:32:03 +01:00
Ludovic Marie 9cf948a102 Merge pull request #5750 from horizon-rust/fix/main-build-throwable-no-gravity
Thrown projectiles keep NoGravity: save_roundtrip passes again
2026-11-01 06:23:19 +01:00
Ludovic MarieandClaude Opus 5.5 2c935d8d91 Thrown projectiles keep NoGravity: save_roundtrip passes again
Since #5641 a saved snowball, egg or ender pearl loads as a live projectile,
but NoGravity was dropped: the scene's floating snowball and pearl fell into
the floor and were gone before the save. Entity.getGravity is 0 with
NoGravity; the projectile now reads it, honours it and writes it back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 06:22:38 +01:00
Ludovic Marie cbfae59314 Merge pull request #5702 from rdbtCVS/perf/frame-cpu
Cull off-screen dropped items; fix index crash after translucent sections
2026-11-01 05:57:28 +01:00
Ludovic Marie 3dba8448f6 Merge pull request #5726 from horizon-rust/feat/anticheat-prediction-detail
🤖 Agent: log-only detail for prediction lines (#5725 step 1); unit tests for the worst tick's offset/state; vanilla client on a dedicated server logs the new detail; pr_ready --quick all green on 21a12582c.
2026-11-01 05:57:01 +01:00
Ludovic Marie d1e40035ef Merge pull request #5476 from Teiwazik/shader/s7-shadow-translucent
🤖 Agent: requeued after the LAND-STOP: holds #5461's main merge; tests + clippy pass.
2026-11-01 05:55:50 +01:00
Ludovic Marie 6ae81c6449 Merge pull request #5461 from Teiwazik/shader/s6-shadow-solid
🤖 Agent: requeued after the LAND-STOP: main merged (Frame.mods first, shadow fields after it, reference regenerated); shader-api + render native tests + clippy pass.
2026-11-01 05:55:46 +01:00
Ludovic Marie 46a177c046 Merge main and #5461's main merge into #5476
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	docs/api/shaders.md
2026-11-01 05:53:46 +01:00
Ludovic Marie 205d4c32c7 Merge main into #5461 (after the native shader params, #5411)
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/gfx/native_post.rs
#	crates/horizon-shader-api/src/inputs.rs
#	docs/api/shaders-reference.md
#	docs/api/shaders.md
2026-11-01 05:52:57 +01:00
Ludovic Marie 8a4ea81699 Merge pull request #5413 from levzzz5154/launcher/gpui-fast
🤖 Agent: launcher on GPUI Fast (Apache-2.0; fixes the NVIDIA/blade crash #3812); macOS text fixed (font-kit) and checked against main by screenshot; launcher tests + clippy pass on macOS; approved as code owner.
2026-11-01 05:51:27 +01:00
Ludovic MarieandClaude Opus 5.5 ef2b5d25fa Launcher: GPUI Fast's macOS text system needs font-kit
Without the feature gpui-fast-platform has no font source on macOS: the
launcher drew no text at all and half its home page was blank. With it,
the home page matches main's GPUI build.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:48:28 +01:00
Ludovic MarieandClaude Opus 5.5 eb6d6c3616 Merge main into #5413
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:47:41 +01:00
Ludovic MarieandClaude Opus 5.5 6fa9bef54f Anticheat prediction: log what the worst tick did
A prediction line said only how far off a move was. On the official server
it logs about 15,000 lines an hour, from ordinary players (mostly 0.1 to
0.2 blocks) and cheaters (1 to 10 blocks) alike, and nothing in a line
tells a glide, water, a ladder or a lag from a cheat, so the check can't
be trusted to act.

Each line now ends with its worst tick as the replay played it: where the
client said it went less where the replay ended (x, y, z), how far the
client said it moved, and what the replayed body was doing (ground or air,
water, gliding, sprinting, sneaking, swimming, flying, riding). Only
flagged ticks pay for the text; the state is a few bits per played tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:47:06 +01:00
Ludovic MarieandClaude Opus 5.5 f8ee6221ff Merge main into #5413
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:42:46 +01:00
Ludovic Marie 81b0cd084e Merge pull request #5484 from guilhermejansen/fix/macos-updater-keychain
🤖 Agent: macOS updater keeps retired bundles until unused (fixes #5427 Keychain 100002); main merged; launcher 433 tests incl. 7 mac_update + clippy pass on macOS; approved as code owner.
2026-11-01 05:42:36 +01:00
Ludovic Marie 37fd2a4722 Merge pull request #5458 from xFedeT/feat/debug-world
🤖 Agent: DebugLevelSource grid + isDebug gates (rdbtCVS approved); main merged (random-tick masks, superflat structures); clippy + debugworld/save tests pass.
2026-11-01 05:39:54 +01:00
Ludovic MarieandClaude Opus 5.5 c539244947 Merge main into #5484
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:39:40 +01:00
Ludovic Marie 7baaa4423a Merge pull request #5412 from Vaspyyy/feat/native-camera-shake
🤖 Agent: native_camera_shake: identity without a mod, scaled by Distortion Effects, bounded; holds #5411. pr_ready --quick on chain-5412 (holds #5406 #5409-#5412, main f99f8feaa): fmt, clippy -D warnings, 2493 tests, 511 Python tests pass.
2026-11-01 05:37:55 +01:00
Ludovic Marie acc1fdceb5 Merge main into #5458
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/world_loading.rs
2026-11-01 05:37:29 +01:00
Ludovic Marie 31fccc920b Merge pull request #5409 from Vaspyyy/feat/native-falling-blocks
🤖 Agent: native_spawn_falling_block: server-only, bounded speed, 1024 cap, loaded chunk; holds #5406. pr_ready --quick on chain-5412 (holds #5406 #5409-#5412, main f99f8feaa): fmt, clippy -D warnings, 2493 tests, 511 Python tests pass.
2026-11-01 05:36:35 +01:00
Ludovic MarieandClaude Opus 5.5 68c5daa3ae Merge main and #5461's main merge into #5476
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:33:13 +01:00
Ludovic MarieandClaude Opus 5.5 e554c66ae2 Merge main into #5461
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:33:13 +01:00
Ludovic Marie 3099586bbc Merge pull request #5447 from Qwerner88880/hooks-ingredients
🤖 Agent: NeoForge ingredient shapes (compound = union, intersection, difference); rdbtCVS's points fixed; HashSet-order test made order-free; holds #5439.
2026-11-01 05:32:46 +01:00
Ludovic Marie a1a4fd46be Merge pull request #5439 from Qwerner88880/hooks-conditions
🤖 Agent: neoforge:conditions gate on every recipe loader; rdbtCVS approved, arismoko checked in game; main merged; conditions tests + clippy pass.
2026-11-01 05:32:43 +01:00
Ludovic MarieandClaude Opus 5.5 b7ce7c00da crafting test: a tag's items have no fixed order
The test tag is a HashSet, so the compound's union came out in either order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:31:41 +01:00
Ludovic MarieandClaude Opus 5.5 ae0ed11caf Streaming: build the light views of proto chunks on the workers
A light job for a column of the last ring reads its neighbours past the
area as views of their decorated proto chunks. The server thread built
those views itself while it scheduled light (~8 columns per tick at 20
players). The proto chunk is now checked out to a worker, like a save,
which builds the view; the light job is scheduled once it is back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:29:03 +01:00
Ludovic Marie 05f240102a Merge pull request #5480 from Vaspyyy/fix/native-summon-orbs-passengers
🤖 Agent: native_summon root ids: orbs (canceled → None), end crystals and cushions; passengers placed by /summon's place helper; clippy clean.
2026-11-01 05:24:58 +01:00
Ludovic MarieandClaude Opus 5.5 1488ba172e Merge main and #5439's main merge into #5447
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:24:08 +01:00
Ludovic Marie 88e4e2b847 Merge main into #5439
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	docs/native-mods.md
2026-11-01 05:24:07 +01:00
Ludovic Marie b6134f4841 Merge pull request #5414 from sirrayi/fix/fox-trust
🤖 Agent: merged main. Fox trust list, save/load, breeding and egg-on-fox trust (rdbtCVS's fix, approved). pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:23:27 +01:00
Ludovic Marie ba8be81ecb Merge pull request #5450 from reservedbytes/fix/native-hurt-server-canceled-notes
🤖 Agent: merged main. Canceled hits forget their attackers on every damage path (PvP, /damage, thorns, dragon, blast, potion); test. pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:23:23 +01:00
Ludovic Marie 9267c6870b Merge pull request #5442 from ezejde/fix/head-profile-texture-patch
🤖 Agent: merged main. ResolvableProfile texture patch drawn for heads, read from the active resource root, reloaded on pack change. pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:23:19 +01:00
Ludovic Marie 50f47f3acd Merge pull request #5441 from xFedeT/fix/rabbit-raids-carrots
🤖 Agent: merged main. Rabbit.RaidGardenGoal + MoreCarrotTicks; customServerAiStep's nextInt(3) checked in bytecode (same order). pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:23:15 +01:00
Ludovic MarieandClaude Opus 5.5 5fc86a7384 native_summon: end crystals and cushions keep their root ids too
The fallback for roots no command Target stands for checked only orbs after
the canceled-orb fix; end crystals and cushions also take an id at once
(rdbtCVS's re-review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:23:07 +01:00
Ludovic MarieandClaude Opus 5.5 f5a7175f27 Merge main into #5480
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:21:40 +01:00
Ludovic Marie e91a4ba7c0 Merge #5411 (queued ahead of it) into #5412
The native-mods PRs each add a section to docs/native-mods.md at the same
place; holding the one ahead lets the series merge in one pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-01 05:20:51 +01:00
Ludovic MarieandClaude Opus 5.5 6fbec036d7 Merge main into #5412
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:20:51 +01:00
Ludovic Marie b36c7f9da5 Merge #5410 (queued ahead of it) into #5411
The native-mods PRs each add a section to docs/native-mods.md at the same
place; holding the one ahead lets the series merge in one pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/native_hooks.rs
#	docs/native-mods.md
2026-11-01 05:20:50 +01:00
Ludovic MarieandClaude Opus 5.5 aff0fec800 Merge main into #5411
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:20:50 +01:00
Ludovic Marie 8a50d1c414 Merge #5409 (queued ahead of it) into #5410
The native-mods PRs each add a section to docs/native-mods.md at the same
place; holding the one ahead lets the series merge in one pass.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	docs/native-mods.md
2026-11-01 05:20:49 +01:00
Ludovic MarieandClaude Opus 5.5 e600371fc9 Merge main into #5410
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:20:49 +01:00
Ludovic MarieandClaude Opus 5.5 59d8117f64 Merge main into #5409
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:20:48 +01:00
Ludovic Marie 21a12582cf Merge pull request #5433 from k3yh/fix/nether-water-evaporate
🤖 Agent: merged main. BucketItem.emptyContents in the Nether: sendParticles(LARGE_SMOKE, corner, 8, 1,1,1, 0, ALTERNATIVE) now reaches clients; ALTERNATIVE spread added. pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:20:34 +01:00
Ludovic Marie aef5704b1e Merge pull request #5418 from xFedeT/feat/spawner-minecart
🤖 Agent: merged main. MinecartSpawner: BaseSpawner ticks at the cart, saved/loaded, drops and picks as a minecart, entity event broadcast. pr_ready --quick on a union of #5414 #5418 #5433 #5441 #5442 #5450 merged into main f99f8feaa: fmt, clippy -D warnings, 2403 client/render tests, 510 Python tests pass.
2026-11-01 05:20:30 +01:00
Ludovic Marie 8bf92ca0ae Merge pull request #5715 from horizon-rust/perf/lan-attribute-ids
🤖 Agent: playhorizon A/B, 20 bots, 3 rounds: lan_entities 1.13→0.92 ms (−19 %), tick_lan 4.52→4.27 ms. pr_ready --quick pass (1635 horizon-client tests, 511 Python). attribute_updates harness PASS; same ids, bases and modifiers sent.
2026-11-01 05:20:26 +01:00
TiggerTakkyTTTandCodex eb46a6d619 fix(launcher): unify installed native mods and expose Build
Include GitHub and local native bundles in both installed-mod views, retaining source/update actions and the existing build confirmation.

Co-Authored-By: Codex (GPT-6) <[email protected]>
2026-11-01 11:09:00 +07:00
rdbtCVSandClaude Opus 5.5 139ef2f801 Cull off-screen dropped items; restore quad indices after sorted pages
dropped_item_geometry resolved and meshed every item within 64 blocks,
even behind the camera. Apply EntityRenderer.shouldRender's frustum test
(box grown by 0.5) first, as mobs already do: ~0.2 ms -> ~0.01 ms per
frame with a few dozen items around.

draw_sections left a translucent sorted-index page bound, so the cloud
draw after it indexed past its end and wgpu aborted on the first frame.
Rebind the shared quad indices when a sorted page was used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:47:09 +00:00
Ludovic Marie c51dcda011 Merge pull request #5691 from horizon-rust/fix/lunge-motion-skipped-tick
lunge_motion: name a server tick that ran without a move, retry the harness once (#5656)
2026-11-01 04:30:59 +01:00
Ludovic MarieandClaude Opus 5.5 9228c44123 lunge_motion: name a server tick that ran without a move, and retry the harness once (#5656)
#5656's falling motion (-0.3015 against vanilla's -0.2277) is a tick of
gravity more, not a change in the game: main, and the exact tree CI
failed on (#5502's head merged into 76dc5e3bd), pass every time on a
quiet machine. On a loaded one the server catches up with two ticks for
one of the jump's moves (seen: "the server ran 2 ticks for move 7"), and
the server's copy of the player falls in the tick with no move. Before
the jab that changes the motion the jab sends.

LocatorBarClient's arc now records an error when that happens up to the
jab, so the failure says why, and lunge_motion joins known_flaky so the
runner gives it its one retry.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:30:20 +01:00
Ludovic Marie 521614fc7f Merge pull request #5689 from horizon-rust/fix/launcher-github-build-hint
🤖 Agent: Message text only, in 11 lang files that all parse; the launcher CI job runs the i18n tests. Merging once it's green.
2026-11-01 04:23:20 +01:00
Ludovic MarieandClaude Opus 5.5 a50005adb4 Launcher: point the GitHub-install message at the Mods page's Build
The Build button moved from Settings > Native mods to the Mods page, but the
message after installing a mod from GitHub still sent players to the old
setting (reported on Discord, 2026-11-01). Updated in English and the ten
translations.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:22:22 +01:00
Ludovic Marie fbd0ca7f11 Merge pull request #5688 from horizon-rust/automation/native-hook-stamp
🤖 Agent: The stamp commit from the native hook stamp workflow (the bot can't open PRs). Merging once CI is green.
2026-11-01 04:18:28 +01:00
Ludovic MarieandClaude Opus 5.5 bc1d4d1516 Entity tracking: a mob's movement speed looked up once per tick
mob_attributes asked the attribute catalog for movement speed (by name)
and recomputed its base value up to three times per mob and tick, on
top of the registry id built with format! per attribute (Attr::id, the
previous commit). The speed instance, the baby and the sprinting
modifiers now share one lookup.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:12:45 +01:00
Ludovic MarieandClaude Opus 5.5 75a9116efb Attributes: static registry ids, not format! per mob and tick
The type's default of an attribute (base_value, read by mob movement and
every tick's entity tracking) and the LAN attribute sync built the id
"minecraft:<name>" with format! on each call: for a server's 2000 mobs that
is several allocations per mob per tick. Attr::id returns the id as a
static string.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:10:17 +01:00
Claude 88784b6910 Merge remote-tracking branch 'upstream/main' into fix/native-summon-orbs-passengers
# Conflicts:
#	crates/horizon-client/src/game/native_entities.rs
2026-11-01 02:50:55 +00:00
Claude 64aa0b4473 Merge remote-tracking branch 'upstream/main' into feat/native-camera-shake
# Conflicts:
#	docs/native-mods.md
2026-11-01 02:50:26 +00:00
Claude a62c609529 Merge remote-tracking branch 'upstream/main' into feat/native-shader-params
# Conflicts:
#	docs/native-mods.md
2026-11-01 02:50:25 +00:00
Claude c6a5386e9f Merge remote-tracking branch 'upstream/main' into feat/native-local-sound
# Conflicts:
#	docs/native-mods.md
2026-11-01 02:50:24 +00:00
Claude a8b7cdc12e Merge remote-tracking branch 'upstream/main' into feat/native-falling-blocks
# Conflicts:
#	crates/horizon-client/src/game.rs
#	docs/native-mods.md
2026-11-01 02:50:23 +00:00
Ludovic Marie b6ac0602d7 Merge pull request #5670 from horizon-rust/fix/sprite-split-adapter-limit
🤖 Agent: unit tests for the split choice and resplit; HORIZON_SPRITE_SPLIT=512 renders the same world as the default on the Mac mini; pr_ready --quick green. Merging after verify + parity.
2026-11-01 03:39:17 +01:00
github-actions[bot] 593e4af363 Stamp native hook API fragments 2026-11-01 02:35:29 +00:00
Ludovic Marie 929f53ad3e Merge pull request #5578 from Sylviromi/screen-mouse-button
🤖 Agent: merger STOP was a conflict with main after #5571 landed; main merged (clean). clippy -D warnings, the native hook tests (55) and rustfmt pass on it; the rest was proven in union3 with #5571.
2026-11-01 03:35:07 +01:00
Ludovic MarieandClaude Opus 5.5 475af9674a Renderer: cut block sprites by the GPU's texture array limit
A GPU with 512 layers per texture array refused to start ("GPU supports 512
texture layers; 1291 required") although terrain binds 8 sprite arrays. The
split is now the smallest of SPRITE_SPLIT, HORIZON_SPRITE_SPLIT and the
adapter's max_texture_array_layers (one less on OpenGL when a full array would
be padded past the limit), computed once when the renderer is made and used
by the sprite arrays, the shader constants and the shader pack atlas. Mip
chains prepared before the adapter was known are cut again for it. Startup
only fails when the sprites need more than 8 arrays of that split.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:35:06 +01:00
Ludovic Marie 9be37e6877 Merge remote-tracking branch 'origin/main' into n5578 2026-11-01 03:30:51 +01:00
Ludovic Marie ae4e28774f Merge pull request #5569 from v3nn7/fix/mount-offhand-steering
🤖 Agent: vanilla's Pig/Strider getControllingPassenger use player.isHolding, which checks both hands; the server path (lan_vehicles → tick_mount_riding) shares this check. Main merged; its test passes. union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:29:27 +01:00
Ludovic Marie 678c9b7073 Merge pull request #5573 from some-du6e/feat/native-modify-default-components
🤖 Agent: main merged (item-default and loot free hooks both kept), clippy needless borrow fixed. union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:29:23 +01:00
Ludovic Marie 63e9b9207a Merge pull request #5571 from Sylviromi/screen-render-post
🤖 Agent: main merged (the hook fires, then main's split-screen HUD). union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:28:15 +01:00
Ludovic Marie 8978c7c35e Merge pull request #5566 from Qwerner88880/hooks-enchant
🤖 Agent: main merged (both doc sections kept). union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:28:11 +01:00
Ludovic Marie c7095cc9e5 Merge pull request #5535 from mariuslozo-boop/fix/horse-inventory-close
🤖 Agent: main merged (the level check before main's lan_mount_valid). union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:28:07 +01:00
Ludovic Marie 78547c3b61 Merge pull request #5516 from sirrayi/mods/placement-state
🤖 Agent: main merged (both sides' level hooks kept). union3 = main ba8b5b414 + #5516 #5535 #5566 #5571 #5578 #5573 #5569: pr_ready --quick passes (python 511, rustfmt, workspace tests 2856, content root); clippy -D warnings clean after the #5573 borrow fix.
2026-11-01 03:28:03 +01:00
Ludovic MarieandClaude Opus 5.5 db0ec1edd4 content_identity: no needless borrow (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:23:26 +01:00
Ludovic Marie 1734ca3982 Merge pull request #5445 from navinor/fix/armor-stand-lerp
🤖 Agent: Review points from some-du6e fixed and confirmed (seat_own_stands ordering, the cancel() test, port-ledger citations); local lan_e2e proof posted by the author's agent; CI green on 6f941a5e9. Queued for merge.
2026-11-01 03:19:11 +01:00
Ludovic Marie 8c936ae640 Merge remote-tracking branch 'origin/main' into n5569 2026-11-01 03:19:02 +01:00
Ludovic MarieandClaude Opus 5.5 c4cbf36967 Merge main into #5573 (item-default and loot hooks both kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:18:00 +01:00
Ludovic Marie ddeecbad3b Merge remote-tracking branch 'origin/main' into n5578 2026-11-01 03:17:38 +01:00
Ludovic Marie 6ac25bbc02 Merge remote-tracking branch 'origin/main' into n5571 2026-11-01 03:17:38 +01:00
Ludovic Marie d2ad79e4c1 Merge remote-tracking branch 'origin/main' into n5566 2026-11-01 03:17:38 +01:00
Ludovic Marie 072ad5d00a Merge remote-tracking branch 'origin/main' into n5535 2026-11-01 03:17:38 +01:00
Ludovic Marie 57ae3494b8 Merge remote-tracking branch 'origin/main' into n5516 2026-11-01 03:17:38 +01:00
Ludovic MarieandClaude Opus 5.5 c30ebd6aaf Merge main into #5516 (both sides' level hooks kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:17:37 +01:00
Ludovic Marie ba8b5b4146 Merge pull request #4250 from martipops/feature/launcher-auth-status
🤖 Agent: launcher account status, profile heads, refresh and targeted sign-in (Microsoft only); rdbtCVS's points fixed earlier; main merged here (lang union, both sides' account tests, a string main dropped removed). Proven: union of main 956e35469 with 19 PRs, launcher clippy + tests green (the i18n tests after the string fix), game clippy/tests green.
2026-11-01 03:16:32 +01:00
Ludovic Marie 22a3c3c55b Merge branch 'n5571' into n5578 2026-11-01 03:16:20 +01:00
Ludovic MarieandClaude Opus 5.5 29761e3803 Merge main into #5571 (the hook, then main's split-screen HUD)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:16:20 +01:00
Ludovic MarieandClaude Opus 5.5 cd39ec5fdc Merge main into #5566 (both hook sections kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:16:04 +01:00
Ludovic Marie 5a8414ddad Merge pull request #5561 from some-du6e/mods/loot-table-hooks
🤖 Agent: native loot table and drop hooks. Fixed here: NativeLootDrops.player only while the hurt memory lasts (dropFromLootTable's playerKilled). Proven: union of main 956e35469 with 19 PRs had this PR's code (clippy, workspace + content-root tests green); the author's later commits are docs, a test and rustfmt, and CI verify passes on this head.
2026-11-01 03:15:19 +01:00
Ludovic MarieandClaude Opus 5.5 9c1bd32c77 Merge main into #5535 (the level check before main's lan_mount_valid)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:14:53 +01:00
Ludovic Marie 42ffde8cd2 Merge remote-tracking branch 'origin/main' into n5573 2026-11-01 03:14:33 +01:00
Ludovic Marie af2edf2e86 Merge pull request #5558 from Qwerner88880/hooks-consume
🤖 Agent: native consume effect kinds; fixed here: an empty namespace is minecraft's. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:13:54 +01:00
Ludovic Marie 735f4371c5 Merge pull request #5536 from Xmilon/native/block-touch-hooks
🤖 Agent: native block contact / landing hooks; fixed here: no item/projectile sweep without mod blocks (hot path), vanilla stepOn-then-inside order, getOnPosLegacy. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:13:50 +01:00
Ludovic Marie 213a16d88a Merge pull request #5527 from sirrayi/mods/bone-meal-on-mod-blocks
🤖 Agent: native bone meal hooks; fixed here: Some(false) keeps the meal as growCrop does, None leaves the template's. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:13:47 +01:00
Ludovic MarieandClaude Opus 5.5 553c11fd2f Launcher lang: drop the string main's redesign no longer uses
"Microsoft account · signed in" came back with this branch's tables in the
main merge; the code lost it in 86149f7da (i18n tests).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 03:13:12 +01:00
Ludovic Marie 09d3345c9b Merge pull request #5514 from kacpherek/fix/knot-and-orbs
🤖 Agent: fence knot clicks and orb smoothing; fixed here: the orb draws its correction step (previous kept), knot hit only within entity range; main merged. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:10:58 +01:00
Ludovic Marie 0a8def17fa Merge pull request #5654 from horizon-rust/sweepF/5521
🤖 Agent: #5521 by rdbtCVS finished (main merged: serialize keeps screenshotQuality and main's shadowCache). Screenshot Quality defaults to 1x, so at 1x and 100% scale F2 saves the window's frame as before; the scaled frame draws offscreen inside OOM/validation error scopes. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green (clippy -D warnings, workspace tests).
2026-11-01 03:08:38 +01:00
Ludovic Marie 6253329644 Merge pull request #5562 from ErikKalikas/mod-block-entity-draw
🤖 Agent: native block entity renderers; main merged here. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:08:32 +01:00
Ludovic Marie f28c2b348c Merge pull request #5574 from some-du6e/optifine/properties
🤖 Agent: OptiFine .properties reader (new crate, no dependents); fixed here: no .. paths, linear glob, no link loops; skip-release. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:06:11 +01:00
Ludovic Marie 707340c312 Merge pull request #5570 from some-du6e/perf/faster-prepare
🤖 Agent: parallel horizon-prepare (same output digest, release check covers both lane layouts); fixed here: the extraction test on case-insensitive disks. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:06:08 +01:00
Ludovic Marie 98642e8b22 Merge pull request #5512 from PzmuV1517/mods/can-survive
🤖 Agent: native can_survive / may_place_on hooks; fixed here: a piston head's neighbour change asks can_survive. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:36 +01:00
Ludovic Marie f752cb0841 Merge pull request #5503 from navinor/feat/mod-advancement-triggers
🤖 Agent: native advancement criteria triggers (+ the author's on_fire fix); main merged here. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:32 +01:00
Ludovic Marie 9a4faa20d7 Merge pull request #5502 from sirrayi/mods/biome-modifiers
🤖 Agent: biome modifiers place mod features; fixed here: unread modifier types and unknown names are skipped with a warning instead of dropping every content mod. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:27 +01:00
Ludovic Marie f90fb824b9 Merge pull request #3825 from martipops/split/09-windows
🤖 Agent: splitscreen part 9 (on #3801, testing only); main carried up the stack. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:22 +01:00
Ludovic Marie 0fefb3744c Merge pull request #3801 from martipops/split/08-join-with-controller
🤖 Agent: splitscreen part 8 (on #3794, testing only); main carried up the stack. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:18 +01:00
Ludovic Marie 3e34340e75 Merge pull request #3794 from martipops/split/07-controllers
🤖 Agent: splitscreen part 7 (on #3777, testing only); main carried up the stack. Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:14 +01:00
Ludovic Marie 84ccfa68de Merge pull request #3777 from martipops/split/06-two-players
🤖 Agent: splitscreen part 6 (HORIZON_SPLIT testing only, off by default); main merged here (main's headless loop runs before the split's event loop). Proven on a union of main 956e35469 with 19 open PRs including this head: pr_ready --quick green for it (clippy -D warnings incl. the server build, workspace tests, content-root tests, Python tests). CI parity covers the harnesses.
2026-11-01 03:05:09 +01:00
some-du6eandClaude Opus 5.5 c2e44bda19 Native mods: the content identity hashes the changed defaults
origin/main's identity hashes mod components' defaults so peers that
disagree are told apart; it read items.json raw, so a native mod's change
slipped past it. It reads them through item_defaults::read now. The read
and hook docs mention the stack-size range.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:01:14 -06:00
some-du6eandClaude Opus 5.5 9fb61eafa6 Native loot hooks: rustfmt the hookability test
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:59:46 -06:00
Ludovic Marie 690a1a4512 Merge pull request #5641 from reservedbytes/fix/save-thrown-projectiles
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:59:31 +01:00
some-du6e 2413dabfae Merge remote-tracking branch 'origin/main' into mods/loot-table-hooks
# Conflicts:
#	crates/horizon-client/src/game/creatures.rs
2026-10-31 19:58:43 -06:00
Ludovic Marie 6647659a3b Merge pull request #5542 from kacpherek/fix/remote-ride-gone-vehicle
🤖 Agent: stop riding a remote vehicle that is gone (Entity.baseTick); approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:57:14 +01:00
Ludovic Marie 321af52ea4 Merge pull request #5640 from rdbtCVS/fix/armor-stand-disabled-slots-and-lead-owner
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:36 +01:00
Ludovic Marie 504f90a09a Merge pull request #5639 from rdbtCVS/fix/mob-equipment-itemstack
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:33 +01:00
Ludovic Marie b3f954f038 Merge pull request #5636 from mariuslozo-boop/fix/inventory-revert-update
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:29 +01:00
Ludovic Marie c4fd435788 Merge pull request #5635 from rdbtCVS/fix/autosave-saves-players
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:25 +01:00
Ludovic Marie 039d514f4f Merge pull request #5634 from rdbtCVS/fix/removed-entity-drops-contents
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:22 +01:00
Ludovic Marie 8df8d78559 Merge pull request #5633 from rdbtCVS/fix/menu-still-valid-on-click
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests). Stacked on #5630, tested together.
2026-11-01 02:56:18 +01:00
Ludovic Marie 231b549303 Merge pull request #5632 from rdbtCVS/fix/open-menu-closes-previous
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests). Stacked on #5630, tested together.
2026-11-01 02:56:15 +01:00
Ludovic Marie daac34040f Merge pull request #5630 from rdbtCVS/fix/close-menus-on-leave
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:11 +01:00
Ludovic Marie bd9f2f2683 Merge pull request #5628 from JustKarkat/fix/held-block-item-culling
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:07 +01:00
Ludovic Marie ab49db6a46 Merge pull request #5626 from Teiwazik/shader/pack-settings
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests).
2026-11-01 02:56:02 +01:00
Ludovic Marie 2b7b1711c9 Merge pull request #5605 from Xmilon/native/image-tooltips
🤖 Agent: verified by Ludovic's AI agent (Claude). I read the diff. pr_ready --quick passed on main c4a65832e with this PR merged with 16 others from the same sweep (fmt, clippy -D warnings, horizon-client, -core and -render tests, Python tests). The image follows the text and the bundle component, the rule Horizon's bundle tooltip already uses; moving both under the title, as vanilla's index 1, is a separate parity fix.
2026-11-01 02:55:59 +01:00
Ludovic Marie 483a51e9f0 Merge pull request #5538 from xFedeT/fix/tadpole-brain
🤖 Agent: tadpole AbstractFish goals and TadpoleAi brain, mob_ai scenarios; approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:55:55 +01:00
Ludovic Marie 904e161bf1 Merge pull request #5519 from elythepreciouscat/perf/leaf-cutout
🤖 Agent: fancy leaves sorted front to back on the mesh workers (no render-thread cost); approved; verify green. Proven on a union of main f99f8feaa with 37 open PRs including this head: pr_ready --quick green for it (fmt, clippy -D warnings incl. the server build, 3077 workspace tests, 20 content-root tests, launcher clippy + 340 tests, 510 Python tests; the one failure is #5570's own new test, not in this PR). CI parity covers the harnesses.
2026-11-01 02:55:50 +01:00
some-du6eandClaude Opus 5.5 bb3d830ed9 Native loot hooks: the drops doc lists a mob's inventories and what comes before its table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:53:38 -06:00
some-du6e e6d8501486 Merge remote-tracking branch 'origin/main' into feat/native-modify-default-components 2026-10-31 19:49:47 -06:00
some-du6eandClaude Opus 5.5 885988f9d1 Native loot hooks: name every non-item death drop; say which tables a mob's drops come from
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:49:23 -06:00
Ludovic MarieandClaude Opus 5.5 df8aa42d9b Merge main into #5521 (serialize: screenshotQuality and main's shadowCache)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:48:52 +01:00
Ludovic MarieandClaude Opus 5.5 737633786c Merge #5571's main merge into #5578
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:48:33 +01:00
Ludovic MarieandClaude Opus 5.5 cfa8e57125 Merge main into #5571 (both new tests)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:48:08 +01:00
Ludovic MarieandClaude Opus 5.5 c652b50920 Merge main into #5562 (module list)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:48:08 +01:00
Ludovic MarieandClaude Opus 5.5 003f47c465 Merge main into #5535 (both new lan_e2e checks kept whole)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:47:57 +01:00
Ludovic MarieandClaude Opus 5.5 2c9b07d6d1 Merge main into #5514 (ExperienceOrb::tick's new argument kept)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:47:12 +01:00
Ludovic MarieandClaude Opus 5.5 08b3aca04c rustfmt's module order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:47:04 +01:00
Ludovic MarieandClaude Opus 5.5 175635b82b Merge main into #5503 (native_criteria next to main's native_components; both doc sections)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:46:57 +01:00
some-du6eandClaude Opus 5.5 96b7a3332f Native mods: keep the stack size in range for every reader
The clamp moves from the item table into item_defaults::read, after the
hook, so loot and item models read the same 1 to 99 (a negative size too).
The durability tooltip no longer underflows when a mod lowers max_damage
below a saved stack's damage (getDamageValue clamps to the max).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:46:50 -06:00
Ludovic MarieandClaude Opus 5.5 f9839553c7 Merge main into #4250 (lang tables: both sides' strings; account tests: both)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:46:38 +01:00
Ludovic Marie db4acf3105 Merge branch 'ss3801' into ss3825 2026-11-01 02:43:29 +01:00
Ludovic Marie a72a10a288 Merge branch 'ss3794' into ss3801 2026-11-01 02:43:28 +01:00
Ludovic Marie d5aefe39e2 Merge branch 'ss3777' into ss3794 2026-11-01 02:43:27 +01:00
Ludovic MarieandClaude Opus 5.5 96e310baa6 Merge main into #3777 (main's headless loop before the split's event loop)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:43:19 +01:00
Aaron Wang 6f941a5e92 Merge branch 'main' into fix/armor-stand-lerp 2026-10-31 21:42:57 -04:00
Ludovic Marie 8788e9438e Merge branch 'pr/F5570new' into fix5570 2026-11-01 02:41:57 +01:00
Ludovic MarieandClaude Opus 5.5 fd5f849c42 horizon-prepare test: ask the disk whether Tag.json and tag.json are one file
The skip compared Paths, which compare case-sensitively, so it never fired
and the extraction test failed on macOS's case-insensitive file system (the
later member, tag.json, wins there). It now checks whether a case-folded
path exists.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:40:52 +01:00
some-du6eandClaude Opus 5.5 d0013cfc31 Native loot hooks: document the hurt memory behind the killing player; scan the loot hook from its doc comment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:40:30 -06:00
some-du6eandClaude Opus 5.5 c6a450499b horizon-prepare: HORIZON_PREPARE_JOBS caps each step's threads
Each copying, hashing or compiling step spreads over that many threads; the lanes run theirs side by side, so it is no total limit.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:38:53 -06:00
Aaron Wang 1c6d6935d4 lan_e2e objects: a stand that falls, so the guest follows its moves
The objects check summoned its stands on the floor, where a guest that
never moved a remote stand still matched the host. This one starts 3
blocks up and turned to 200 degrees, so where it lands and its yaw are
compared between host and guest.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 21:38:02 -04:00
some-du6eandClaude Opus 5.5 2a3429819e Native mods: keep a changed stack size to vanilla's 1 to 99
A mod setting minecraft:max_stack_size to 0 made merchant.rs clamp(1, 0)
panic on the server thread. Items now keeps it to ExtraCodecs.intRange(1, 99),
and the docs say what else a malformed value does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:34:52 -06:00
some-du6eandClaude Opus 5.5 0ed5f554b3 Native loot hooks: name what the drops hook doesn't hear; check the loot hook's attributes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:34:39 -06:00
rdbtCVSandClaude Sonnet 5.5 c1348a3526 Armor stand DisabledSlots and another player's lead are locked
ArmorStand.interactAt ignored `DisabledSlots` (not even read or saved), so
locked stands could be emptied and filled. The stand now keeps the bitmask:
bit `filterFlag` disables a slot (an empty hand then takes from the main
hand), `+8` stops taking from it and `+16` putting into it
(`isDisabled`, `swapItem`), and the save writes it back.

A lead tied to another player was untied by anyone who clicked the mob;
only the player it is tied to unties it (`getLeashHolder() == player`).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:31:01 +00:00
Ludovic Marie 998049b7dd Merge branch 'pr/F5561new' into fix5561 2026-11-01 02:30:58 +01:00
Ludovic MarieandClaude Opus 5.5 8d3e175d49 Native loot drops: the killing player only while the hurt memory lasts
LivingEntity.dropFromLootTable supplies LAST_DAMAGE_PLAYER only when
playerKilled (lastHurtByPlayerMemoryTime > 0), so NativeLootDrops.player is
None for a mob that dies to the environment after the memory ran out
(arismoko's review). Vanilla's own loot is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:30:06 +01:00
Ludovic MarieandClaude Opus 5.5 58f3f290e6 Block contact hooks: no sweep without mod blocks, vanilla's order and on-pos
- swept_inside returns at once when no loaded block is outside minecraft:,
  so a server's items and projectiles no longer sweep their blocks every tick
  with no content mod (rdbtCVS's hot-path note).
- An item's step_on comes before its inside contacts, as
  Entity.applyEffectsFromBlocks calls stepOn before checkInsideBlocks.
- A mob's step_on (and the magma floor, turtle egg checks with it) reads
  getOnPosLegacy through block_on_legacy, so a mob over the edge of its
  supporting block still steps on it (arismoko's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:28:31 +01:00
rdbtCVSandClaude Sonnet 5.5 d4941b1155 Mobs keep the whole stack in each equipment slot
Mob equipment was an item id plus enchantments and a trim, so a picked-up
sword or armour piece lost its damage, name and components, a hand took one
item of a stack, a worn piece always compared as fresh, and death or
replacement dropped it repaired and unnamed.

`Gear.stacks` (the main hand keeps `main_hand_stack`) now holds the stack of
a slot that is more than one plain item. `set_item_stack` stores it,
`stored_stack`/`item_stack` read it back, replacing the item forgets it.
`equipItemIfPossible` takes the whole stack into a hand (one into an armour
slot) and drops the replaced stack as it was; `canReplaceCurrentItem` sees
the worn piece's damage and name; death drops the stack itself
(`Drop::Stack`), with the rolled damage when the drop is not preserved; the
save writes and reads it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:27:41 +00:00
Ludovic MarieandClaude Opus 5.5 064313363c Biome modifiers: skip what can't be read, with a warning
A modifier of a type not read yet (add_spawns, add_carvers, a mod's own
serializer) or naming a biome, feature or tag the root doesn't have (another
mod's ids, #c: convention tags) dropped every content mod. Real mods ship
those beside their feature modifiers, so that modifier is now skipped with a
warning naming the mod and file, as NeoForge skips an unresolvable holder
set (reservedbytes's review). A malformed modifier of a read type (unknown
step) still stops the build. The docs say #tag in features reads vanilla's
feature tags only for now (arismoko's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:26:32 +01:00
some-du6eandClaude Opus 5.5 9148cb676f Native mods: the default-components docs name every reader and thread
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:25:37 -06:00
some-du6eandClaude Opus 5.5 33efc55c5b Native loot hooks: keep a replaced table's random_sequence; test the loot hook stays hookable
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:24:36 -06:00
Ludovic MarieandClaude Opus 5.5 2c93d81d0c lan_e2e mount_other_level: horse and camel in the Nether by default
The whole matrix (7 mounts x 2 levels x 2 players + in_nether) takes about 10
minutes, and the nightly LAN suite already runs ~2,400 s of its 3,000 s
timeout (rdbtCVS's review). The default keeps the regression signal;
LAN_E2E_MOUNTS=all and LAN_E2E_LEVELS=all run everything.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:24:15 +01:00
Ludovic MarieandClaude Opus 5.5 934df75a12 Orbs draw their correction step; a knot is hit only within entity range
- Entity.commonTick sets the old position before the interpolation step, so
  the frame lerps from the pre-step position: previous keeps it now, where
  ExperienceOrb::tick had overwritten it and the orb still hopped (rdbtCVS).
- The knot is an entity, so a click counts as one on it only within the
  entity interaction range (filterHitResult), not the fence's block range
  (Joansitoh).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:23:26 +01:00
Ludovic MarieandClaude Opus 5.5 cb9e4996d3 OptiFine reader: paths stay in the root, linear globs, no link loops
rdbtCVS's review points 1-3:
- resolve returns None for a path with a .., . or empty segment, so a pack
  can never name a file outside the overlay root (vanilla refuses them).
- glob backtracks only to the last star: O(pattern x name), where every
  star tried every split before (a player-chosen item name, per frame).
- properties_files reads each real folder once and at most 16 deep, so a
  folder pack linking to its own parent no longer recurses to PATH_MAX.
Tests for each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:22:01 +01:00
Ludovic MarieandClaude Opus 5.5 8d98852911 Screen mouse hooks: buttons past 4 are the platform's numbers
winit's MouseButton::Other(n) is the raw X11/evdev number on Linux, not GLFW's
index, so the hook docs and the fragment say a button past 4 is
platform-defined (rdbtCVS's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:20:32 +01:00
rdbtCVSandClaude Sonnet 5.5 ca88f2e2e5 The dedicated server's autosave and save-all save every player
The player-file save in `save_game_metadata` only ran for a dedicated-mode
integrated server (the fixture), so a real `--dedicated` server (the
`DedicatedServer` role) skipped it on autosave and `save-all`: players were
only written when they left or the server stopped. It now runs for every
server role, as PlayerList.saveAll does.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:19:35 +00:00
Ludovic MarieandClaude Opus 5.5 fef6f8080a Consume effects: an empty namespace is minecraft's
Identifier.bySeparator reads ":clear_all_effects" as minecraft:clear_all_effects
(Consumable.CODEC decodes both), so it is vanilla's clear, not a mod kind for
the native hook (arismoko's review). Test added.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:19:25 +01:00
reservedbytes db4208923e fix(saves): thrown snowballs, eggs and ender pearls are saved with their chunk 2026-11-01 00:18:57 -01:00
rdbtCVSandClaude Sonnet 5.5 1e3758f6c1 A removed vehicle empties its container and drops its stand-in
A chest boat or chest/hopper minecart open in a menu keeps its slots in a
stand-in under a placeholder key. Removing the vehicle (broken, killed,
exploded, a rider's death, a portal) dropped the vehicle's stale items and
left the stand-in with a full copy a stale menu could still take from.
`release_vehicle_container` writes the menu's live slots back to the vehicle
first (so they are what drops or travels), then removes the stand-in, before
each removal.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:18:10 +00:00
Ludovic MarieandClaude Opus 5.5 124cd2ac33 Dispenser placement: getRotation is -90 facing up or down
DirectionalPlaceContext.getRotation is get2DDataValue() * 90, so a vertical
facing gives -90, not toYRot's 270. Both name East for vanilla's blocks
(from_y_rot, rotation segments), but the placement hook shows the raw value
(arismoko's review on #5516). Armor stands and boats keep toYRot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:17:18 +01:00
rdbtCVSandClaude Sonnet 5.5 f79e3b7e60 Check a menu's stillValid before every guest click
A menu was only validated once per tick, after the packet phase, so a click
could still land on a vehicle that was gone, a mount that died or left reach,
a lectern out of reach or a station block that was replaced. The click
packets (container click, button, slot state, rename, beacon, trade select,
bundle select, place recipe) now run one shared check first: the open menu,
whichever kind, must still be valid, else it closes like ContainerClose and
the click lands on nothing. The per-tick checks use the same predicates.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:16:19 +00:00
rdbtCVSandClaude Sonnet 5.5 a65385ff3e Close a leaving guest's open menu before the player is saved
Leaving the server (a disconnect, a LAN close, a dedicated stop) kept the
guest's cursor stack, trade payment, grid and station inputs in the menu, a
trade unwritten on the villager and a vehicle's stand-in in the level.
`guest_close_all` runs the same close code as ContainerClose for every kind
of menu, in the guest's own level, and runs from `HostEvent::Left` and
`save_leaving_guests` (LAN close, dedicated stop, integrated stop) before the
playerdata is written.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:15:36 +00:00
Ludovic MarieandClaude Opus 5.5 06584db009 Bone meal hook: a mod's no keeps the meal, as vanilla's growCrop does
native_is_valid_bonemeal_target answers Option<bool>: Some(false) refuses
the meal even where the block's template takes it (BoneMealItem.growCrop
returns false when isValidBonemealTarget is false), None leaves the
template's BonemealableBlock (arismoko's review). The client prediction
falls through to growWaterPlant on a refusal, as vanilla's useOn does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:15:16 +01:00
rdbtCVSandClaude Sonnet 5.5 ff6ef169c3 Every guest menu open closes the previous menu first
Each opener closed a different subset of the menus the guest could have
open (the trading screen left a mount or crafting state behind, a vehicle's
stand-in stayed, the old window id still took clicks). They now all start
with `guest_close_all`, as `ServerPlayer.openMenu` closes the open menu:
containers, vehicles, mounts, merchants, stations, lecterns, the crafting
table and native menus.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:13:35 +00:00
Aaron Wang ae2088d2ab Merge remote-tracking branch 'origin/main' into fix/armor-stand-lerp 2026-10-31 21:13:12 -04:00
Aaron Wang 642ed5b09b A stand riding the player is seated after the player's move
position_riders ran before the player moved and before previous_position
was saved, so a name hologram on the local player was drawn a full tick
behind it. Vanilla ticks the player (tickNonPassenger) before its
passengers (positionRider), so seat_own_stands now runs after the move,
from the previous_position just saved to the moved position.

The snap test now walks toward x 5 when the far sync comes and checks no
step back toward it is left, so it fails if a snap stops clearing the
handler. The ledger citations move off the first doc lines they were
read as ports from.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 21:13:11 -04:00
rdbtCVSandClaude Sonnet 5.5 322d45fda5 Close a leaving guest's open menu before the player is saved
Leaving the server (a disconnect, a LAN close, a dedicated stop) kept the
guest's cursor stack, trade payment, grid and station inputs in the menu, a
trade unwritten on the villager and a vehicle's stand-in in the level.
`guest_close_all` runs the same close code as ContainerClose for every kind
of menu, in the guest's own level, and runs from `HostEvent::Left` and
`save_leaving_guests` (LAN close, dedicated stop, integrated stop) before the
playerdata is written.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:12:39 +00:00
rdbtCVSandClaude Sonnet 5.5 7a19762534 Close a leaving guest's open menu before the player is saved
Leaving the server (a disconnect, a LAN close, a dedicated stop) kept the
guest's cursor stack, trade payment, grid and station inputs in the menu, a
trade unwritten on the villager and a vehicle's stand-in in the level.
`guest_close_all` runs the same close code as ContainerClose for every kind
of menu, in the guest's own level, and runs from `HostEvent::Left` and
`save_leaving_guests` (LAN close, dedicated stop, integrated stop) before the
playerdata is written.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 01:11:47 +00:00
Ludovic MarieandClaude Opus 5.5 4eb27f4216 Piston head: a neighbour change asks can_survive, so a mod's answer counts
PistonHeadBlock.neighborChanged calls state.canSurvive; the direct
piston_head_can_survive call skipped native_can_survive (arismoko's review).
can_survive keeps the piston rule as its default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:11:18 +01:00
some-du6eandClaude Opus 5.5 39e9a8e0d1 horizon-prepare: a stamp without outputs never matches; say what HORIZON_PREPARE_JOBS limits
stamp_matches compared a missing outputs key with the digest of a missing output as None == None. The docs and comments said HORIZON_PREPARE_JOBS is how many things run at once; it is how many threads copy and hash, the lanes all run at once unless it is 1, and it counts logical processors. With the split there are five lanes. java.rs's memory figure now says what it compared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:08:43 -06:00
JustKarkatandJustKarkat's AI agent 58a141917f Item quads: cull back faces like vanilla's item render types
A held two-sided model carries a quad per direction (a calibrated sculk
sensor's tendrils and amethyst planes, big dripleaf, block/cross), and the
item render types vanilla picks in ItemBlockRenderTypes -- solidBlockSheet
(entitySolid), cutoutBlockSheet (entityCutout), translucentItemSheet
(entityTranslucentCull) -- all keep the composite state's CULL. Horizon
culled only quads whose sprite has translucent pixels, so held blocks drew
both sides: the coplanar pairs striped (#4856, #4855) and a hay block's far
faces bled through the near ones (#4801).

Two-sidedness in vanilla is geometry, not culling: ItemModelGenerator's
bakeExtrudedSprite bakes a South and a North quad per layer, which
horizon-assets already mirrors.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-10-31 20:08:25 -05:00
MariuslozoandClaude Opus 5.5 7ca3d98901 Single player: the owner's leave saves the file it loads; .dat_old fallback
The owner loads the file level.dat names, but when it left (save_guest,
PlayerList.remove) only its own file was written; the named one waited for
the world's save that follows (stop_integrated: free_player_beds, the
chunks, then save_game_metadata). A game stopped or failing in between
left the named file older than the own one, and the owner then loaded the
older state. `playerdata::save_owner` writes the named file
(`Level::write_player`, also the player half of `Level::write`) before the
own one, so the file the owner loads is never the older.

`playerdata::load` (PlayerDataStorage.load) now falls back to the player's
`.dat_old`, as vanilla does: `write_dat` moves the old file aside before it
renames the new one in, so a save cut off there leaves only the
`.dat_old`, which the named file's read already used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:07:22 +01:00
MariuslozoandClaude Opus 5.5 3e75d6484b Port map: load_owner's row keeps the load and datafix gap; no stale citation
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:07:22 +01:00
MariuslozoandClaude Opus 5.5 be7de6230c Single player: the owner loads the player file level.dat names (#5351)
PlayerList.loadPlayerData gives the single-player owner the file level.dat
names (`singleplayer_uuid`), not their own. Since #5069 the integrated
server saves the owner to both files, `players/data/<singleplayer_uuid>.dat`
(offline `Player`) and `<owner uuid>.dat`, but loaded the owner's own file
first. A build that saves the owner to the named file alone (0.2.93 and the
stable releases before #5069) leaves the own file behind, so after
switching back the owner got an older inventory and position, and the next
save wrote them over the newer ones.

`Level::read` now says whether its player came from the named file, and
`playerdata::load_owner` applies it before the owner's own file; the own
file stays the fallback when the named file is missing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:07:22 +01:00
Ludovic MarieandClaude Opus 5.5 28bd35e286 Merge main into #5414
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:42 +01:00
Ludovic MarieandClaude Opus 5.5 f8fa3c9996 Merge main into #5441
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:41 +01:00
Ludovic MarieandClaude Opus 5.5 e33808a648 Merge main into #5450
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:16 +01:00
Ludovic MarieandClaude Opus 5.5 9c77c753a5 Merge main into #5442
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:15 +01:00
Ludovic MarieandClaude Opus 5.5 8b9d3235e1 Merge main into #5433
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:12 +01:00
Ludovic MarieandClaude Opus 5.5 7fcd32faa1 Merge main into #5418
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:04:11 +01:00
some-du6eandClaude Opus 5.5 5b16c46f5d horizon-prepare: the release check's repeat run compares the manifest too; four JVMs with the split
The repeat run's file check skipped horizon-resources.json, so a manifest the repeat rewrote differently passed. The comments that said at most three JVMs run at once now say four with the second server JVM.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:58:51 -06:00
TeiwazikandClaude Opus 5.5 5cc2d09d5c Shader packs: a Shader Options page for a Horizon pack's [[options]]
A Horizon pack declares options, but they stayed at their defaults: the player had no way to set them.
The picked pack now gets the same Shader Options... button as a shaders/ pack, and its values live in
shaderpacks/<pack>.txt, the file that kind already uses. A saved value counts as a change to the pack,
so it reloads with it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 01:58:23 +01:00
Aaron Wang a484e2b491 The player predicate sees a burning player on fire
player_entity left on_fire false, so a criterion's player predicate with
flags.is_on_fire true never matched, a mod's trigger (the reviewer's
case) or a vanilla one. It is Entity.isOnFire on a server now:
remainingFireTicks above 0 (players aren't fire immune).

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 20:56:58 -04:00
[email protected]andGPT-6 4497abec98 feat(mods): support image tooltip components
Co-Authored-By: GPT-6 <[email protected]>
2026-11-01 01:27:20 +01:00
Sylviromi 068b434a3d Merge branch 'screen-render-post' into screen-mouse-button 2026-11-01 03:04:24 +03:00
SylviromiandClaude Code ccb1474a03 Native mods: a server's trading window gives the screen view its title
remote_open_merchant left the menu title unset, so NativeScreenView.title was
None on that window although the docs promise the server's title.

Co-Authored-By: Claude Code <[email protected]>
2026-11-01 03:04:23 +03:00
ErikKalikasandClaude Opus 5.5 600e7bec77 Native mods: test that infinite bounds skip a frustum that culls everything
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:02:38 +02:00
ErikKalikasandClaude Opus 5.5 4ad692a07b Native mods: leave native_draw_item and native_block_state_property for a follow-up
Keeps this pull request near the size limit; the block state stays in the
hooks, whose signatures can't change once merged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:56:04 +02:00
ErikKalikasandClaude Opus 5.5 97c8e9ed15 Native mods: the block state in the block entity hooks, items and state properties for drawing
Both hooks get the block's state, as a renderer reads
blockEntity.getBlockState(); native_block_state_property reads its facing
without a world lookup, and native_draw_item draws an item stack under the
pose stack as a campfire does (resolve_item, submit_item_world).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:46:43 +02:00
TeiwazikandClaude Opus 5.5 6a47c9eab5 Docs: shadow_color carries the vertex colour's shading
The colour is the mesher's vertex colour, so it includes the face's directional shade and ambient occlusion as well as the tint. The docs now say so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 00:38:31 +01:00
SylviromiandClaude Opus 5.5 4685ab06e2 Native mods: fragment names the renamed gate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:35:45 +03:00
SylviromiandClaude Opus 5.5 7c42d32723 Native mods: the screen-hook gate is a helper, not a hook point
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:35:34 +03:00
some-du6eandClaude Opus 5.5 42cfa789b9 Native mods: document the default-components hook, share the catalog scan
docs/native-mods.md gets an "Items' default components" section beside the
render hook points: the target path, both sides, threads, vanilla clients,
creative search. The catalog test reads free-function hooks with one helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:34:11 -06:00
some-du6eandClaude Opus 5.5 9cecb86ec2 horizon-prepare: review fixes for the parallel preparation
- A failed exporter compile fails every lane at once instead of being
  retried by each lane that waits for it.
- The gameplay facts exporter streams to a .partial file and moves it into
  place: a run stopped halfway no longer leaves a short file the next run
  would keep.
- pool::map takes no per-thread state (a jar clone costs two Arc counts);
  pool::lanes and map are tested at 1 job and several. With 1 job the first
  failing lane stops the rest, as documented now.
- The lanes' log and stage messages go through relay(), which a test now
  checks; block behaviour reuses export_on; the stamp inputs are worked out
  once; the fifth lane exists only when it splits; dead checks are gone.
- check_prepare_exporters.py also prepares with 2 jobs (the default path
  below 6 cores, 5 JVMs) and repeats that run, which must start no JVM and
  change no file.
- The docs say what HORIZON_PREPARE_JOBS does, that the split is not yet
  timed on 6 cores, and the split's measured ~400 MB.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:33:51 -06:00
ErikKalikasandClaude Opus 5.5 ee34e1982a Native mods: cull mod block entities by the bounds their mod gives (getRenderBoundingBox)
native_block_entity_render_bounding_box lets a mod widen the box its block
entity is culled with (NeoForge's getRenderBoundingBox), or make it
infinite, so a tall part stays drawn while its block is out of view. The
view distance check is strict, as Vec3.closerThan.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:33:44 +02:00
SylviromiandClaude Opus 5.5 5f87b9e60d Native mods: mouse hook docs, which screens fire
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:33:16 +03:00
MariuslozoandClaude Opus 5.5 bc13a0403f lan_e2e mount_other_level: the player in the Nether, and no clicks on the last mount
The other way round from a force-loaded chunk: the player and a horse in the
Nether while the other player keeps the Overworld ticking; the Overworld's pass
must leave the menu alone (in_nether, --server-thread). The player lands in
creative and is put back on the platform once it stands, so a cave below the
spot doesn't drop it out of reach.

A killed mount lies at the spot for 20 ticks (LivingEntity.tickDeath), and the
next one, summoned there 10 ticks later, took the clicks meant for it on a
slow run: wait until the client no longer sees it. LAN_E2E_LEVELS takes a
comma list (the_nether, the_end, in_nether), as LAN_E2E_MOUNTS does; the
force-load is removed even when the wait after adding it fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:27:22 +01:00
SylviromiandDeepSeek e39e0088cb Native mods: mouse button press and release on the open screen
`native_screen_mouse_button_pressed_pre` and
`native_screen_mouse_button_released_pre` (`ScreenEvent.MouseButtonPressed.Pre`
and `.MouseButtonReleased.Pre`) hand a mod the open screen, the button's GLFW
index (0 left, 1 right, 2 middle, 3 back, 4 forward, `Other(n)` = n) and
GLFW's modifier bits (shift 1, control 2, alt 4, super 8), plus `canceled`
for a press the screen never sees.

They fire first in `handle_window_event`'s closure, before the screen or the
container hotkey, sort and header buttons act, so a cancelled press leaves
nothing half-applied and a cancelled release keeps a drag held. The pad's and
the keyboard focus's made-up clicks are not the mouse and fire nothing.

The gate and the view two more input hook families need are here: the pure
`glfw_modifiers` and `glfw_button`, `native_screen_open` (a screen of the
game's over `Screen::Playing`, no connect or load) and `screen_view_now`,
which builds only the `HudBatch` its rect needs. The hook docs and the
fragment name the screens that never fire: the title screen, the connect and
loading screens, the prompts, books and sign editors drawn over gameplay, and
any screen while a world loads or saves.

The fragment is pending, so this is "API next".

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 02:25:08 +03:00
some-du6eandClaude Opus 5.5 d253b46872 OptiFine packs: a shared reader for their .properties, matchers and paths
New crate horizon-optifine, the layer every OptiFine-format feature (CTM,
CIT, random and emissive entities, CEM, sky, colours) reads its files
through: Java .properties with escapes and continuations, name matchers
(pattern:, ipattern:, regex:, iregex:), range lists with parenthesised
negatives, id lists with ! exclusions, block lists with states, OptiFine
path resolution (~/, ./, namespace:) and the optifine/ and mcpatcher/
folders of every namespace. Written from OptiFine's public format docs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:24:52 -06:00
some-du6eandClaude Opus 5.5 619b5fe709 Native mods: change the default components of any item (API next)
A mod could not change a default component of an item it didn't add (a
stack size, a food, a tool rule), as NeoForge's ModifyDefaultComponentsEvent
lets it. native_modify_default_components hands a mod each item's defaults
in items.json's form as they are read, to set or remove components.

Every reader of items.json (the item table, loot, enchanting, item models,
banners, pot sherds, rarity in chat) now goes through
horizon_world::item_defaults::read, so a change reaches all of them, and
stacks are saved and sent against the changed defaults like vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:21:27 -06:00
Teiwazik b10619ee4f Merge branch 'shader/s6-shadow-solid' into shader/s7-shadow-translucent
# Conflicts:
#	crates/horizon-client-render/src/gfx/native_shadow.rs
2026-11-01 00:16:37 +01:00
TeiwazikandClaude Opus 5.5 d5316caaaf Shader API: review fixes for [shadow]
The depth range now reaches past the build height below the camera and shadow_position clamps z,
so a point past the far end stays lit, the blank map included. The light's box culls the native
lists instead of a section radius that lost casters at low sun. The native camera's snap is
euclidean, the resolution is clamped once, and the distortion and the camera each live in one
place. The generated reference lists the shadow helpers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 00:14:36 +01:00
huiandClaude Opus 5.5 36b0d2670f Native mods: enchant_effect honours requirements and names the mob hit
The first cut told an entry's kind before its requirements were checked, so a
failed requirement still fired, and gave the hook no way to know which mob was
hit. The kind is now told after the check, with the mob's id. A bare type name
is minecraft's own, so ignite runs as minecraft:ignite instead of reaching the
hook. The docs now say what's told: attacker on victim, a player's weapon on a
mob.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 00:13:18 +01:00
some-du6eandClaude Opus 5.5 5bc2501d7f Native loot hooks: clippy's auto-deref
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:10:58 -06:00
some-du6eandClaude Opus 5.5 c3e5e7866c horizon-prepare: block behaviour on a second server JVM with 6+ cores
A fresh preparation on a 6-core laptop finished its other JVM lanes by ~17 s,
then ran the server lane alone for ~20 s more: its last export, the world
generator's block behaviour (11.5 s there), sat behind everything else on
that JVM. From 6 cores (pool::jobs, so HORIZON_PREPARE_JOBS too) it and the
structure blocks run on a fifth lane, in their own server JVM. It pays a
second Bootstrap and ~400 MB of peak memory, so it stays off below 6 cores:
forced on this 3-core box it took 44.9 s against 40.5 s.

Same output (digest unchanged either way). check_prepare_exporters.py now
runs its batched preparation that way and expects 6 JVM launches; it passes
(23,204 files).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:07:12 -06:00
some-du6eandClaude Opus 5.5 75047c93d9 horizon-prepare: cut its memory peak from 1.47 GB to 1.06 GB
Measured on the pinned 26.3 jars, sampling every process's RSS: the peak came
at the end, from the server worker alone (1.25 GB). Its live heap stayed under
~250 MB; the rest was garbage the serial collector promoted and kept, mostly
from two exporters:

- The collision exporter wrote, and the worker cached, 62 MB of verification
  inputs (printf'd, ~4 s) that only the parity harness reads. Inside the
  prepare worker it now skips them; standalone runs still write them.
- The gameplay facts exporter built its 56 MB file as a Gson tree, then a
  String, then bytes. It now streams each state to the file; the bytes are
  the same (re-serializing the parsed file with Gson.toJson gives them back).

The worker now peaks at ~480 MB. In the Rust process, every thread reading a
jar parsed its member list again (~10 MB each, eight at once); they now
share one parse through clones of the archive that read at their own offsets.
Its heap peak halved (96 to 51 MB).

check_prepare_exporters.py passes (23,054 files, 5 JVM launches); the output
digest is unchanged. "Movement and attributes" takes 12-13 s instead of 16-18 s.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:07:10 -06:00
Teiwazik b13a22fdcb Merge remote-tracking branch 'origin/main' into shader/s6-shadow-solid 2026-11-01 00:06:32 +01:00
huiandZCode AI agent (GLM-5.3) f074c2d4bd Native mods: an enchantment's effect of a mod's own kind
An enchantment's effects entry that names an effect.type in a namespace
outside minecraft: used to be silently skipped at the firing point. It
now reaches native_enchant_effect(enchantment, kind, data, level) — the
post_attack components first, where the weapon's holder is the acting
player — and the game's own damage, sounds, effects and drops are open
to the hook. An unclaimed kind does nothing.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-11-01 00:06:13 +01:00
some-du6eandClaude Opus 5.5 a9d6eaf3b3 horizon-prepare: serial collector for its JVMs; time steps after compiling
The serial collector is as fast as the parallel one for these JVMs (fresh run
63-66 s either way, pinned 26.3 jars) and keeps the three that run at once to
1.25 GB together instead of 2 GB. An export's time no longer includes waiting
for javac. A compile error still names the export it stopped.

tools/check_prepare_exporters.py passes: 23054 resource files match between two
isolated runs and a batched one, with 5 JVM launches and no compiler.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:05:57 -06:00
some-du6eandClaude Opus 5.5 a935fe0bf2 horizon-prepare: run its JVMs as batch jobs (C1 only, ParallelGC)
The exporters, the data generator and javac are short batch JVMs, three of them
running at once. The C2 compiler's background work and G1's concurrent threads
cost them more CPU than those win back. With -XX:TieredStopAtLevel=1
-XX:+UseParallelGC (javac gets them through -J), a fresh preparation from the
pinned 26.3 jars took 67 s instead of 102-105 s on the same machine. The output
digest (tools/prepare_output.py) is unchanged. java.rs is not part of the
exporter bundle key, so cached bundles stay valid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:05:57 -06:00
some-du6eandClaude Opus 5.5 78d211e0a4 horizon-prepare: run independent steps at once, skip unchanged exporters
Fresh preparation ran everything one after another. The steps now run on four
lanes: the server exporter JVM (same order as before, since its exporters share
state), the client exporter JVM then the command registries', the data generator
then the creative inventory's, and the Rust-only copying. At most three JVMs run
at once, still five JVMs in total. A worker stops when its lane is done. The two
exporter sides compile at once. Asset extraction, structure templates, worldgen
bundles, server library unpacking, client library imports, inventories and pin
checks run on several threads. Every pinned file is still hashed in full.

Repeat runs always started the server JVM for the block behaviour and
structure-block exporters, which had no skip check. They now keep a stamp.json
in .local (exporter bundle key, server jar, preparer sources, output sha256), so
an unchanged repeat run starts no JVM. The prepared files are unchanged.

Every step logs how long it took. HORIZON_PREPARE_JOBS=1 runs it all serially.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:05:56 -06:00
MariuslozoandClaude Sonnet 5.5 276b77a0f0 lan_e2e: mount_other_level covers every mount, the End, getting off and reopening (#5331)
The check now opens a tamed horse, camel, llama, trader llama, nautilus
(owned), chested donkey and mule by shift + right click and by the
inventory key while riding, and again by shift + right click after getting
off, on the host and on the guest, with a chunk force-loaded in the Nether
and in the End. Every way is reported on its own and must still be open 20
ticks later.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 00:05:40 +01:00
some-du6eandClaude Opus 5.5 8226fb0b51 Native loot hooks: a mob's drops name its position as the origin
LivingEntity.dropFromLootTable's ORIGIN is the mob's position, not the point
its drops spawn from.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 17:04:52 -06:00
SylviromiandClaude Opus 5.5 dc6b189768 Native mods: the screen view's rect is the drawn window; menu None when unknown
The rect comes from the same container image the game draws, so the
merchant and the beacon report their wider, shifted window. An unknown menu
type is None rather than an empty id, and slot_count is checked against
slots() in debug builds.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:02:41 +03:00
v3nn7andCodex 3cdf2466ae Test steering items in both hands
Co-Authored-By: Codex <[email protected]>
2026-10-31 23:59:48 +01:00
v3nn7andCodex 88c6720079 Allow steering mounts with offhand item
Co-Authored-By: Codex <[email protected]>
2026-10-31 23:59:42 +01:00
some-du6eandClaude Opus 5.5 0c987c9f63 Native loot hooks: round 3 fixes
A server names no player of its own as a mob's killer, and the drops
context's fields aren't flagged as never read.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:56:21 -06:00
SylviromiandDeepSeek dfcc1eb4dc Native mods: the screen hook's payload, and a view that does not allocate
`native_screen_render_post` (`ScreenEvent.Render.Post`) hands the mod the
mouse in GUI pixels and the frame's partial tick besides the screen view,
as the event's `mouseX`, `mouseY` and `partialTick` do, so a mod can draw a
hover highlight.

The view also carries `menu`, the vanilla menu type of the menu the server
opened (`minecraft:generic_9x3`, `minecraft:furnace`, a mod's own id), and
None for a window the client opened. Every container, station and mod
window draws on the inventory screen, so `id`'s doc says they all report
`inventory` and are told apart by `menu`, `title` and `slots`.

The view is built without allocating: slots are counted the way `Game::slots`
lists them (`slot_count`, with `station_slot_count`, `mount_slot_count` and
`native_screen_slot_count` beside their list versions) instead of being
collected into a `Vec` every frame, and the id comes from the screen
`hud_layers` drew rather than from a second argument beside `self.screen`.

The hook and the fragment say which screens never fire it: the title screen,
which returns before its point, and the prompts, books and sign editors
drawn over gameplay after it, which keep `Screen::Playing`, are skipped, and
draw over what the hook drew. Vanilla fires Render.Post for them; that gap
is now written down.

With no mod installed the body stays `let _ = (...)`: the frame is vanilla's.

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 01:55:58 +03:00
rdbtCVSandClaude Opus 5.5 6ebfcd0ded Spawner minecart: /summon loads its spawner; review fixups
- /summon minecraft:spawner_minecart with no NBT runs BaseSpawner.load on an
  empty compound, as SummonCommand.createEntity does, so SpawnPotentials is
  WeightedList.of(new SpawnData()) rather than empty.
- The save test passes no raw tag and changes the delay after loading, so it
  checks the cart's own save arm.
- The cart model test covers spawner_minecart#main.
- Port ledger: MinecartSpawner's tick, save, load and display state are ported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:55:46 +00:00
rdbtCVSandClaude Opus 5.5 0ddaee3da8 Native hooks: a villager bonemeals a mod block only when it is a crop
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:54:24 +00:00
rdbtCVSandClaude Opus 5.5 75c6194615 lan_e2e: realign the riding entry of the check list
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:51:18 +00:00
huiandZCode AI agent (GLM-5.3) 1742791286 Native mods: a consumable's effect of a mod's own kind
The loader dropped an on_consume_effects entry whose type the game
doesn't know silently. It now keeps the kind and the JSON the file
wrote, and the game hands them to native_consume_effect when the item
is consumed, on the server thread, for the acting player. The game's
own effects, sounds, teleports and drops are open to the hook; an
unclaimed kind does nothing, as an unknown kind on any loader.

A bare type name without a namespace is minecraft's own — apply_effects
is minecraft:apply_effects — so only a namespaced kind outside
minecraft's becomes Native; an unknown minecraft: kind is dropped. The
hook sees the item's own consumable component, not a patch on the stack.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 23:50:37 +01:00
some-du6eandClaude Opus 5.5 7f9f86a7ab Native loot hooks: round 2 fixes
The wall-table test's registry has every block's default, a kill by a guest
who left names no player, and the load hook's docs say two loads can overlap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:50:04 -06:00
huiandZCode AI agent (GLM-5.3) 1841925457 Ingredient shapes: compound is the union, intersection the all-of; ingredient_type key; fluid dropped
NeoForge's CompoundIngredient matches if any child does — the union of
their items, not the intersection (that is IntersectionIngredient).
The ingredient_type key is neoforge:ingredient_type, type only as the
older packs' spelling; children is the field name, ingredients as the
older spelling. neoforge:fluid is not among NeoForge's item ingredient
types (its fluid ingredients are a separate registry for fluid stacks,
not items), so it is dropped until a use case arrives with a source.

Review: rdbtCVS's agent in the pull request.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 23:49:30 +01:00
Claude 4a361d034b native_summon: a root a mod kept out has no id
spawn_loaded_entities hands out the id before native_entity_joins runs,
and a cancelled orb doesn't give it back, so the fallback returned an id
no entity holds. Take it only when an orb holds that id (root_id), and
test both cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QT61YPiPzVNysmVHsH
2026-10-31 22:48:42 +00:00
some-du6eandClaude Opus 5.5 603fda280e Native loot hooks: review fixes
Empty stacks a mod leaves don't drop, the load hook runs outside the
cache's lock, a mob's context names the table that rolled (one helper with
drops::die), the wall-variant table lookup has a test, and the docs say
which breaks the drops hook hears.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:44:37 -06:00
some-du6eandClaude Opus 5.5 f749bbeef1 Native mods: change vanilla loot tables and drops
native_loot_table_load (horizon_world::loot) hears each loot table's JSON as
it loads, after the data packs' (LootTableLoadEvent, LootTableEvents.MODIFY),
and native_loot_drops hears a broken block's or a dead mob's items with what
dropped them (global loot modifiers, MODIFY_DROPS).

Closes #5522

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:44:37 -06:00
ElyTheCatandCodex 8f785872f8 Remove redundant render upload diff
Keep the PR focused on worker-side side sorting.

Co-Authored-By: Codex <[email protected]>
2026-11-01 02:14:05 +03:30
SylviromiandDeepSeek 3e5b8b3ecc Native mods: draw over the open screen
Adds `screen_render_post`, the point of NeoForge's `ScreenEvent.Render.Post`: once a frame, after the
open screen has drawn itself and its tooltips, a native mod may draw over it. It is handed a
`NativeScreenView`: the screen's id (one per `Screen` variant, from `Game::screen_id`), the open
menu's title (borrowed from the menu, not copied: no per-frame allocation), the container panel in GUI
pixels or the whole window, and the menu's slot count.

The firing site is the end of `hud_layers`'s screen match, where every screen but Win and Options is
drawn. Those two keep their early returns: folding them into the match would draw the HUD and the
prompt path behind them, so the hook fires on their own path, after their drawing, instead. It does
not fire while playing with no screen, and it is a `&self` hook on the window only, so a mod can
read and draw but not change the screen.

With no mod installed the body is `let _ = (...)`: the output stays vanilla's.

The hook is API next (`tools/native-hook-fragments/screen_render_post.json`); `native-hooks.json` and
`NATIVE_HOOKS_API` are untouched.

Co-Authored-By: DeepSeek <[email protected]>
2026-11-01 01:38:39 +03:00
rdbtCVSandClaude Opus 5.5 de0c665692 Scaled screenshots: halve the size when video memory runs out, not crash
An 8x screenshot at 200% Resolution Scale with the video memory already full
crashed: the depth buffer's allocation failed, and its view raised a
validation error that only an out-of-memory scope was watching. The target
and depth buffer, and the whole scaled frame, now run inside out-of-memory
and validation scopes; a failure writes no PNG and tries again next frame at
half the size, down to the window's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:37:16 +00:00
ErikKalikas 30350feba8 Merge remote-tracking branch 'upstream/main' into mod-block-entity-draw
# Conflicts:
#	crates/horizon-client-render/src/hud.rs
#	crates/horizon-client/src/game.rs
2026-11-01 00:36:06 +02:00
ElyTheCatandCodex 31456cb738 Move leaf draw ordering into mesh workers
Sort solid cutout quads while the worker-side side buckets are built, so rendering uploads the canonical worker result without a render-thread permutation or duplicate allocation.

Co-Authored-By: Codex <[email protected]>
2026-11-01 01:56:35 +03:30
Kacpherek 0bf4904595 Stop riding a remote vehicle that is gone
A rider kept its vehicle id after the vehicle left the level. A respawn into
another level keeps the id when KEEP_ENTITY_DATA is set, and a removed
nautilus or minecart leaves it behind. remote_ride then kept the player in
the riding pose with no vehicle under it: it did not move, sneaking did
nothing, and the seat stayed until a vehicle packet named it again.

Vanilla Entity.baseTick stops riding a vehicle that is removed
(isPassenger() && getVehicle().isRemoved() -> stopRiding(), Entity.java).
Do the same in remote_ride when no minecart, boat, cushion or mob has the
id, as tick_riding already does on the local path (boats.rs).
2026-10-31 23:25:37 +01:00
Federico TaglianiandClaude Sonnet 5.5 b5d2db2e60 Tadpole: AbstractFish goals and swim, TadpoleAi brain
A tadpole is an AbstractFish with a brain: it gets panic 1.25, avoid-player and
the fish random swim goals, SmoothSwimmingMoveControl(85, 10, 0.02, 0.1, true),
SmoothSwimmingLookControl(10), water-bound navigation, and the TadpoleAi brain
(panic, look at player, follow temptation, swim/walk/stay-in-water gate).
Its travelInWater is AbstractFish's (0.01 speed, 0.9 drag, -0.005 gravity).

Adds mob_ai parity scenarios tadpole_idle, tadpole_tempted, tadpole_panic.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 23:24:16 +01:00
[email protected]andGPT-6 69c0f83d85 docs: clarify landing hooks preserve block-specific sounds
Co-Authored-By: GPT-6 <[email protected]>
2026-10-31 23:20:13 +01:00
[email protected]andGPT-6 e1c1669993 feat(mods): add server-side block contact hooks
Adds step-on, inside and fall-on callbacks with entity and block context; landing damage is adjustable before sounds and hurt.

Co-Authored-By: GPT-6 <[email protected]>
2026-10-31 23:19:26 +01:00
sirrayiandDeepSeek Harness 86165f5754 Native mods: the placement fragment's summary is one JSON string
The hardening edit put a raw newline inside the fragment's `doc_summary`, which
JSON reads as a control character: `catalog_matches_hook_points` failed to
parse it. The line break is the `\n` escape the other fragments use.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 22:15:22 +00:00
MariuslozoandClaude Opus 5.5 8b5a93cbd0 Keep a horse inventory open while another level ticks (#5331)
tick_lan_mount_menus checked every open mount menu on every level pass,
not only in the player's own level. When a second level ticks (a player
or a force-loaded chunk in the Nether, a portal ticket), its pass found
no such mob and closed the menu the tick it opened. Like the station,
crafting and merchant ticks, it now skips players not in the level in
place (ServerPlayer.tick checks stillValid in the player's level).

lan_e2e mount_other_level force-loads a Nether chunk and checks that the
host's and the guest's horse inventories stay open, opened with shift +
right click and with the inventory key while riding.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:10:52 +01:00
sirrayiandDeepSeek Harness 2aec9b416e Native mods: the placement hook's test covers a dispenser too
The issue asks the hook to run for every placer that goes through vanilla's
path, not only the player. `the_level_chooses_the_state_a_block_is_placed_in`
now drives a dispenser's `DirectionalPlaceContext` as `dispense_shulker_box`
builds it — `directional_can_place`, then `place_block` — and asserts the
furnace lands in the hook's state, with the hook handed the dispenser's own
click (its yaw, the front block it replaces).

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 22:09:58 +00:00
sirrayiandDeepSeek Harness 6c494834f5 Native mods: a placement answer must be the placed block's own state
Review point on #5516: an answer naming another block would place a block the
item isn't bound to, and skip that block's `setPlacedBy` and sound. Vanilla's
`getStateForPlacement` sets properties on the block's own `defaultBlockState`,
so the hook's answer is taken only for a state of the block being placed;
another block's, like an id past the state table, leaves vanilla's state.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 22:07:45 +00:00
sirrayiandDeepSeek Harness 0be8029be8 Native mods: bone meal on a mod block
A mod block took no bone meal: `BoneMealItem.growCrop` looked the block's
`BonemealableBlock` up in the port's classes and stopped. The level now
answers vanilla's three questions for it, ahead of the `BonemealableBlock` a
content template would give it, so a mod block can refuse, roll and grow:

- `is_valid_bonemeal_target` — `isValidBonemealTarget`. Asked on both sides,
  as vanilla asks it, so a client predicts the use with the mod's answer.
- `is_bonemeal_success` — `isBonemealSuccess`, drawing from
  `native_level_random` so a mod's draws stay in vanilla's order.
- `perform_bonemeal` — `performBonemeal`, with the level's ticks.

The hooks are `native_is_valid_bonemeal_target`, `native_is_bonemeal_success`
and `native_perform_bonemeal` (a fragment, no API number taken), plus
`native_level_random` for a world hook, the stream `native_level_random
(dimension)` hands a server hook.

One insertion point serves every placer: `Sim::grow_crop` is where the
player's `BoneMealItem.useOn`, a dispenser's `BoneMealItem.growCrop` and a
villager's bone meal meet, and the meal is used up and level event 1505 fired
by the callers as before, so a mod block gets vanilla's particles and sound.
The client's copy of the level now forwards the one read-only question
through `DryLevel`, and a mod block's growth particles stay inside it, as
vanilla's `GROWER` type does, where the port knows no type for it.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 22:05:15 +00:00
ElyTheCatandCodex 095b522455 Optimize alpha-tested leaf rendering order
Reorder solid cutout quads front to back during GPU upload while preserving the canonical section mesh and shared index batching. Include the before and after leaf-scene captures used for validation.

Co-Authored-By: Codex <[email protected]>
2026-11-01 01:33:08 +03:30
rdbtCVSandClaude Opus 5.5 02caa53a27 Scaled F2 screenshots: game resolution times a Screenshot Quality option
F2 now saves the frame at the game's resolution (Resolution Scale above
100%) times a new Video Settings option, Screenshot Quality (1x, 2x, 4x,
8x; 1x by default). The frame is drawn once into its own offscreen
target, with the HUD zoomed to the same layout, and capped to the GPU's
texture and buffer limits; if the large targets can't be allocated the
window's frame is saved as before.

Fixes #3030

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 22:01:44 +00:00
rdbtCVSandClaude Opus 5.5 2f7dd09e90 Windows import check: uiautomationcore.dll ships with Windows (GPUI Fast's accesskit_windows imports it)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:58:38 +00:00
rdbtCVSandClaude Opus 5.5 4e61981299 Merge #3801's branch (with main) into #3825
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:51:17 +00:00
Kacpherek 67c4b573ad Smooth an experience orb's server corrections over 3 ticks
ExperienceOrb.createInterpolationHandler returns a LinearInterpolationHandler, so a client spreads each server position correction over 3 ticks (doInterpolate in Entity.commonTick) and the orb's own move carries the target along (applyPredictedMovement). The remote path put the orb at the new position at once, so it jumped on every correction; the comment claimed vanilla's NO_OP handler.
2026-10-31 22:45:25 +01:00
Kacpherek 6d2682cf87 Take leads back or snip them only when the click hits the fence knot
LeashFenceKnotEntity.interact runs only when the ray meets the knot (its box from recalculateBoundingBox, 0.375 wide, y + 0.375 to y + 0.875) before the post. A click elsewhere on the fence is FenceBlock.useWithoutItem or LeadItem.useOn, which only ties the player's own mobs (LeadItem.bindPlayerMobs). interact_knot ran the take-back and shears branches for any fence click, so clicking the top of a post with stone took a tied horse's lead instead of placing the stone, and shears snipped every lead.
2026-10-31 22:45:25 +01:00
rdbtCVSandClaude Opus 5.5 72e89aca4a Splitscreen: say Linux's gilrs matches a reconnected pad by model too
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:44:53 +00:00
rdbtCVSandClaude Opus 5.5 19c46b3fde Biome modifier ore test: the configured feature in 26.3's flat form
The prepared root holds features without vanilla's old "config" wrapper and
with a block state as an id, so the zinc ore never decoded and the
content-root test failed over real data (CI skips it without .local/).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:43:01 +00:00
rdbtCVS 500c13a0bf Merge branch 'pr-3794' into pr-3801 2026-10-31 21:42:23 +00:00
sirrayiandDeepSeek Harness 09a67d8c48 Native mods: a mod block chooses the state it is placed in
`getStateForPlacement` had no way in for a mod: a block could only be placed
in its default state. `LevelAccess::before_state_for_placement` now asks the
level, once per block whose placement state is computed, with vanilla's
`BlockPlaceContext` (the clicked face, the placer's rotation, the clicked
position, whether the clicked block is being replaced, inside, the item in
hand) and the fluid of the position the block would go in, so a block can
face the player, sit on the clicked face or come waterlogged. The answer is
`Some(Some(state))` to place that state, `Some(None)` to refuse the placement
as vanilla's `null` does, and empty to let the block's own placement run.

`native_state_for_placement` is the hook a native bundle answers with, and
`native_state_with_property` builds the state from the block's default state
and one of its properties (a content block's declared ones included).

`Ctx`, the resolved context, is now `PlacementContext`, public and documented
as what a mod block is handed. Both placers that go through vanilla's path
reach it: the player's `BlockItem.place` and a dispenser's
`DirectionalPlaceContext` (both `Sim::state_for_placement`), and the answer
still takes its survival and entity obstruction checks.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 21:41:50 +00:00
rdbtCVSandClaude Opus 5.5 6d1237c04a Merge main into #5476
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:39:49 +00:00
rdbtCVS 746bdf8a96 Merge remote-tracking branch 'origin/main' into pr-3794 2026-10-31 21:39:39 +00:00
rdbtCVS 288ba8ca10 Merge branch 'pr-3777' into pr-3794 2026-10-31 21:39:34 +00:00
rdbtCVSandClaude Opus 5.5 f7bdb7449e Merge main into #3777
Main's handlers take a loop handle (EventLoopCtl) and about_to_wait's body is Game::idle: exit_on_error takes the handle, the split guard stays in idle, and run_event_loop stops the narrator after the split's loop.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:34:01 +00:00
levzzz eaf3bf704d Launcher: the AppImage path test runs on Unix only
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 39696d7a74)
2026-11-01 02:33:31 +05:00
Aaron Wang e103983b5d Native mods fire their own advancement criteria triggers
An advancement of a content mod or a data pack can name a trigger of
the mod's (mymod:pipe_connected); the tree keeps it, but only the game's
own events called Game::trigger, so nothing could grant it.

native_trigger_criterion(uuid, trigger, test) is vanilla's
SimpleCriterionTrigger.trigger for one player: each open criterion of
that trigger goes to the mod's test, then to its player predicate, and
those that pass are granted and awarded as the game's own are. The test
reads the criterion's conditions and can check a vanilla item predicate
in them. It reaches the acting player inside their hook, and any player,
in their own level (for_player), where no player is swapped in;
minecraft: triggers are refused.

Fixes #5481.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 17:25:54 -04:00
levzzzandClaude Opus 5.5 6f0839a15e Launcher: the macOS and Windows tests build on gpui-fast
Keystroke::parse takes a 'static str; the app comes from gpui_platform::application(), and
AsyncApp::update returns nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:16:39 +05:00
PzmuV1517 b1f3afcd9a Native mods: a mod block decides if it can stay where it is
native_can_survive answers BlockState.canSurvive before the block does: as it is placed (BlockItem.canPlace)
and from the shape update of a block whose template checks it, which breaks it with its drops.
native_may_place_on answers VegetationBlock.mayPlaceOn, the blocks a mod plant can stand on, which its
template's canSurvive asks. Both through LevelAccess, on authoritative servers, as native_update_shape is.
2026-10-31 23:14:07 +02:00
rdbtCVSandClaude Opus 5.5 3332137ad2 Merge main into #5412
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:12:15 +00:00
rdbtCVSandClaude Opus 5.5 5a2f191b22 fix(skins): a head's texture patch only names a file inside the packs
The profile's texture id comes from a server's NBT; Identifier's characters and
no '.', '..' or empty segment keep it from reading a PNG outside the resource root.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:10:46 +00:00
rdbtCVSandClaude Opus 5.5 29f5ee49ee Merge main into #5411
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:10:22 +00:00
rdbtCVSandClaude Opus 5.5 b42b510cfb Merge main into #4250
The ten lang tables conflicted where both sides added strings: main's table as the
base, plus this branch's account strings, without the "Microsoft account · signed in"
this branch removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:08:52 +00:00
rdbtCVSandClaude Opus 5.5 68b5f9e525 native_play_local_sound: check the event with sound_id, as native_play_sound does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:03:25 +00:00
rdbtCVSandClaude Opus 5.5 53cfb35095 Merge main into #5441
Both sides added an Extra field and its NBT round trip (rabbit MoreCarrotTicks here,
ocelot Trusting on main); kept both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 21:00:23 +00:00
rdbtCVSandClaude Opus 5.5 5c3017a630 fix(mobs): a fox egg's trust goes to the baby made on a fox, not to an egg used on a block
Fox.onOffspringSpawnedFromEgg's only caller is Mob.checkAndHandleImportantInteractions
(a fox egg used on an adult fox), so the trust moves from use_spawn_egg to
spawn_egg_offspring. The port map's Fox.clearTrusted and readAdditionalSaveData rows
now say what is ported.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:59:05 +00:00
rdbtCVSandClaude Opus 5.5 27fb1b40c6 Merge main into #5410
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:57:32 +00:00
levzzz 2034a32866 Merge remote-tracking branch 'origin/launcher/gpui-fast' into launcher/gpui-fast 2026-11-01 01:55:57 +05:00
levzzzandClaude Opus 5.5 124904afa4 Docs: the launcher reference lists completions
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 01:54:47 +05:00
Claude f2d940e4c8 Biome modifiers: test one biome id and a list of biomes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NLEqWLKW2LHgtLer35pT8m
2026-10-31 20:54:36 +00:00
levzzz 26a4cdc5c5 Merge remote-tracking branch 'hr/main' into launcher/gpui-fast 2026-11-01 01:53:36 +05:00
rdbtCVSandClaude Opus 5.5 a844d55815 Merge main into #5409
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:52:06 +00:00
Guilherme Jansen 4f1ffce47d fix(launcher): preserve running macOS app bundles during updates 2026-10-31 17:49:40 -03:00
rdbtCVSandClaude Opus 5.5 e05059a562 Merge main into #5418
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:49:11 +00:00
rdbtCVSandClaude Opus 5.5 88590156f0 Merge main into #5461
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 20:46:21 +00:00
rdbtCVS f97c2cd499 Merge main into #5413 2026-10-31 20:45:41 +00:00
Claude 1fedaa1c5a Content mods: place mod features in biomes (biome modifiers)
A content mod's placed features now generate in vanilla's biomes through
NeoForge's biome modifiers (data/<ns>/neoforge/biome_modifier/, and
forge/biome_modifier/ for older mods): add_features, remove_features and
none. When the content root builds they rewrite the biomes' feature
lists, an added feature at the end of its step as NeoForge adds it; the
generator's FeatureSorter then orders them as vanilla does. Biomes no
modifier names keep their text, and a root with no modifiers keeps
vanilla's bundle. An unknown biome, tag, feature, step or type fails the
build with the mod and file named.

Part of #4953, closes #5431.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NLEqWLKW2LHgtLer35pT8m
2026-10-31 20:45:38 +00:00
Claude 8a6eaac999 native_summon: return experience orbs' ids, and place passengers without a Pos
Follow-up to #5408. A summoned orb spawned but native_summon returned None,
since no command Target lookup covers orbs: return the stack's first
spawned id. Passengers without Pos were not placed and got lost: share
/summon's recursive place helper (place_summoned), which also supplies
the Fire and Rotation defaults.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QT61YPiPzVNysmVHsH
2026-10-31 20:39:58 +00:00
huiandZCode AI agent (GLM-5.3) 42e5a2fb8e Load conditions: an unknown kind nested in the tree leaves the file out
known() now checks the whole subtree, not only the immediate type:
not(not(unknown)) and or(true, not(unknown)) both failed to exclude
the file, because the outer combinator was a known kind and only the
direct child was checked. Both now fail closed, with tests.

Review: Aria's agent swarm (Dyna) in the pull request.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 21:39:32 +01:00
huiandZCode AI agent (GLM-5.3) bb0e421674 Recipes parse NeoForge's standard ingredient shapes
An ingredient that is one of NeoForge's standard object shapes now
resolves to the flat item list it matches, where the string parser
dropped the recipe file whole: neoforge:compound and
neoforge:intersection keep the items every sub-ingredient names,
neoforge:difference the base's without the subtracted's, and
neoforge:fluid a fluid's item form, its full bucket (a water or lava
tag names the same still fluid). A kind outside these still fails the
file - a mod's own ingredient types come with their own hook, not the
loader.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 21:39:32 +01:00
TeiwazikandClaude Opus 5.5 d36550e82c Shader API: translucent terrain in the shadow pass, and its colour
Translucent terrain is drawn over a copy of the solid shadow map, so a pack can tell a point under
water or stained glass from one in shadow: shadow_map_translucent holds that depth, and shadow_color
the texture colour times the tint of the nearest such surface, with alpha 1 on a fluid.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 21:32:52 +01:00
Aaron Wang 7bb5aec013 Measure a far teleport from where the entity is headed
setValuesFromPositionPacket measures tooBigToInterpolate from
PositionMoveRotation.of(entity), which is getClientPositionAndRotation:
the interpolation target while one runs. handleEntityPositionSync
measures from the entity's position. MovePacket keeps the two apart.

The mob and armor stand branches of move_entity now share stepped_move.
A mob put somewhere at once also resets its previous yaw and pitch
(snapTo's setOldPosAndRot), so it doesn't spin for a frame, and every
spawn starts with a fresh interpolation handler, a stand's included.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 16:25:51 -04:00
huiandZCode AI agent (GLM-5.3) 3379d00ae0 Load conditions: mod IDs, not namespaces; unknown fails closed; the overlay replaces before the gate
mod_loaded now asks which mods are loaded (the mod-content root's new
horizon-mod-ids.json marker), not which namespaces they own — a mod that
also owns a namespace doesn't make that namespace another mod. An
unknown condition kind fails the file through not and or as well, not
just on its own: `not` of an unknown kind is not a pass, and an `or`
over one doesn't short-circuit past it. And the stations, the recipe
book and brewing apply the world's packs first and gate the merged
file, as the crafting loader already did: a false-conditioned pack file
leaves the lower recipe gone too, not still active underneath.

Review: Aria's agent swarm (Dyna) in the pull request.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 21:22:30 +01:00
Aaron Wang 34ff18cdad Armor stands turn between ticks and snap on far moves, as living entities
An armor stand is a LivingEntity, so a vanilla client moves it with the
SteppedInterpolationHandler over its update interval (3 ticks), position
and yaw together, and ArmorStand.tickHeadTurn has the body follow yRot.
Horizon eased stands with the generic three-step lerp and set the yaw at
once from each packet. Stands now share the mobs' stepped handler:
ArmorStand::interpolate takes its step, keeps the yaw continuous and
saves yBodyRotO, and the renderer and the F3+B hitbox turn the body by
Mth.rotLerp(partialTick, yBodyRotO, yBodyRot).

A move of more than 64 blocks now snaps (tooBigToInterpolate), with
nothing left to blend from (Entity.snapTo). TeleportEntity snaps too, as
setValuesFromPositionPacket does; before, only EntityPositionSync did,
so a far teleported mob also slid there over three ticks.

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 16:06:36 -04:00
TeiwazikandClaude Opus 5.5 acc8c21f4c Shader API: [shadow], a shadow map of the solid terrain
A pack asks for a shadow map in its manifest and the renderer draws the solid terrain the light
sees into it each frame, from the shadow camera's lists shaders/ packs already use. The material
and post stages read it as shadow_map with shadow_sampler and the prelude's shadow_position, and
the frame gains light_direction, has_shadow, shadow_view_proj, shadow_distance, shadow_distortion,
shadow_resolution and shadow_center_texel. A pack without [shadow] gets a 1x1 map that lights
everything. Translucent terrain and entities don't cast yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-10-31 21:03:05 +01:00
Federico TaglianiandClaude Sonnet 5.5 e2b71fa692 The Debug world preset: a grid of every block state
DebugLevelSource: barriers at y 60 and the block states in registry order on
a grid at y 70, one every other block. A Debug world's blocks never change
(Level.setBlock fails; setblock, fill and clone report it), nothing ticks
(scheduled, random, natural spawning), the login packet says isDebug, and
level-type minecraft:debug_all_block_states creates it.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 21:02:03 +01:00
Aaron Wang ccc34ef964 Draw armor stands between ticks, so a falling one moves smoothly
EntityRenderer draws an entity at Mth.lerp(partialTick, xo, x), but armor
stands were drawn at their tick position, so a falling stand jumped once
a tick (20 times a second) while boats, TNT and mobs slid. ArmorStand now
keeps its previous position: ArmorStand::interpolate saves it before each
interpolation step on a server's client, a stand riding a mob or the
player takes it from its vehicle's previous seat (as the mob riders do,
so it stays right while the level is frozen), and the in-frame tick
saves it too. The body, its light, the culling box, the name tag, the
flames and the F3+B hitbox all use the blended position.

Fixes #4416

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 15:39:13 -04:00
reservedbytes 742e4dc206 fix(mods): a canceled hit forgets its attackers on every damage path 2026-10-31 18:37:46 -01:00
Claude 6b5780b686 Merge remote-tracking branch 'upstream/main' into feat/native-camera-shake 2026-10-31 19:33:10 +00:00
Claude f58fe06685 Native hooks: finite keeps its own doc comment 2026-10-31 19:33:10 +00:00
Federico TaglianiandClaude Sonnet 5.5 b1e4937c9a A rabbit eats ripe carrots
Rabbit.RaidGardenGoal: a MoveToBlockGoal (0.7, 16) to carrots at their maximum
age on #supports_crops ground, which takes one age off them (or the crop when
it had none) and leaves the rabbit full for 40 ticks (moreCarrotTicks, saved
as MoreCarrotTicks, running down by 0 to 2 a tick).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 20:31:50 +01:00
sirrayiandDeepSeek Harness 7170948484 test(probe): the mob probe feeds without a love cause
`feed_animal` takes the player who feeds an animal into love since this
branch; the probe passes none, so `--examples` builds again.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:24:24 +00:00
MartipopsandClaude Opus 5.5 242065a84d Merge upstream main into feature/launcher-auth-status
Main's #5397 translated the same 17 strings into ja, pt-br and tr that dd722ee7a did, with
other wording. Git merges both without a conflict, which left each of those tables with 17
duplicate keys. Main's wording is kept and this branch's dropped, so the tables differ from
main only by this branch's 26 account strings and the key it removed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82d7UpxGuCncnLpJsq
2026-10-31 15:21:49 -04:00
rdbtCVSandClaude Opus 5.5 a209fab13e Native hooks: finite keeps its own doc comment
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:21:31 +00:00
Claude 1ec53c7a3f Merge remote-tracking branch 'upstream/main' into feat/native-falling-blocks
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-31 19:21:14 +00:00
sirrayiandDeepSeek Harness e8a93aa87e fix(mobs): a fox's baby trusts the players who bred it
`Fox.FoxBreedGoal.breed` hands the breeding to the baby as trust, which needs
the player who fed each parent into love: the animals kept the love counter
but not its cause. `Animal.LoveCause` is now kept (`Extra.love_cause`, fed
from the player who hands over the food, saved and read), and
`fox::trust_breeders` gives the baby both players, once each.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:19:52 +00:00
sirrayiandDeepSeek Harness c5f11d1eb6 fix(mobs): foxes trust the players their list names
`Fox.getTrustedEntities` and `Fox.addTrustedEntity` were modelled as "no
trusted players yet": the save wrote an empty `Trusted` list, nothing read it
back, and the avoid-player goal had no trust test, so a fox fled the player
who spawned it and nobody could become trusted (two rows of #3512).

- `Extra.trusted` holds the two `DATA_TRUSTED_ID` slots; `fox::trusted_entities`,
  `add_trusted_entity` (first free slot, then replacing the second),
  `clear_trusted` and `trusts` are the entity's own methods by name.
- The save writes the real list (`Fox.TRUSTED_LIST_CODEC`: int arrays) and
  `mob_from_nbt` clears and rebuilds it, as `Fox.readAdditionalSaveData` does.
- `AVOID_PLAYERS` no longer flees a player the fox trusts, which is what the
  row meant by a bred fox still running from you; `AvoidEntity::player_when`
  takes the avoider so the predicate can ask. A fox spawned from a spawn egg
  trusts the player who used it (`Fox.onOffspringSpawnedFromEgg`).

Tested without prepared game data:
`mobs::fox::trust_tests::a_fox_trusts_two_players_and_the_third_replaces_the_second`.

Still missing from the row: the breeding hand-off (needs a saved love cause,
which the mobs do not keep) and `DefendTrustedTargetGoal`; both are in the
note of the map rows.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-10-31 19:18:19 +00:00
huiandZCode AI agent (GLM-5.3) 617466720a Recipes answer NeoForge's load conditions
A data file with neoforge:conditions loads only when every condition
holds, and is silently absent otherwise, as NeoForge's ICondition leaves
it out of the manager. The gate covers the recipe loaders - crafting's
three kinds and the mods' own types, the stations, brewing, and the
recipe book - and the packs' overlays over them; mod_loaded asks which
mods' data is in the root (minecraft, always, and the content mods
that are on), and neoforge:true, not, and and or combine. A condition
the game does not know leaves the file out: a future shape fails
closed. Ported modpacks already carry such recipes verbatim (the carry
step keeps them), so cross-mod recipes stop loading blind.

Co-Authored-By: ZCode AI agent (GLM-5.3)
2026-10-31 20:16:20 +01:00
k3yhandClaude Opus 5.5 ad85e99600 Smoke where a bucket's water evaporates in the Nether (#5288)
BucketItem.emptyContents ends an evaporation with ServerLevel.sendParticles
(LARGE_SMOKE, the block's corner, 8, spread 1, speed 0, ALTERNATIVE).
Horizon played the hiss but sent no smoke for a player's bucket, and the
dispenser drew its own smoke one block too wide (nextDouble * 2 - 1 around
the corner instead of nextDouble from it) and sent none to other players.

evaporation_smoke does both: the window's puffs and the packet, now with
its randomization type (QueuedParticle.randomization). A client receiving
LevelParticles now honors ALTERNATIVE and ALTERNATIVE_WITH_SPEED, as
ClientPacketListener.handleParticleEvent does: positions spread over
[0, dist), speeds kept or scaled by nextDouble.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 14:10:43 -05:00
Claude d356420e76 Merge main into feat/native-shader-params
Resolves the conflicts with the water reflection fields: Frame.mods now
follows reflection_resolution at offset 528.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QRonhDhVY6koHuncJx
2026-10-31 19:10:34 +00:00
levzzz 120a5f41e3 Merge remote-tracking branch 'hr/main' into launcher/gpui-fast 2026-11-01 00:04:08 +05:00
levzzz fefa788a34 Merge remote-tracking branch 'hr/main' into launcher/gpui-fast
# Conflicts:
#	launcher/Cargo.lock
2026-10-31 23:58:48 +05:00
Federico TaglianiandClaude Sonnet 5.5 cb12c27a6e MinecartSpawner: a spawner minecart ticks its BaseSpawner where it stands
A new cart kind (spawner_minecart, made by /summon: there is no item) that
shows a spawner block, drops and is picked as a plain minecart, and saves and
loads the BaseSpawner fields. Its serverTick is the spawner block's, with
broadcastEvent as an entity event on the cart.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-31 19:55:19 +01:00
levzzzandClaude Opus 5.5 e36ed13712 Launcher: GPUI Fast instead of GPUI
longbridge/gpui-fast: Zed's GPUI with retained views and cached scroll layers.
The API changes are mechanical: focus and blur take the App, the application
comes from gpui-fast-platform (wgpu on Linux), menus say whether they're
disabled, shadows whether they're inset. oo7 follows to 0.6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 23:55:12 +05:00
RansomandClaude Opus 5.5 46598a2bc5 Native mods: native_play_local_sound plays a client mod's sound, later
A client helper that plays a sound event, vanilla's or the mod's own
from its assets' sounds.json, for this player only and up to a minute
later (SoundEngine.playDelayed), so a mod can time a blast's sound to
its shock wave. Audio's distance delay now goes through the same
play_after.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:50:02 +00:00
RansomandClaude Opus 5.5 de92159b98 Native mods: native_spawn_falling_block throws a block from a server mod
A server mod's helper that throws a falling block at a velocity: it
flies, lands and places itself as sand does, or drops as an item, and
can hurt what it lands on as an anvil does. For explosion debris and
collapsing builds. Refused on a client, for another dimension, air, an
unloaded chunk, values that aren't finite, or past 1024 falling blocks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:50:02 +00:00
RansomandClaude Opus 5.5 39755f90ff Native mods and shader packs: native_shader_params hands a mod's pack eight values
A client hook asked once a frame for eight vec4s that every stage of a
Horizon shader pack reads as frame.mods, so a native mod can tell the
pack it bundles where and when something happened (an explosion's
centre and age for a cloud ray marched in a post pass). Values that
aren't finite read as 0; all are 0 when no mod sets them. The
reference is regenerated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:50:02 +00:00
RansomandClaude Opus 5.5 4983adec8b Native mods: native_camera_shake lets a client mod shake the camera
A client hook asked each frame for turns in degrees and an offset in
blocks, which add up across mods. Horizon scales the sum by Distortion
Effects (0% turns it off), bounds it to 10 degrees and half a block on
each axis, and multiplies it into the view after the hurt tilt and view
bobbing. It moves what the player sees, not where they aim.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01XnU6QSxLfzUbudHhcvZtgy
2026-10-31 18:33:03 +00:00
MartipopsandClaude Opus 5.5 dd722ee7a7 Launcher: main's new strings in Japanese, Brazilian Portuguese and Turkish; tr gets the accounts'
Main's 31c1212bf added 17 strings to seven tables but not to ja, pt-br or the new tr, which
fails the every-language test on main. They are translated here as in the PR to main that
fixes it, so the lines are the same. tr.json also gets this branch's 26 account strings and
drops "Microsoft account · signed in", as ja and pt-br did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82d7UpxGuCncnLpJsq
2026-10-31 14:23:00 -04:00
Claude 71955880b7 Draw a head profile's texture override instead of its skin
A ResolvableProfile can carry a `texture` id (PlayerSkin.Patch body, a
ClientAsset.ResourceTexture). Vanilla draws a player head with that
resource-pack texture instead of the downloaded or default skin; Horizon
ignored it and drew the profile's default skin. Placed heads and head
items now read the patch, loading non-vanilla namespaces' textures from
the resource packs into a skin slot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01P1ZTfB3AfwKgGnv7UFKYVD
2026-10-31 18:22:56 +00:00
Ludovic Marie ee20fbd1a9 Merge branch 't3794' into t3801 2026-10-31 19:17:11 +01:00
Ludovic Marie 696c5709e7 Merge branch 't3777' into t3794 2026-10-31 19:17:06 +01:00
Martipops 80bd8b2aa2 Merge upstream main into feature/launcher-auth-status
# Conflicts:
#	launcher/lang/de.json
#	launcher/lang/es.json
#	launcher/lang/fr.json
#	launcher/lang/pl.json
#	launcher/lang/ru.json
#	launcher/lang/uk.json
#	launcher/lang/zh.json
2026-10-31 14:17:01 -04:00
Ludovic Marie f0761a306d Merge remote-tracking branch 'origin/main' into t3777
# Conflicts:
#	crates/horizon-client/src/game/integrated.rs
2026-10-31 19:13:05 +01:00
MartipopsandClaude Opus 5.5 695574db09 Launcher accounts: a refresh saves to the accounts file it is given; pt-BR and Japanese strings
refresh_account and renew_for_game_with take the accounts.json a renewed profile is saved to.
The commands pass the launcher's own (Accounts::path()); the tests pass one that is never
there, so they no longer read or write the developer's launcher data. A new test checks that
a refresh saves the services' newer profile and leaves an unchanged one unwritten.

Main added pt-br.json and ja.json after this branch's strings: both get its 26 account
strings, and lose "Microsoft account · signed in", which the code no longer asks for (the
other tables dropped it already).

With main merged: profile_heads' test uses Accent::VIOLET (main made Accent a hue), and
docs/launcher-commands.md is regenerated for main's `completions` command, which main had
not written into it (the reference test failed on main too).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01JLHp82d7UpxGuCncnLpJsq
2026-10-31 14:08:21 -04:00
Martipops 8bbf67da6f Merge upstream main into feature/launcher-auth-status 2026-10-31 13:55:01 -04:00
ErikKalikasandClaude Opus 5.5 91ed167997 Native mods: keep the block entity draw helpers for mods (dead_code)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:50:44 +02:00
ErikKalikas a8a17b12d0 Merge remote-tracking branch 'upstream/main' into mod-block-entity-draw 2026-10-31 19:42:38 +02:00
ErikKalikasandClaude Opus 5.5 4d9dbdb565 Native mods: draw mod block entities on the client (BlockEntityRenderer.submit)
native_block_entity_submit runs once a frame for each mod block entity in
view, as vanilla's block entity renderers do (64 blocks, frustum, chunk
fade-in), with a pose stack and helpers that add to the entity batch:
quads, model-element boxes baked as FaceBakery does, and block models
(submitBlockModel). Content mods' textures/entity join the atlas.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 19:42:38 +02:00
Ludovic Marie abb630c564 Merge main into #3825
# Conflicts:
#	crates/horizon-client/src/game/integrated.rs
2026-10-31 18:41:25 +01:00
Ludovic MarieandClaude Opus 5.5 802a3fa668 Merge #3794's branch; a model match only where ids change and one player waits with it; P1 with controllers off takes no pad
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:37:57 +01:00
Ludovic MarieandClaude Opus 5.5 95122e4ff8 Merge #3777's branch; a pressed pad skips players with controllers off; say splitscreen has no rumble yet
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:29:40 +01:00
Ludovic MarieandClaude Opus 5.5 da45330894 Merge main; splitscreen seats follow the owner's Allow Cheats, a failed join leaves P1 playing
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:26:05 +01:00
Ludovic MarieandClaude Opus 5.5 c7b8559015 Launcher accounts: status workers wait for the lock; a refresh saves the renewed profile
rdbtCVS's two review points on #4250:
- The launcher's own status workers (off the UI thread, showing "Waiting for
  account access…") take the lock without the 3 s bound, so a worker queued
  behind another account's renewal no longer shows a false "Could not check
  sign-in". account list and MCP keep the bounded wait.
- refresh_account saves a renewed profile (new name or skin) to accounts.json
  as launch() does, and the UI reloads accounts and profile heads when a
  status arrives and the saved list changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:24:16 +01:00
Ludovic MarieandClaude Opus 5.5 d876fa1212 Merge main into #3825; splitscreen windows: the review's three fixes
- audio.rs: main's shared-output fields (the device tracker's, f80c0360d).
- A guest in Window 1 leaves its F11 to P1: the game's window follows P1's option.
- disable() shows Window 1 again and says so (window1_shown), so a later
  split into Window 2 hides it again.
- Clicks, key presses and the wheel from a window the focused player isn't
  in no longer reach that player; releases still pass, so no key sticks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 18:11:32 +01:00
Ludovic Marie d291364f56 Merge main into #4250 2026-10-31 18:08:06 +01:00
rdbtCVSandClaude Opus 5.5 9ec5a13f6b Merge origin/main into launcher auth status
launcher/lang/*.json: keep both sides' new strings (main's text where a key is in both).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:15:13 +00:00
rdbtCVSandClaude Opus 5.5 35f41e670c Merge origin/main into split/09-windows
Kept PadRouter's state_of with main's stick_axes, both Flags fields, and filled
main's audio device-tracking fields in Audio::share.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:09:57 +00:00
rdbtCVSandClaude Opus 5.5 d309aed786 Merge origin/main into split/07-controllers
Conflicts: integrated.rs keeps both Flags fields (split_players, load);
pad/hardware.rs keeps the PadRouter split and moves main's stick_axes
mapping into state_of. A shared Audio gets main's device-tracking fields
and leaves reopening the output to the game's own Audio.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:06:04 +00:00
rdbtCVSandClaude Opus 5.5 59e106cf25 Merge origin/main into split/06-two-players
main's audio device tracking (#default_seen, devices_seen, device_ticks) added fields
the shared Audio built by share() now fills.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-31 16:05:26 +00:00
martipops 9e3aaa3400 Merge branch 'split/08-join-with-controller' into split/09-windows
# Conflicts:
#	crates/horizon-client/src/game/options.rs
2026-10-30 19:04:33 -04:00
martipops 1e13f55868 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-30 19:03:22 -04:00
martipops 3779a7c5d0 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-30 19:03:22 -04:00
martipopsandClaude Opus 5.5 c6661cfe72 Merge upstream main into split/06-two-players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 19:03:22 -04:00
martipopsandClaude Opus 5.5 626de8923b Merge upstream main into launcher auth status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:59:08 -04:00
martipops cd48c77ff2 Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-30 18:37:16 -04:00
martipops c7dbf8f290 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-30 18:37:16 -04:00
martipops 858e8b3ba8 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-30 18:37:16 -04:00
martipops a2fcdeef3b Merge branch 'split/02-audio' into split/06-two-players 2026-10-30 18:37:16 -04:00
martipopsandClaude Opus 5.5 a12892258f Merge upstream main into split/06-two-players
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:36:45 -04:00
martipopsandClaude Opus 5.5 dbb23c1eca Splitscreen windows: don't retry a window that failed to open, pace on a shown one
- A window that failed to open was tried again every frame, each time
  re-laying out every player. Its players now play in Window 1 and the
  window is forgotten.
- Vsync and the frame pacing's refresh rate follow the first shown
  window, not a hidden Window 1; Window 1's visibility is kept in a
  field, so Wayland (which can't report it) isn't told every frame.
- Only free windows remember where they were: a monitor's rect made a
  later free window with its number open monitor-sized.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:19:50 -04:00
martipops fd4b82f92d Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-30 18:18:16 -04:00
martipopsandClaude Opus 5.5 2d9d1f2264 Splitscreen: keep a lone P1's controller output between frames
route_pads released P1's pad hardware every frame, so with P1 alone the
reader lent for P1's turn lost its rumble and DualSense link a frame
after they started. It now releases only when nothing is lent.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:17:39 -04:00
martipopsandClaude Opus 5.5 2317bebacf Merge split/07-controllers into split/08-join-with-controller
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:17:39 -04:00
martipopsandClaude Opus 5.5 aad4fd03f4 Splitscreen: hold back a trigger press that gave a controller its player
A controller first pressed with a trigger got a player, and the held
trigger then attacked or used: only buttons were held back.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:15:30 -04:00
martipops a7802c004c Merge branch 'split/06-two-players' into split/07-controllers 2026-10-30 18:14:30 -04:00
martipops 3d94aa69d1 Merge branch 'split/02-audio' into split/06-two-players 2026-10-30 18:14:20 -04:00
martipopsandClaude Opus 5.5 7215229f71 Splitscreen: let a seat's name in only through memory
A LAN player who logged in under a registered seat's name (P2) passed
the integrated server's gate and the full-server check and held the
owner's permissions given to that seat. The gate now admits a seat's
name only from the memory connection (MEMORY_ADDRESS, port 0).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 18:14:20 -04:00
martipopsandClaude Opus 5.5 9945e72c81 Merge upstream main into launcher auth status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:39:33 -04:00
martipopsandClaude Opus 5.5 1af012a921 Launcher auth: let a status read give up behind a long operation
A refresh or a launch check holds the account lock across network calls.
Status reads (account list, the account page) now wait at most 3 s for
it, then say another operation is running, instead of waiting out a
network timeout. Writers still wait.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:38:37 -04:00
martipopsandClaude Opus 5.5 134b6f1a42 Merge upstream main into launcher auth status
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 17:02:13 -04:00
martipopsandClaude Opus 5.5 af5ac52ace Launcher auth: answer review nits
Translate the account status messages, print a failed `account refresh`
once, fetch a profile head again a minute after its skin download failed,
and check every account's sign-in at most once a minute on page switches
(an account added meanwhile at once), so switching pages no longer reads
the keychain each time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 16:57:48 -04:00
martipopsandClaude Opus 5.5 dc6d372db8 Let a running game renew its session through the launcher CLI
`account session WHO` renews an account and hands the running game a new
read-once session file, or the public status when it needs a browser
sign-in or a retry. `account sign-in --for WHO` runs the UUID-bound
re-sign-in from the command line. The launcher passes its own path to the
game as HORIZON_LAUNCHER_EXE. `account session` is kept out of MCP: its
file holds a token.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 04:01:40 -04:00
0dab1469ac Show profile heads, auth progress, and targeted re-sign-in in the launcher
Fetch public skin heads on workers with a bounded, URL-keyed cache and UUID default fallback, report each account operation's actual phase, and bind Sign in again to the account's UUID so a different Microsoft account is rejected before any credentials are stored.

Co-Authored-By: Codex <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-30 03:34:27 -04:00
martipopsandCodex 3fab32c08a Give launcher account states semantic colors and icons
Show ready, refreshable, and sign-in-required states using the theme's success, warning, and danger colors, retaining icons and full labels.

Co-Authored-By: Codex <[email protected]>
2026-10-30 02:58:31 -04:00
martipopsandCodex 501c9abbfe Handle completed sign-in only once while polling the launcher
Compare sign-in states directly instead of comparing a state to an Option's debug output, which continually restarted every account's status checks.

Co-Authored-By: Codex <[email protected]>
2026-10-30 02:49:01 -04:00
martipopsandCodex f0a06742ea Show account status and refresh actions in the launcher
Inspect and renew due sessions on workers, expose Refresh and Sign in again actions, and share the operation through CLI and MCP for future launcher-to-game integration.

Co-Authored-By: Codex <[email protected]>
2026-10-30 02:32:45 -04:00
martipopsandCodex 6e1ac28831 Add launcher-owned account status and forced session renewal
Keep authentication state credential-free, remember rejected refresh credentials, preserve rotated credentials after downstream failures, and serialize refresh with sign-in, sign-out and launch.

Co-Authored-By: Codex <[email protected]>
2026-10-30 02:32:45 -04:00
martipopsandClaude Opus 5.5 f96acabfdc Splitscreen: the alone check in device_event is no surfaces, as in window_event
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:46:46 -04:00
martipops 1a7e3da3c4 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-29 23:43:33 -04:00
martipops 5596fd89f4 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 23:43:33 -04:00
martipops f6bd4918d7 Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-29 23:43:33 -04:00
martipops 38bda0fc71 Merge branch 'split/02-audio' into split/06-two-players 2026-10-29 23:42:35 -04:00
martipopsandClaude Opus 5.5 126d270448 Splitscreen: alone, device events go straight to P1 as window events do
Review: device_event turned P1's GUI scale on for every mouse motion even with no other
player; it now hands the event to P1 directly while there is no compositor.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 23:42:35 -04:00
martipops 92ba57a02e Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-29 23:07:35 -04:00
martipops c350caa74f Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 23:07:35 -04:00
martipops 91290d7d0a Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-29 23:07:35 -04:00
martipops 0286833cb7 Merge remote-tracking branch 'upstream/main' into split/06-two-players
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-29 23:07:34 -04:00
martipops 8a8ee31ea2 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-29 21:39:41 -04:00
martipops c9e0a240a4 Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-29 21:39:41 -04:00
martipops 69bc9623a1 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 21:39:40 -04:00
martipopsandClaude Opus 5.5 dc772c4601 Merge upstream main into part 6
Upstream moved the title actions into title_action (the split guest's Quit guard goes there),
added the server's online mode beside our memory-seat limit bypass, the audio output's device
name (a shared output carries it), and dropped code mods' key hooks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 21:38:16 -04:00
martipops 950e0aeac2 Merge branch 'split/07-controllers' into split/08-join-with-controller 2026-10-29 13:39:25 -04:00
martipops ec514fd94b Merge branch 'split/08-join-with-controller' into split/09-windows 2026-10-29 13:39:25 -04:00
martipops 664c82127f Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 13:39:25 -04:00
martipops 90f2db9114 Merge remote-tracking branch 'upstream/main' into split/06-two-players
# Conflicts:
#	crates/horizon-client-render/src/gfx.rs
2026-10-29 13:39:15 -04:00
martipopsandClaude Opus 5.5 685548932b Splitscreen: players in windows of their own, or filling a monitor
Part 9 of the splitscreen series (#3541), on #3801. Each player plays in a window: Window 1
(the game's), Window 2, 3... opened for the players put there, or a borderless window filling
a monitor (over the menu bar on macOS, the system's borderless fullscreen elsewhere). Players
in one window share it. Closing a player's own window drops its players; the game's window
hides while nobody plays in it; titles say who plays where; a window reopens where it was.
Another player's F11 toggles its own window. HORIZON_SPLIT_WINDOWS (testing, until the Setup
screen) says each seat's window: `1,1,2`, or `m2` for monitor 2.

Live: tools/splitscreen_e2e.py adds `windows`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 13:01:59 -04:00
martipopsandClaude Opus 5.5 2edc3b57a4 Splitscreen: join with a controller; a lost controller's player waits
Part 8 of the splitscreen series (#3541), on #3794. While splitscreen watches the controllers
(HORIZON_SPLIT for now; the switch comes later), one reader serves every player, alone too:
the first controller used is P1's (its rumble and DualSense output lent to P1's turn). In P1's
world another controller pressing a button is offered to join on every view: A joins (as
P<seat>), Y drives P1 when P1 has none, B says no, 10 s and it goes. A controller that
disconnects leaves its player dimmed and asked: the same controller back takes it again, A on
another takes over, B lets it leave. Each view gets a short badge (who, and why: "Keyboard
and mouse" when it takes them).

Live: tools/splitscreen_e2e.py adds `alone` (watching, nobody joins).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:32:57 -04:00
martipops bd6ead73c3 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 12:24:01 -04:00
martipopsandClaude Opus 5.5 557a675d5d splitscreen_e2e: replies through a file, so Windows doesn't hang on launch
On Windows the game horizon-ctl launch starts inherits horizon-ctl's stdout pipe, which then
never closes while the game runs: subprocess.run waited forever. No pkill there either.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 12:22:32 -04:00
martipops 408ca59a65 Merge branch 'split/06-two-players' into split/07-controllers 2026-10-29 11:56:09 -04:00
martipopsandClaude Opus 5.5 c911e9060d splitscreen_e2e: find horizon-ctl.exe on Windows
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:56:01 -04:00
martipopsandClaude Opus 5.5 9b19ab5b04 Splitscreen: each controller drives its own player
Part 7 of the splitscreen series (#3541), on #3777. While several play, one controller reader
(PadRouter) serves every player's game: a controller nobody has goes, when pressed, to the
first player without one (P1 first); its first press is not passed on; a controller that
disconnects leaves its player without one. Each player's glyphs follow its controller.
Alone, P1 reads the controllers itself, exactly as without splitscreen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:50:52 -04:00
martipopsandClaude Opus 5.5 70a9fd6f6a Splitscreen: a second player in the same window (HORIZON_SPLIT=N)
Part 6 of the splitscreen series (#3541), on parts 1 (compositor), 2 (audio), 4 (pointer
ownership) and 5 (layout). With HORIZON_SPLIT=N (testing only, no switch yet), N-1 more
players join P1's world through memory as P2, P3..., each its own Game in this process,
rendered offscreen and composited into P1's window. Keyboard and mouse go to the view
clicked last. A player who quits or disconnects drops out; alone again, P1 draws on its
window as before. Guests never open the controllers (pad.feed); routing comes next.

Live: tools/splitscreen_e2e.py (join, four).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-29 11:34:36 -04:00
martipops 563d85f957 Merge remote-tracking branch 'origin/split/05-viewports' into split/06-two-players 2026-10-29 11:25:55 -04:00
martipops ce0983fecc Merge remote-tracking branch 'origin/split/04-pointer-ownership' into split/06-two-players 2026-10-29 11:25:55 -04:00
martipops b3e5cb2fa6 Merge remote-tracking branch 'origin/split/02-audio' into split/06-two-players 2026-10-29 11:25:55 -04:00
284 changed files with 21503 additions and 4184 deletions

No files matched your search

Generated
+11
View File
@@ -1334,6 +1334,9 @@ dependencies = [
"log",
"miniz_oxide 0.8.9",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"png",
"pollster",
@@ -1506,6 +1509,13 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"regex",
]
[[package]]
name = "horizon-port-pack"
version = "0.1.0"
@@ -1648,6 +1658,7 @@ version = "0.1.0"
dependencies = [
"libc",
"libmimalloc-sys",
"log",
"mimalloc",
"windows-sys 0.61.2",
]
+1
View File
@@ -30,6 +30,7 @@ members = [
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-optifine",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"xtask",
+12 -2
View File
@@ -1226,6 +1226,17 @@ impl Audio {
self.play(event, Some(at), volume, pitch);
return;
}
self.play_after(
event,
at,
volume,
pitch,
(distance_sqr.sqrt() / 40.0 * 20.0) as i32,
);
}
/// `play` at a world position, starting `ticks` sound-engine ticks (20 a
/// second) from now: `SoundEngine.playDelayed`.
pub fn play_after(&mut self, event: &str, at: [f64; 3], volume: f32, pitch: f32, ticks: i32) {
let seed = self.random.next_long();
if let Some(capture) = self.capture.as_mut() {
capture.push((event.to_owned(), at, volume, pitch));
@@ -1239,8 +1250,7 @@ impl Audio {
let instance =
SoundInstance::at(&event, source, volume, pitch, LegacyRandom::new(seed), at);
let id = self.engine.add(instance, false);
self.engine
.play_delayed(id, (distance_sqr.sqrt() / 40.0 * 20.0) as i32);
self.engine.play_delayed(id, ticks);
}
/// `play` for this client only: a sound a packet's handler plays
/// (`playLocalSound`), which the guests play from that packet themselves.
@@ -885,25 +885,26 @@ impl BlockEntityRenderer {
let mut renderer = Self::new(models, registry);
renderer.visibility = Some(Mutex::new(visibility));
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
let items: Value = serde_json::from_slice(&bytes)?;
for (item, components) in items.as_object().into_iter().flatten() {
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str()
else {
continue;
};
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
let Ok(def) = std::fs::read(file) else {
continue;
};
let def: Value = serde_json::from_slice(&def)?;
if let Some(asset) = def["asset_id"].as_str() {
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
renderer
.pot_patterns
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
}
let items = match horizon_world::item_defaults::read(root) {
Ok(items) => items,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
Err(e) => return Err(e.into()),
};
for (item, components) in &items {
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str() else {
continue;
};
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
let Ok(def) = std::fs::read(file) else {
continue;
};
let def: Value = serde_json::from_slice(&def)?;
if let Some(asset) = def["asset_id"].as_str() {
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
renderer
.pot_patterns
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
}
}
Ok(renderer)
@@ -160,6 +160,18 @@ pub struct Brewing {
}
impl Brewing {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
// NeoForge's load conditions: a file whose don't hold is silently
// absent. Vanilla's brewing recipes carry none; a pack's can.
let mods: Vec<String> = {
let mut mods = horizon_items::content_namespaces::mod_ids(root);
if !mods.iter().any(|m| m == "minecraft") {
mods.push("minecraft".to_owned());
}
mods
};
let conditions_hold = |v: &Value| {
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
};
let mut sources: Vec<(String, Value)> =
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
@@ -191,6 +203,9 @@ impl Brewing {
None => sources.push((id.to_owned(), v)),
}
}
// The gate answers the merged file, as the crafting loader's does:
// a false-conditioned pack file leaves the lower one gone too.
sources.retain(|(_, v)| conditions_hold(v));
sources.sort_by(|a, b| a.0.cmp(&b.0));
let mut recipes = Vec::new();
for (id, v) in sources {
@@ -233,6 +233,9 @@ pub struct SkullSkin {
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<NameOrId>,
/// `PlayerSkin.Patch.body`: the profile's `texture`, a resource pack
/// texture drawn instead of any skin.
pub texture: Option<String>,
pub fallback: &'static str,
}
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
@@ -196,6 +196,7 @@ impl SkullSkin {
Self {
download,
dynamic,
texture: crate::skins::profile_texture_nbt(profile),
fallback,
}
}
+3 -85
View File
@@ -1,85 +1,3 @@
//! A fast, non-cryptographic hasher (the Fx hash used by rustc) for hot maps
//! keyed by small integers, such as chunk coordinates. Iteration order of
//! such maps carries no meaning: the default hasher's is random anyway.
use std::hash::{BuildHasherDefault, Hasher};
const SEED: u64 = 0xf135_7aea_2e62_a9c5;
#[derive(Clone, Copy, Default)]
pub struct FxHasher {
hash: u64,
}
impl FxHasher {
#[inline]
fn add(&mut self, word: u64) {
self.hash = self.hash.wrapping_add(word).wrapping_mul(SEED);
}
}
impl Hasher for FxHasher {
#[inline]
fn write(&mut self, bytes: &[u8]) {
let (words, rest) = bytes.as_chunks::<8>();
for word in words {
self.add(u64::from_le_bytes(*word));
}
if !rest.is_empty() {
let mut word = [0u8; 8];
word[..rest.len()].copy_from_slice(rest);
self.add(u64::from_le_bytes(word));
}
}
#[inline]
fn write_u8(&mut self, i: u8) {
self.add(u64::from(i));
}
#[inline]
fn write_u16(&mut self, i: u16) {
self.add(u64::from(i));
}
#[inline]
fn write_u32(&mut self, i: u32) {
self.add(u64::from(i));
}
#[inline]
fn write_u64(&mut self, i: u64) {
self.add(i);
}
#[inline]
fn write_usize(&mut self, i: usize) {
self.add(i as u64);
}
#[inline]
fn write_i32(&mut self, i: i32) {
self.add(u64::from(i as u32));
}
#[inline]
fn finish(&self) -> u64 {
// The multiply leaves the low bits weakest; hash tables use them.
self.hash.rotate_left(26)
}
}
pub type FxBuild = BuildHasherDefault<FxHasher>;
pub type FxHashMap<K, V> = std::collections::HashMap<K, V, FxBuild>;
pub type FxHashSet<K> = std::collections::HashSet<K, FxBuild>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nearby_chunks_spread_over_buckets() {
let mut map: FxHashMap<(i32, i32), i32> = FxHashMap::default();
for x in -40..40 {
for z in -40..40 {
map.insert((x, z), x * z);
}
}
assert_eq!(map.len(), 6400);
assert_eq!(map[&(-7, 13)], -91);
let mut low = std::collections::HashSet::new();
for x in 0..64 {
low.insert(std::hash::BuildHasher::hash_one(&FxBuild::default(), (x, 0)) & 63);
}
assert!(low.len() > 32, "low bits vary: {}", low.len());
}
}
//! The Fx hasher for hot maps keyed by small integers: kept in horizon-core
//! (the scheduled ticks use it too), and reachable here under its old path.
pub use horizon_core::fast_hash::*;
+396 -56
View File
@@ -94,6 +94,7 @@ mod native_material;
mod native_pack;
mod native_post;
pub mod native_render;
mod native_shadow;
mod native_sky;
mod occlusion;
mod oit;
@@ -480,6 +481,9 @@ pub struct ScreenshotRequest {
/// `GameRenderer.takeAutoScreenshot`: centre-crop the frame to a square
/// and average it down to this size. `None` saves the frame as drawn.
pub size: Option<[u32; 2]>,
/// F2's: drawn at the game's resolution (Resolution Scale above 100%) times
/// `Renderer::screenshot_quality`, not the window's.
pub scaled: bool,
}
pub struct Renderer {
instance: wgpu::Instance,
@@ -494,6 +498,9 @@ pub struct Renderer {
offscreen: Option<wgpu::Texture>,
/// Offscreen submissions still on the GPU: at most two frames run ahead.
in_flight: std::collections::VecDeque<wgpu::SubmissionIndex>,
/// Splitscreen: the caller waits for the GPU once every player has submitted
/// (`wait_in_flight`), so one player's work overlaps the GPU drawing another's.
pub paced_elsewhere: bool,
/// Which steps of the first frame on this device still go to the log
/// (see `startup_step`).
first_frame: FirstFrame,
@@ -601,9 +608,11 @@ pub struct Renderer {
group: wgpu::BindGroup,
globals: wgpu::Buffer,
lightmap: wgpu::Texture,
/// The block sprites, [`SPRITE_SPLIT`] layers per array (the first is
/// The block sprites, `sprite_split` layers per array (the first is
/// `sprites` in terrain.wgsl, the others `sprites_1`...).
sprite_textures: Vec<wgpu::Texture>,
/// Block sprite layers per array on this device ([`layer_split`]).
sprite_split: u32,
/// A 1-layer array bound where a pack needs fewer arrays.
sprite_unused: wgpu::TextureView,
mip_levels: u32,
@@ -751,6 +760,12 @@ pub struct Renderer {
pub screenshot: Option<ScreenshotRequest>,
/// Outcome of the last screenshot: frame size, or the error.
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
/// times the game's resolution (1, 2, 4 or 8).
pub screenshot_quality: u32,
/// How many times the pending scaled screenshot ran out of video memory: each
/// halves its size, down to the window's.
screenshot_halvings: u32,
/// Brightness option (lightmap gamma), 0..1.
pub gamma: f32,
/// Optional full-light lightmap (Horizon Extras).
@@ -765,18 +780,45 @@ pub struct Renderer {
}
/// Every layer's mip levels, layer major (`first` is the first layer's
/// index into `settings`), made by `MipmapGenerator`.
/// Block sprite layers per texture array: the smallest
/// `max_texture_array_layers` of the GPUs Horizon runs on. Mods add sprites
/// past it into more arrays (terrain.wgsl's `sprite`).
/// Block sprite layers per texture array at most: the
/// `max_texture_array_layers` of most GPUs. Mods add sprites past it into more
/// arrays (terrain.wgsl's `sprite`); a GPU with fewer layers per array gets a
/// smaller split ([`layer_split`]).
pub(crate) const SPRITE_SPLIT: u32 = 2048;
/// [`SPRITE_SPLIT`], or a smaller split from `HORIZON_SPRITE_SPLIT` (dev
/// only: vanilla's sprites drawn from several arrays must look the same).
fn sprite_split() -> u32 {
/// A smaller split from `HORIZON_SPRITE_SPLIT` (dev only: vanilla's sprites
/// drawn from several arrays must look the same).
fn requested_split() -> Option<u32> {
std::env::var("HORIZON_SPRITE_SPLIT")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&n| (1..=SPRITE_SPLIT).contains(&n))
.unwrap_or(SPRITE_SPLIT)
}
/// The split the CPU mip chains are cut by before the adapter is known.
fn sprite_split() -> u32 {
requested_split().unwrap_or(SPRITE_SPLIT)
}
/// The block sprite layers per texture array on an adapter that holds
/// `max_layers` layers per array: [`SPRITE_SPLIT`], `requested`
/// (`HORIZON_SPRITE_SPLIT`) or `max_layers`, whichever is smallest, with a full
/// array still fitting once [`array_layers`] pads it for OpenGL. Errors when
/// `layers` sprites need more than the [`SPRITE_ARRAYS`] arrays terrain.wgsl binds.
fn layer_split(
max_layers: u32,
gl: bool,
requested: Option<u32>,
layers: u32,
) -> std::result::Result<u32, String> {
let mut split = requested.unwrap_or(SPRITE_SPLIT).min(max_layers).max(1);
if split > 1 && array_layers(split, gl) > max_layers {
split -= 1;
}
if layers > split * SPRITE_ARRAYS {
return Err(format!(
"GPU supports {max_layers} texture layers per array; {layers} block textures need \
more than the {SPRITE_ARRAYS} arrays of {split} Horizon draws from"
));
}
Ok(split)
}
/// Sprite arrays terrain.wgsl binds (`sprites`, `sprites_1`...).
const SPRITE_ARRAYS: u32 = 8;
@@ -879,6 +921,8 @@ fn terrain_group(
/// construction so the temporary mip chains are released after GPU upload.
pub struct PreparedTextures {
sprites: Vec<(Vec<u8>, u32)>,
/// The sprite layers per array `sprites` is cut by.
split: u32,
forcefield: (Vec<u8>, u32),
}
impl PreparedTextures {
@@ -910,9 +954,33 @@ impl PreparedTextures {
mip_chain(&pixels[start..], size, settings, first, count)
})
.collect(),
split,
forcefield: mip_chain(forcefield.0, forcefield.1, &[], 0, 1),
}
}
/// The same mip chains cut by `split` layers per array: chains made before
/// the adapter was known, for a GPU with fewer layers per array.
fn resplit(mut self, layers: u32, split: u32) -> Self {
if self.split == split || layers <= split.min(self.split) {
self.split = split;
return self;
}
let levels = self.sprites[0].1;
let all: Vec<u8> = self
.sprites
.drain(..)
.flat_map(|(bytes, _)| bytes)
.collect();
// Every layer's mip levels are contiguous (layer major).
let layer_bytes = all.len() / layers as usize;
self.sprites = all
.chunks(layer_bytes * split as usize)
.map(|chunk| (chunk.to_vec(), levels))
.collect();
self.split = split;
self
}
}
fn mip_chain(
@@ -1081,16 +1149,13 @@ fn describe_adapter(adapter: &wgpu::Adapter) {
}
/// Whether an adapter's texture arrays hold the block textures Horizon puts
/// in one array.
fn check_layers(limits: &wgpu::Limits, layers: u32) -> Result<()> {
let needed = layers.min(sprite_split());
if limits.max_texture_array_layers < needed {
return Err(format!(
"GPU supports {} texture layers; {needed} required",
limits.max_texture_array_layers
)
.into());
}
Ok(())
fn check_layers(adapter: &wgpu::Adapter, layers: u32) -> Result<u32> {
Ok(layer_split(
adapter.limits().max_texture_array_layers,
adapter.get_info().backend == wgpu::Backend::Gl,
requested_split(),
layers,
)?)
}
/// Whether the swapchain can take a window of `width` x `height`: each side from 1 to the
/// device's `max_texture_dimension_2d` (`max`), else `Surface::configure` panics. 0 is a
@@ -1261,7 +1326,7 @@ async fn open_backend(
describe_adapter(&adapter);
let info = adapter.get_info();
let limits = adapter.limits();
if let Err(e) = check_layers(&limits, layers) {
if let Err(e) = check_layers(&adapter, layers) {
return Err(NoDevice::Failed(format!("{name}: {e}")));
}
// Mappable primary buffers: section pages that no longer fit in
@@ -1425,14 +1490,6 @@ impl Renderer {
previous: Option<&Self>,
api: GraphicsApi,
) -> Result<Self> {
let split = sprite_split();
if layers > split * SPRITE_ARRAYS {
return Err(format!(
"{layers} block textures; Horizon draws at most {}",
split * SPRITE_ARRAYS
)
.into());
}
let Opened {
instance,
surface,
@@ -1446,7 +1503,7 @@ impl Renderer {
// window.
Some(renderer) => {
describe_adapter(&renderer.adapter);
check_layers(&renderer.adapter.limits(), layers)?;
check_layers(&renderer.adapter, layers)?;
Opened {
surface: if offscreen {
None
@@ -1470,6 +1527,12 @@ impl Renderer {
}
None => open_device(&window, offscreen, api, layers).await?,
};
// Computed once: the sprite arrays, the shaders' constants and the
// shader pack atlas all cut the sprites by it.
let split = check_layers(&adapter, layers)?;
if split < sprite_split() {
log::info!("Horizon: {split} block textures per array (the GPU's limit)");
}
let info = adapter.get_info();
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
let size = window.inner_size();
@@ -1847,9 +1910,17 @@ impl Renderer {
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let prepared = prepared.unwrap_or_else(|| {
PreparedTextures::new(sprite_pixels, sprite_size, layers, sprite_mips, forcefield)
});
let prepared = match prepared {
Some(prepared) => prepared.resplit(layers, split),
None => PreparedTextures::with_split(
sprite_pixels,
sprite_size,
layers,
sprite_mips,
forcefield,
split,
),
};
let mut mip_levels = 1;
let gl = adapter.get_info().backend == wgpu::Backend::Gl;
let sprite_textures: Vec<wgpu::Texture> = prepared
@@ -3018,6 +3089,7 @@ impl Renderer {
spare_surface: std::sync::Mutex::new(None),
offscreen,
in_flight: std::collections::VecDeque::new(),
paced_elsewhere: false,
first_frame: if previous.is_none() {
FirstFrame::Acquire
} else {
@@ -3102,6 +3174,7 @@ impl Renderer {
mipmaps: mip_levels - 1,
terrain_layout: bindings,
terrain_sprites: sprite_constants.map(|(_, v)| v),
sprite_split: split,
sprite_sampler,
texture_filtering: 0,
max_anisotropy: 1,
@@ -3181,6 +3254,8 @@ impl Renderer {
started: Instant::now(),
screenshot: None,
screenshot_result: None,
screenshot_quality: 1,
screenshot_halvings: 0,
gamma: 0.5,
improved_transparency: false,
oit: None,
@@ -3552,7 +3627,8 @@ impl Renderer {
/// origins bound: one indirect multi-draw, or a loop without it.
/// Consecutive draws in one arena page share a vertex buffer binding,
/// keeping the list's order (translucent sections go back to front).
/// `indirect` holds the list's draws from `base`.
/// `indirect` holds the list's draws from `base`. Leaves the quad
/// indices bound, as the draws after it expect.
fn draw_sections(
&self,
pass: &mut wgpu::RenderPass<'_>,
@@ -3582,6 +3658,12 @@ impl Renderer {
}
i += run;
}
if pages[first as usize..end]
.iter()
.any(|(_, sorted)| sorted.is_some())
{
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
}
fn release_section(&mut self, key: SectionKey) {
if let Some(s) = self.sections.remove(key) {
@@ -4164,7 +4246,7 @@ impl Renderer {
&self.device,
&mut encoder,
&self.sprite_textures,
sprite_split(),
self.sprite_split,
layers,
self.sprite_textures[0].width(),
);
@@ -4387,7 +4469,11 @@ impl Renderer {
}
}
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
let rendered = self.draw_frame(frame);
let scaled = self.screenshot_size();
let rendered = match scaled {
Some(size) => self.draw_scaled_frame(frame, size),
None => self.draw_frame(frame, None),
};
// A frame that was not drawn still submits the section copies and
// atlas patches (glyphs, skins, maps), which the caller already took.
if !matches!(rendered, Ok(true)) && !frame.hud.patches.is_empty() {
@@ -4410,9 +4496,74 @@ impl Renderer {
} else if let Some(copies) = self.take_section_copies() {
self.queue.submit([copies]);
}
rendered
// A scaled screenshot's frame never reached the window.
rendered.map(|drawn| drawn && scaled.is_none())
}
fn draw_frame(&mut self, frame: &Frame) -> Result<bool> {
/// The frame size of the pending `scaled` screenshot when it is larger than the
/// window: the window's times the Resolution Scale (when above 100%) and
/// `screenshot_quality`, cut down to what one texture and one readback buffer hold,
/// and halved for each time it ran out of video memory.
fn screenshot_size(&self) -> Option<[u32; 2]> {
let request = self.screenshot.as_ref()?;
if !request.scaled || request.size.is_some() || self.suspended {
return None;
}
let window = [self.config.width, self.config.height];
let limits = self.device.limits();
let factor = scaled_screenshot_factor(
window,
self.world_post.scale,
self.screenshot_quality,
limits.max_texture_dimension_2d,
limits.max_buffer_size,
) / 2f32.powi(self.screenshot_halvings as i32);
(factor > 1.0).then(|| window.map(|side| (side as f32 * factor) as u32))
}
/// Draws one frame at `size` into a texture of its own, for the pending screenshot:
/// every pass sizes itself by `config`, which says `size` for this frame, the HUD
/// keeps its layout at a larger scale, and the world is drawn at `size` (a Resolution
/// Scale below 100% stays as low). The window keeps its last frame.
fn draw_scaled_frame(&mut self, frame: &Frame, size: [u32; 2]) -> Result<bool> {
let window = [self.config.width, self.config.height];
let mut config = self.config.clone();
[config.width, config.height] = size;
config.usage |= wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC;
// Out of video memory (an invalid texture's view is a validation error):
// tried again at half the size next frame; the window keeps its last one.
let checks = GpuChecks::push(&self.device);
let target = Self::offscreen_target(&self.device, &config);
let depth = Self::depth(&self.device, &config);
if let Some(e) = checks.pop() {
log::warn!(
"Horizon renderer: no memory for a {}x{} screenshot ({e}); trying half the size",
size[0],
size[1]
);
self.screenshot_halvings += 1;
return Ok(false);
}
let scale = self.world_post.scale;
self.world_post.scale = scale / scale.max(1.0);
self.hud.zoom = size[0] as f32 / window[0] as f32;
[self.config.width, self.config.height] = size;
let surface = self.surface.take();
// HDR presents to the swapchain; this frame goes to a file.
let hdr = self.hdr.take();
let offscreen = self.offscreen.replace(target);
let window_depth = std::mem::replace(&mut self.depth, depth);
// The frame's own targets at `size` can run out of memory too.
let drawn = self.draw_frame(frame, Some(GpuChecks::push(&self.device)));
self.depth = window_depth;
self.offscreen = offscreen;
self.hdr = hdr;
self.surface = surface;
[self.config.width, self.config.height] = window;
self.hud.zoom = 1.0;
self.world_post.scale = scale;
drawn
}
/// `checks`: the error scopes of a scaled screenshot's frame, checked before its PNG is written.
fn draw_frame(&mut self, frame: &Frame, checks: Option<GpuChecks>) -> Result<bool> {
self.arena.trim();
self.compact_arena();
self.sorted_indices.trim();
@@ -4767,7 +4918,13 @@ impl Renderer {
.as_ref()
.and_then(|p| p.1.as_ref())
.filter(|_| !frame.title)
.and_then(|p| p.shadow_view(frame));
.and_then(|p| p.shadow_view(frame))
.or_else(|| {
// One of Horizon's own packs with `[shadow]` uses the same lists.
let native = self.native.as_ref().filter(|_| !frame.title)?;
native.shadow.as_ref()?;
native.frame.shadow_view(frame)
});
let mut shadow_visible = std::mem::take(&mut self.shadow_visible);
shadow_visible.clear();
if let Some(v) = &shadow_view {
@@ -5708,6 +5865,69 @@ impl Renderer {
native.sky.is_some() && t.recording() && sky_visible && frame.skybox;
}
}
// One of Horizon's own packs with `[shadow]`: the solid terrain the light sees, from the
// shadow camera's lists, into the pack's shadow map (a pack without one gets its 1×1 map
// cleared to "far" once).
if !frame.title
&& let Some(native) = &mut self.native
{
native.frame.shadow_map.clear_blank(&mut encoder);
}
if !frame.title
&& shadow_view.is_some()
&& let Some(native) = &self.native
&& let Some(shadow) = &native.shadow
{
let mut pass = native
.frame
.shadow_map
.begin(&mut encoder, "Native shader pack shadow");
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
if flat_frame
&& let Some(flat) = &self.flat
&& let Some(pipeline) = &shadow.flat
{
pass.set_pipeline(pipeline);
let [first, translucent] = flat.shadow_calls;
flat.draw(&mut pass, &flat.calls[first..translucent], |p| {
self.arena.buffer(p)
});
} else if !flat_frame {
pass.set_pipeline(&shadow.solid);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections(&mut pass, &self.indirect, 0, shadow_first, shadow_solid);
}
drop(pass);
let mut pass = native
.frame
.shadow_map
.begin_translucent(&mut encoder, "Native shader pack translucent shadow");
pass.set_bind_group(0, &self.group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
if flat_frame
&& let Some(flat) = &self.flat
&& let Some(pipeline) = &shadow.flat_translucent
{
pass.set_pipeline(pipeline);
let [_, translucent] = flat.shadow_calls;
flat.draw(&mut pass, &flat.calls[translucent..], |p| {
self.arena.buffer(p)
});
} else if !flat_frame {
pass.set_pipeline(&shadow.translucent);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections(
&mut pass,
&self.indirect,
0,
shadow_first + shadow_solid,
shadow_translucent,
);
}
}
if native_sky_pass
&& let Some(native) = &mut self.native
&& let Some(sky) = &native.sky
@@ -6832,21 +7052,34 @@ impl Renderer {
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
let failed = checks.and_then(GpuChecks::pop);
if let Some((request, buffer)) = readback {
if let Some(hdr_buffer) = hdr_readback {
let hdr_path = hdr_screenshot_path(&request.path);
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()),
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
// Out of memory on the way: the PNG would be black; taken again at half the size.
if let Some(e) = failed {
log::warn!(
"Horizon renderer: a {}x{} screenshot failed ({e}); trying half the size",
self.config.width,
self.config.height
);
self.screenshot_halvings += 1;
self.screenshot = Some(request);
} else {
self.screenshot_halvings = 0;
if let Some(hdr_buffer) = hdr_readback {
let hdr_path = hdr_screenshot_path(&request.path);
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()),
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
}
}
self.screenshot_result = Some(
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
);
}
self.screenshot_result = Some(
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
);
}
let present = Instant::now();
if let Some(surface_frame) = surface_frame {
@@ -6870,14 +7103,9 @@ impl Renderer {
self.configure_surface();
}
} else {
// No swapchain paces offscreen frames: keep two on the GPU at most.
self.in_flight.push_back(submission);
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: index,
timeout: Some(std::time::Duration::from_secs(5)),
});
if !self.paced_elsewhere {
self.wait_in_flight();
}
}
self.last_wait += present.elapsed();
@@ -6889,6 +7117,41 @@ impl Renderer {
Ok(true)
}
}
/// Out-of-memory and validation error scopes, popped together: the first error either caught.
struct GpuChecks {
memory: wgpu::ErrorScopeGuard,
validation: wgpu::ErrorScopeGuard,
}
impl GpuChecks {
fn push(device: &wgpu::Device) -> Self {
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
Self { memory, validation }
}
fn pop(self) -> Option<wgpu::Error> {
let validation = pollster::block_on(self.validation.pop());
let memory = pollster::block_on(self.memory.pop());
memory.or(validation)
}
}
/// How many times the window's size a scaled screenshot is: the Resolution Scale
/// `world_scale` when above 1, times `quality`, as far as a side fits in
/// `max_side` pixels and the readback in `max_buffer` bytes.
fn scaled_screenshot_factor(
[width, height]: [u32; 2],
world_scale: f32,
quality: u32,
max_side: u32,
max_buffer: u64,
) -> f32 {
let wanted = world_scale.max(1.0) * quality.max(1) as f32;
let (w, h) = (f64::from(width.max(1)), f64::from(height.max(1)));
let side = f64::from(max_side) / w.max(h);
// padded_row(w * f) * h * f <= (4 * w * f + 256) * h * f <= max_buffer.
let (a, b) = (4.0 * w * h, 256.0 * h);
let buffer = ((b * b + 4.0 * a * max_buffer as f64).sqrt() - b) / (2.0 * a);
wanted.min(side.min(buffer) as f32)
}
/// Bytes per row of a `width`-pixel frame read back at 4 bytes a pixel.
fn padded_row(width: u32) -> u32 {
(width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
@@ -6925,6 +7188,16 @@ pub(crate) fn copy_frame(
buffer
}
impl Renderer {
/// No swapchain paces offscreen frames: keep two on the GPU at most.
pub fn wait_in_flight(&mut self) {
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: index,
timeout: Some(std::time::Duration::from_secs(5)),
});
}
}
/// Take a screenshot request: queue the frame's copy, or report why the
/// frame cannot be read back.
fn take_readback(
@@ -7297,6 +7570,30 @@ mod tests {
);
}
#[test]
fn scaled_screenshots_follow_resolution_scale_and_quality_within_limits() {
use super::scaled_screenshot_factor as factor;
let big = u64::MAX / 2;
// The window's size at 100% and 1x; Resolution Scale below 100% stays the window's.
assert_eq!(factor([1920, 1080], 1.0, 1, 16384, big), 1.0);
assert_eq!(factor([1920, 1080], 0.5, 1, 16384, big), 1.0);
// The game's resolution, times the quality.
assert_eq!(factor([1920, 1080], 2.0, 1, 16384, big), 2.0);
assert_eq!(factor([1920, 1080], 1.5, 4, 16384, big), 6.0);
assert_eq!(factor([1920, 1080], 0.5, 8, 16384, big), 8.0);
// Cut to the largest texture side: 3840 * 4.266 = 16384.
let f = factor([3840, 2160], 1.0, 8, 16384, big);
assert!(
(3840.0 * f) as u32 <= 16384 && (3840.0 * f) as u32 > 16300,
"{f}"
);
// Cut to the largest buffer: 256 MiB holds about 3.8x of 1080p's readback.
let max = 256 << 20;
let f = factor([1920, 1080], 1.0, 8, 16384, max);
let [w, h] = [1920, 1080].map(|s| (s as f32 * f) as u32);
assert!(u64::from(super::padded_row(w)) * u64::from(h) <= max, "{f}");
assert!(f > 3.7, "{f}");
}
#[test]
fn drawable_size_skips_minimized_and_oversized_windows() {
use super::drawable_size;
assert!(drawable_size(1280, 720, 8192));
@@ -7905,9 +8202,52 @@ mod sprite_upload_tests {
.collect();
assert_eq!(actual, expected.0);
assert_eq!(prepared.forcefield, mip_chain(&border, size, &[], 0, 1));
// Chains cut before the adapter was known, cut again for it.
for to in [1, 2, 3, 5, 8] {
let direct = PreparedTextures::with_split(
&pixels,
size,
layers,
&settings,
(&border, size),
to,
);
let resplit = PreparedTextures::with_split(
&pixels,
size,
layers,
&settings,
(&border, size),
split,
)
.resplit(layers, to);
assert_eq!(resplit.split, to);
assert_eq!(resplit.sprites, direct.sprites, "{split} -> {to}");
}
}
}
#[test]
fn sprite_split_follows_the_adapters_layer_limit() {
// The Discord report: 512 layers per array, 1291 block textures (3 arrays).
assert_eq!(layer_split(512, false, None, 1291), Ok(512));
assert_eq!(layer_split(512, true, None, 1291), Ok(512));
assert_eq!(layer_split(2048, false, None, 1291), Ok(SPRITE_SPLIT));
assert_eq!(layer_split(8192, false, None, 1291), Ok(SPRITE_SPLIT));
// HORIZON_SPRITE_SPLIT only lowers it.
assert_eq!(layer_split(2048, false, Some(512), 1291), Ok(512));
assert_eq!(layer_split(256, false, Some(512), 1291), Ok(256));
// OpenGL pads a multiple of 6 by a layer: a full array must still fit.
assert_eq!(layer_split(600, true, None, 1291), Ok(599));
assert_eq!(layer_split(600, false, None, 1291), Ok(600));
assert_eq!(array_layers(599, true), 599);
// Past the arrays terrain.wgsl binds.
assert_eq!(layer_split(256, false, None, 256 * SPRITE_ARRAYS), Ok(256));
let err = layer_split(128, false, None, 1291).unwrap_err();
assert!(err.contains("128 texture layers per array"), "{err}");
assert!(layer_split(512, false, None, 512 * SPRITE_ARRAYS + 1).is_err());
}
#[test]
fn mip_chain_allocates_exactly_its_size() {
for size in [16, 64] {
@@ -5,6 +5,7 @@ use horizon_shader_api::{Report, StageKind};
use super::native_material::{NativeMaterial, TerrainShape};
use super::native_post::{NativeFrame, NativePost};
use super::native_shadow::NativeShadow;
use super::native_sky::NativeSky;
use super::pack_timing::PassTiming;
@@ -13,6 +14,8 @@ pub struct NativePack {
pub sky: Option<NativeSky>,
pub material: Option<NativeMaterial>,
pub post: Option<NativePost>,
/// The draw of the shadow map the stages read, if the pack asks for one.
pub shadow: Option<NativeShadow>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
}
@@ -29,7 +32,16 @@ impl NativePack {
constants: &[(String, f64)],
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let mut frame = NativeFrame::new(device);
let settings = report
.shadow
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref());
let shadow = report
.shadow
.as_ref()
.map(|_| NativeShadow::new(device, terrain, &frame))
.transpose()?;
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
frame.reflects = report
@@ -75,6 +87,7 @@ impl NativePack {
sky,
material,
post,
shadow,
timing: None,
};
Ok((pack, labels))
@@ -24,6 +24,14 @@ pub struct NativePost {
nearest: wgpu::Sampler,
/// Two targets of the world's size, written in turn (none with a single pass).
targets: Option<([u32; 2], [wgpu::TextureView; 2])>,
/// The frame's shadow map, its comparison sampler (bindings 6 and 7), the translucent map
/// and its colour (bindings 9 and 10).
shadow: (
wgpu::TextureView,
wgpu::Sampler,
wgpu::TextureView,
wgpu::TextureView,
),
/// Last frame's input to the last pass (`history`, binding 8), made with the targets,
/// and a black 1×1 in its place before then.
history: Option<wgpu::Texture>,
@@ -54,6 +62,9 @@ pub struct NativeFrame {
reflection: wgpu::TextureView,
blank: wgpu::TextureView,
reflection_sampler: wgpu::Sampler,
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
pub shadow_map: super::native_shadow::ShadowMap,
shadow_camera: Option<horizon_shaders::Shadow>,
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
/// is drawn and `has_reflection` stays 0.
pub reflects: bool,
@@ -140,7 +151,11 @@ fn eased(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
}
impl NativeFrame {
pub fn new(device: &wgpu::Device) -> Self {
/// The frame of a pack with the `[shadow]` settings `shadow`, if any.
pub fn new(
device: &wgpu::Device,
shadow: Option<&horizon_shader_api::manifest::Shadow>,
) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack frame"),
size: size_of::<FrameInputs>() as u64,
@@ -153,7 +168,11 @@ impl NativeFrame {
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
sampler_entry(2),
texture_entry(3, wgpu::TextureSampleType::Depth),
comparison_entry(4),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(7, wgpu::TextureSampleType::Depth),
texture_entry(8, wgpu::TextureSampleType::Float { filterable: true }),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
],
});
@@ -172,11 +191,13 @@ impl NativeFrame {
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let shadow_map = super::native_shadow::ShadowMap::new(device, shadow);
let group = Self::group(
device,
&layout,
&buffer,
(&blank, &reflection_sampler),
&shadow_map,
&opaque_depth,
&opaque_color,
);
@@ -189,6 +210,8 @@ impl NativeFrame {
reflection: blank.clone(),
blank,
reflection_sampler,
shadow_map,
shadow_camera: shadow.map(super::native_shadow::camera),
reflects: false,
last: None,
eye: [f32::NAN; 4],
@@ -199,11 +222,25 @@ impl NativeFrame {
}
}
/// What the shadow camera sees this frame, for the renderer's shadow lists.
pub fn shadow_view(&self, frame: &Frame) -> Option<super::pack_post::ShadowView> {
let camera = self.shadow_camera.as_ref()?;
Some(super::pack_post::ShadowView {
cull: super::pack_uniforms::shadow_culling(frame, camera),
light: super::pack_uniforms::shadow_light(frame, camera),
// The light's box culls: on the ground it reaches further than `distance` along
// the sun's azimuth while the sun is low.
distance: None,
unculled: None,
})
}
fn group(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
buffer: &wgpu::Buffer,
(reflection, sampler): (&wgpu::TextureView, &wgpu::Sampler),
shadow: &super::native_shadow::ShadowMap,
opaque_depth: &wgpu::Texture,
opaque_color: &wgpu::Texture,
) -> wgpu::BindGroup {
@@ -223,6 +260,22 @@ impl NativeFrame {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&shadow.view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&shadow.sampler),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&shadow.translucent_view),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&shadow.color_view),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(
@@ -284,6 +337,7 @@ impl NativeFrame {
&self.layout,
&self.buffer,
(&self.reflection, &self.reflection_sampler),
&self.shadow_map,
&self.opaque_depth,
&self.opaque_color,
);
@@ -295,7 +349,13 @@ impl NativeFrame {
let now = std::time::Instant::now();
let since = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32());
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
let mut inputs = frame_inputs(frame, size, seconds, self.frames);
let mut inputs = frame_inputs(
frame,
size,
seconds,
self.frames,
self.shadow_camera.as_ref(),
);
inputs.wetness = self.wetness.0;
if !self.reflects {
inputs.has_reflection = 0;
@@ -377,6 +437,15 @@ fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::Bi
}
}
fn comparison_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
count: None,
}
}
fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
@@ -406,7 +475,11 @@ impl NativePost {
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
texture_entry(6, wgpu::TextureSampleType::Depth),
comparison_entry(7),
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
],
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
@@ -471,6 +544,12 @@ impl NativePost {
passes,
layout,
frame: frame.buffer.clone(),
shadow: (
frame.shadow_map.view.clone(),
frame.shadow_map.sampler.clone(),
frame.shadow_map.translucent_view.clone(),
frame.shadow_map.color_view.clone(),
),
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest),
targets: None,
@@ -580,10 +659,26 @@ impl NativePost {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
},
],
})
})
@@ -676,7 +771,13 @@ fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
}
/// The frame as packs read it (docs/api/shaders-reference.md), from the renderer's `Frame`.
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
pub fn frame_inputs(
frame: &Frame,
size: [u32; 2],
seconds: f32,
index: u32,
shadow: Option<&horizon_shaders::Shadow>,
) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far)
@@ -748,6 +849,25 @@ pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) ->
water_plane: frame.water_plane.unwrap_or(0.0),
has_reflection: u32::from(frame.water_plane.is_some()),
reflection_resolution: reflection_size(size).map(|s| s as f32),
mods: pack.mod_params,
light_direction: match shadow {
Some(s) => super::pack_uniforms::shadow_light(frame, s),
None => Vec3::Y,
}
.to_array(),
has_shadow: u32::from(shadow.is_some()),
shadow_view_proj: m(shadow.map_or(Mat4::IDENTITY, |s| {
super::pack_uniforms::shadow_culling(frame, s)
})),
shadow_distance: shadow.map_or(0.0, |s| s.distance),
shadow_distortion: shadow.map_or(0.0, |s| {
super::native_shadow::distortion(s.distance, s.resolution)
}),
shadow_resolution: shadow.map_or(0.0, |s| s.resolution as f32),
shadow_center_texel: shadow.map_or(0.0, |s| {
let k = super::native_shadow::distortion(s.distance, s.resolution);
2.0 * s.distance / s.resolution.max(1) as f32 * (1.0 - k)
}),
}
}
@@ -814,7 +934,7 @@ mod tests {
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device);
let frame = NativeFrame::new(&device, None);
queue.write_buffer(
&frame.buffer,
0,
@@ -933,7 +1053,7 @@ file = \"b.wgsl\"
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device);
let frame = NativeFrame::new(&device, None);
let mut post = NativePost::new(&device, report.stages, &[], format, &frame).unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
@@ -0,0 +1,496 @@
//! The shadow map of Horizon's own shader packs (`[shadow]`, docs/shaders.md): each frame the
//! solid terrain the light (the sun by day, the moon by night) sees is drawn into a square depth
//! map, which the material and post stages read as `shadow_map`; translucent terrain goes over a
//! copy of it, `shadow_map_translucent`, with its colour in `shadow_color`. The shadow camera is the one
//! `shaders/` packs use (`pack_uniforms::shadow_camera`), so the renderer's shadow visibility and
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
use super::wgsl_prefix::{TERRAIN, prefixed, section};
const PREFIX: &str = "horizon_terrain_";
/// The shadow pass's entries: terrain's vertices through the shadow camera, distorted, and the
/// cutout test (leaves' and plants' holes let the light through).
const ENTRIES: &str = "
struct HorizonShadowOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) @interpolate(flat) layer: u32,
@location(2) tint: vec3<f32>,
@location(3) @interpolate(flat) fluid: u32,
}
// `color` is the vertex colour: the tint, and the fluid flag in its alpha's high nibble
// (mesher.rs's `fluid_face`).
fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>)
-> HorizonShadowOut {
let clip = frame.shadow_view_proj * vec4(pos, 1.0);
let xy = shadow_distort(clip.xy);
var out: HorizonShadowOut;
out.clip = vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0);
out.uv = uv;
out.layer = layer;
out.tint = color.rgb;
let high = u32(round(color.a * 255.0)) >> 4u;
out.fluid = u32(high >= 8u && (high & 7u) < 6u);
return out;
}
@vertex fn horizon_shadow_vs(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>,
@location(5) origin: vec4<f32>) -> HorizonShadowOut {
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
}
@vertex fn horizon_shadow_vs_flat(
@builtin(vertex_index) index: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32,
@location(3) color: vec4<f32>
) -> HorizonShadowOut {
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[index >> 8u]];
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
}
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let alpha = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu).a;
if alpha < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
}
// Translucent terrain: its colour where it is the nearest thing the light passes through;
// alpha 1 marks a fluid, other surfaces keep their alpha below it.
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
if color.a < 0.01 { discard; }
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
}
";
/// `shadow_color`'s format.
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
/// centre, so a centre that moves with the camera, even by a texel, resamples every shadow but
/// the nearest each frame and they shimmer; a still one maps the world to the same texels until
/// the next cell. Its depth starts 256 blocks further toward the light than `shaders/` packs'
/// (the projection's near plane is at `-near`), so the ground over a deep cave still casts its
/// shadow, and ends past the build height and the slant across the map below the camera.
pub fn camera(settings: &Settings) -> Shadow {
Shadow {
resolution: settings.resolution,
distance: settings.distance,
render_mul: 1.0,
interval: SNAP,
euclidean_snap: true,
near: 256.0,
far: super::pack_uniforms::SHADOW_EYE + 2.0 * settings.distance + 384.0,
..Shadow::default()
}
}
/// `settings` with the resolution `device` can make.
pub fn fitted(device: &wgpu::Device, settings: &Settings) -> Settings {
Settings {
resolution: settings
.resolution
.min(device.limits().max_texture_dimension_2d),
..settings.clone()
}
}
/// The cell the shadow camera keeps to, in blocks along the world's axes: the player stays
/// within 7 blocks of the centre (half the cell on each axis), where the map is still over
/// three times as fine as at its edge.
const SNAP: f32 = 8.0;
/// The ground one shadow texel covers right under the camera, in blocks: a 32nd, half a texture
/// pixel, so shadow edges near the player stay as sharp as the blocks casting them.
const CENTRE_TEXEL: f32 = 1.0 / 32.0;
/// How strongly the map is distorted toward its centre (`Frame::shadow_distortion`).
/// `xy / (|xy| k + 1 - k)` magnifies the centre by 1 / (1 - k); an undistorted texel covers
/// 2 distance / resolution blocks, so k is what brings that down to `CENTRE_TEXEL`. None when
/// the map is already that fine; at most 0.95, past which the edge of the map gets too few texels.
pub fn distortion(distance: f32, resolution: u32) -> f32 {
let texel = 2.0 * distance / resolution.max(1) as f32;
(1.0 - CENTRE_TEXEL / texel).clamp(0.0, 0.95)
}
/// The shadow map: a square depth map of the pack's resolution, or a 1×1 one (cleared to "far",
/// so everything is lit) for a pack without `[shadow]`; the same with translucent terrain drawn
/// in, and that terrain's colour.
pub struct ShadowMap {
depth: wgpu::Texture,
pub view: wgpu::TextureView,
translucent: wgpu::Texture,
pub translucent_view: wgpu::TextureView,
pub color_view: wgpu::TextureView,
pub sampler: wgpu::Sampler,
/// The 1×1 map still holds the zeroes it was made with.
blank_uncleared: bool,
}
impl ShadowMap {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
let size = settings.map_or(1, |s| s.resolution);
let texture = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| usage,
view_formats: &[],
})
};
let depth = texture(
"Native shader pack shadow map",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::COPY_SRC,
);
let translucent = texture(
"Native shader pack translucent shadow map",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::COPY_DST,
);
let color = texture(
"Native shader pack shadow colour",
SHADOW_COLOR,
wgpu::TextureUsages::empty(),
);
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Native shader pack shadow comparison"),
compare: Some(wgpu::CompareFunction::LessEqual),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
Self {
view: depth.create_view(&Default::default()),
depth,
translucent_view: translucent.create_view(&Default::default()),
translucent,
color_view: color.create_view(&Default::default()),
sampler,
blank_uncleared: settings.is_none(),
}
}
/// A depth pass into the map that clears it to "far".
pub fn begin<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
) -> wgpu::RenderPass<'e> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
})
}
/// The translucent pass: the solid terrain's map copied into the translucent one, which it
/// goes on drawing into, and the colour cleared to white (nothing to pass through).
pub fn begin_translucent<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
) -> wgpu::RenderPass<'e> {
encoder.copy_texture_to_texture(
self.depth.as_image_copy(),
self.translucent.as_image_copy(),
self.depth.size(),
);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.color_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.translucent_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
})
}
/// Clears the 1×1 map of a pack without `[shadow]` once.
pub fn clear_blank(&mut self, encoder: &mut wgpu::CommandEncoder) {
if std::mem::take(&mut self.blank_uncleared) {
drop(self.begin(encoder, "Native shader pack shadow map clear"));
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear"));
}
}
}
/// The pipelines that draw terrain into the shadow map, for the renderer's instanced draws and,
/// where they run, its flat ones, and the frame they read (group 3, the uniform alone: the
/// stages' own group 3 also holds the map being drawn).
pub struct NativeShadow {
pub solid: wgpu::RenderPipeline,
pub flat: Option<wgpu::RenderPipeline>,
/// The translucent terrain's draws into the translucent map and the colour, instanced and
/// flat.
pub translucent: wgpu::RenderPipeline,
pub flat_translucent: Option<wgpu::RenderPipeline>,
pub group: wgpu::BindGroup,
}
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries.
fn shader_source() -> String {
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}\
// Horizon's terrain (terrain.wgsl)\n{}{ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX
)
)
}
impl NativeShadow {
pub fn new(
device: &wgpu::Device,
shape: &TerrainShape<'_>,
frame: &super::native_post::NativeFrame,
) -> Result<Self, String> {
let text = shader_source();
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
let constants = [
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
];
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack shadow"),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let frame_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack shadow frame"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
},
count: None,
}],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack shadow frame"),
layout: &frame_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: frame.buffer.as_entire_binding(),
}],
});
let layout = |flat: Option<&wgpu::BindGroupLayout>| {
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] =
Default::default();
groups[0] = Some(shape.globals);
groups[1] = flat;
groups[GROUP as usize] = Some(&frame_layout);
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack shadow"),
bind_group_layouts: &groups,
..Default::default()
})
};
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
let vertices = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &super::TERRAIN_ATTRIBUTES,
};
let instances = wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance_attributes,
};
let color_target = [Some(wgpu::ColorTargetState {
format: SHADOW_COLOR,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})];
let pipeline = |layout: &wgpu::PipelineLayout,
vertex: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
translucent: bool| {
let options = wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack shadow"),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some(vertex),
compilation_options: options.clone(),
buffers,
},
// Both sides: the quads that face the light are drawn (`facing_runs`), and
// plants' crossed quads cast from either side.
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: wgpu::DepthBiasState {
constant: 0,
slope_scale: 1.5,
clamp: 0.0,
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(if translucent {
"horizon_shadow_fs_translucent"
} else {
"horizon_shadow_fs"
}),
compilation_options: options,
targets: if translucent { &color_target } else { &[] },
}),
multiview_mask: None,
cache: None,
})
};
let instanced = layout(None);
let solid = pipeline(
&instanced,
"horizon_shadow_vs",
&[Some(vertices.clone()), Some(instances.clone())],
false,
);
let translucent = pipeline(
&instanced,
"horizon_shadow_vs",
&[Some(vertices.clone()), Some(instances)],
true,
);
let flat_layout = shape.flat.map(|group| layout(Some(group)));
let flat_pipeline = |translucent| {
flat_layout.as_ref().map(|layout| {
pipeline(
layout,
"horizon_shadow_vs_flat",
&[Some(vertices.clone())],
translucent,
)
})
};
let flat = flat_pipeline(false);
let flat_translucent = flat_pipeline(true);
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("shadow pass: {e}"));
}
Ok(Self {
solid,
flat,
translucent,
flat_translucent,
group,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shadow_shader_validates() {
let text = shader_source();
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
#[test]
fn what_the_camera_stands_on_is_inside_the_shadow_cameras_depth() {
let settings = Settings {
resolution: 2048,
distance: 128.0,
};
let shadow = camera(&settings);
let (view, projection) = super::super::pack_uniforms::shadow_camera(
glam::Vec3::new(0.3, 0.9, 0.0).normalize(),
glam::DVec3::new(-2064.5, 246.0, -4304.5),
&shadow,
);
// Up to the build height's span below a player flying high, as well as above them.
for y in [64.0, 0.0, -64.0, -200.0, -384.0] {
let clip = projection * view * glam::Vec4::new(0.0, y, 0.0, 1.0);
assert!(clip.is_finite(), "{clip}");
assert!((-1.0..=1.0).contains(&clip.z), "{clip}");
}
}
#[test]
fn the_centre_stays_near_the_player_on_both_sides_of_zero() {
let shadow = camera(&Settings {
resolution: 2048,
distance: 128.0,
});
let light = glam::Vec3::Y;
for x in [5.5, -5.5, -2064.5, 2064.5] {
let camera = glam::DVec3::new(x, 64.0, x);
let (view, _) = super::super::pack_uniforms::shadow_camera(light, camera, &shadow);
let centre = view.inverse().w_axis.truncate();
assert!(centre.x.abs() <= SNAP / 2.0, "{x}: {centre}");
assert!(centre.z.abs() <= SNAP / 2.0, "{x}: {centre}");
}
}
#[test]
fn the_distortion_brings_the_centre_texel_to_a_32nd_of_a_block() {
// 2048 texels over 256 blocks: an 8th of a block each, magnified 4 times.
assert!((distortion(128.0, 2048) - 0.75).abs() < 1e-6);
// Already that fine: no distortion.
assert_eq!(distortion(32.0, 2048), 0.0);
assert_eq!(distortion(10_000.0, 1024), 0.95);
}
}
@@ -252,7 +252,7 @@ mod tests {
resource: globals_buffer.as_entire_binding(),
}],
});
let frame = NativeFrame::new(&device);
let frame = NativeFrame::new(&device, None);
queue.write_buffer(
&frame.buffer,
0,
@@ -35,6 +35,8 @@ pub struct PackInputs {
/// `heldItemId`, `heldItemId2` (item.properties, 5.3) and `heldBlockLightValue(2)`.
pub held_items: [i32; 2],
pub held_light: [i32; 2],
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
pub mod_params: [[f32; 4]; 8],
}
/// One uniform's value: up to 16 floats or 3 integers.
@@ -245,7 +247,7 @@ pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
/// How far toward the light the shadow projection starts, in blocks: part of the projection,
/// not of `shadowModelView` (Iris's matrices, measured).
const SHADOW_EYE: f32 = 100.0;
pub const SHADOW_EYE: f32 = 100.0;
/// The shadow camera as Iris places it (7.1; measured from the outside): its view (player
/// space to shadow view space) and GL projection. The view undoes `sunPathRotation`, turns the
@@ -264,7 +266,12 @@ pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4
* DMat4::from_rotation_x(tilt);
let step = f64::from(shadow.interval);
let origin = if step > 0.0 {
DVec3::splat(step / 2.0) - camera % step
let remainder = if shadow.euclidean_snap {
camera.rem_euclid(DVec3::splat(step))
} else {
camera % step
};
DVec3::splat(step / 2.0) - remainder
} else {
DVec3::ZERO
};
+26 -1
View File
@@ -313,6 +313,8 @@ pub fn reduced_far_detail_suggested() -> bool {
/// The name the options screen waits on to bind `Graphics::hdr_key`.
pub const HDR_KEY: &str = "horizon.hdrSettings";
/// The Screenshot Quality choices, times the game's resolution.
pub const SCREENSHOT_QUALITIES: [u32; 4] = [1, 2, 4, 8];
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
/// opens its surface; the settings open HDR's screen only then.
@@ -379,6 +381,8 @@ pub struct Graphics {
pub taa: bool,
/// TAA upscaling; anything but `Native` comes with `taa` on.
pub upscaling: Upscaling,
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
pub screenshot_quality: u32,
/// HDR output (HDR10, or scRGB), where the display path offers it.
pub hdr: bool,
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
@@ -432,6 +436,7 @@ impl Default for Graphics {
fxaa: false,
taa: false,
upscaling: Upscaling::Native,
screenshot_quality: 1,
hdr: false,
hdr_paper_white: 0,
hdr_peak: 0,
@@ -502,6 +507,11 @@ impl Graphics {
"fxaa" => g.fxaa = flag(g.fxaa),
"taa" => g.taa = flag(g.taa),
"upscaling" => g.upscaling = Upscaling::parse(value),
"screenshotQuality" => {
g.screenshot_quality = Some(number(0))
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
.unwrap_or(1)
}
"hdr" => g.hdr = flag(g.hdr),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
@@ -538,7 +548,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -565,6 +575,7 @@ impl Graphics {
bool_text(self.fxaa),
bool_text(self.taa),
self.upscaling.name(),
self.screenshot_quality,
bool_text(self.hdr),
self.hdr_paper_white,
self.hdr_peak,
@@ -710,6 +721,7 @@ mod tests {
fxaa: true,
taa: true,
upscaling: Upscaling::Balanced,
screenshot_quality: 4,
hdr: true,
hdr_paper_white: 240,
hdr_peak: 1400,
@@ -728,6 +740,19 @@ mod tests {
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
// Only the button's choices.
assert_eq!(
Graphics::parse("screenshotQuality:8\n").screenshot_quality,
8
);
assert_eq!(
Graphics::parse("screenshotQuality:3\n").screenshot_quality,
1
);
assert_eq!(
Graphics::parse("screenshotQuality:16\n").screenshot_quality,
1
);
// The old menus' Simple / Advanced choice is ignored, and so is the
// shader pack older versions saved.
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
@@ -40,6 +40,7 @@ pub enum H {
ResolutionScale,
Antialiasing,
Upscaling,
ScreenshotQuality,
Hdr,
/// Opens `Level::Hdr`.
HdrSettings,
@@ -279,6 +280,11 @@ pub fn rows(level: Level) -> Vec<Row> {
High,
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
),
h(
H::ScreenshotQuality,
None,
"F2 screenshots at this many times the game's resolution. Higher takes longer.",
),
h(
H::HdrSettings,
None,
@@ -544,6 +550,9 @@ impl H {
},
),
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
H::ScreenshotQuality => {
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
}
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
@@ -654,6 +663,13 @@ impl H {
(g.fxaa, g.taa) = (false, true);
}
}
H::ScreenshotQuality => {
g.screenshot_quality = step(
&crate::graphics::SCREENSHOT_QUALITIES,
g.screenshot_quality,
back,
)
}
H::Hdr => g.hdr = !g.hdr,
H::BatteryMode => g.battery = !g.battery,
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
@@ -775,6 +791,7 @@ impl Item {
Item::Horizon(
H::Preset
| H::Overlay
| H::ScreenshotQuality
| H::Pacing
| H::PresentMode
| H::Workers
@@ -1743,6 +1760,21 @@ mod tests {
assert_eq!(s.horizon.preset, Preset::Ultra);
}
#[test]
fn screenshot_quality_cycles_1_2_4_8_and_keeps_the_preset() {
let mut s = Settings::default();
Item::Horizon(H::Preset).cycle(&mut s, false, [854, 480]);
let mut seen = Vec::new();
for _ in 0..4 {
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, false, [854, 480]);
seen.push(s.horizon.screenshot_quality);
}
assert_eq!(seen, [2, 4, 8, 1]);
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, true, [854, 480]);
assert_eq!(s.horizon.screenshot_quality, 8);
assert_eq!(s.horizon.preset, Preset::Performance);
}
#[test]
fn upload_budget_slider_steps() {
let mut s = Settings::default();
+23 -9
View File
@@ -528,14 +528,16 @@ impl HudAtlas {
for folder in folders {
walk(&textures.join(folder), &mut files);
}
// Content mods' equipment textures, which their equipment assets name as
// `<ns>:<path>`, and their mob effects' icons, `<ns>:mob_effect/<path>`.
// Content mods' entity textures: their equipment assets name them as
// `<ns>:<path>`, and native mods draw their block entities with them
// (`native_draw_quad`: `<ns>:entity/...`); and their mob effects'
// icons, `<ns>:mob_effect/<path>`.
let namespaces = horizon_items::content_namespaces::namespaces(root);
for (folder, textures) in [("entity", "entity/equipment"), ("mob_effect", "mob_effect")] {
for folder in ["entity", "mob_effect"] {
if folders.contains(&folder) {
for namespace in &namespaces {
walk(
&root.join(format!("assets/{namespace}/textures/{textures}")),
&root.join(format!("assets/{namespace}/textures/{folder}")),
&mut files,
);
}
@@ -1604,6 +1606,9 @@ pub struct HudRenderer {
atlas_view: wgpu::TextureView,
/// The resource pack's text shader, when it has one that works.
pack_text: Option<pack_text::PackText>,
/// Target pixels per window pixel: above 1 for a screenshot drawn larger than the
/// window (`Renderer::screenshot_quality`).
pub zoom: f32,
}
impl HudRenderer {
pub fn new(
@@ -1833,6 +1838,7 @@ impl HudRenderer {
format,
atlas_view: view.clone(),
pack_text: None,
zoom: 1.0,
})
}
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
@@ -1968,14 +1974,14 @@ impl HudRenderer {
[
size[0] as f32,
size[1] as f32,
batch.scale,
batch.scale * self.zoom,
batch.glint_alpha,
],
batch.end_portal,
)),
);
if let Some(pack_text) = &self.pack_text {
pack_text.prepare(uploads, encoder, batch, size);
pack_text.prepare(uploads, encoder, batch, size, batch.scale * self.zoom);
}
}
/// Draw the batch `prepare` staged.
@@ -2048,7 +2054,8 @@ impl HudRenderer {
});
pass.set_bind_group(0, &self.group, &[]);
pass.set_vertex_buffer(0, self.buffer.slice(..));
if let Some([x, y, w, h]) = clip {
if let Some(clip) = clip {
let [x, y, w, h] = clip.map(|v| (v as f32 * self.zoom) as u32);
let (x, y) = (x.min(size[0]), y.min(size[1]));
let (w, h) = (w.min(size[0] - x), h.min(size[1] - y));
if w == 0 || h == 0 {
@@ -2186,7 +2193,7 @@ mod tests {
atlas.pixels[at..at + 4].try_into().unwrap()
}
#[test]
fn content_mod_equipment_textures_pack_under_their_namespace() {
fn content_mod_entity_textures_pack_under_their_namespace() {
let root = std::env::temp_dir().join(format!("horizon-atlas-equip-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let equipment = root.join("assets/gems/textures/entity/equipment/humanoid");
@@ -2206,10 +2213,17 @@ mod tests {
br#"["gems"]"#,
)
.unwrap();
// A block entity's texture, for a native mod's renderer.
let machine = root.join("assets/gems/textures/entity/machine");
fs::create_dir_all(&machine).unwrap();
write_strip(&machine.join("cog.png"), 32, 32, &[[0, 0, 200, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["gems:entity/equipment/humanoid/ruby"];
assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]);
// Only equipment: the mods' other textures reach the atlas through atlas definitions.
let rect = atlas.sprites["gems:entity/machine/cog"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (32, 32));
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 0, 200, 255]);
// Only entity textures: the mods' others reach the atlas through atlas definitions.
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
fs::remove_dir_all(&root).unwrap();
}
@@ -216,8 +216,9 @@ impl PackText {
encoder: &mut wgpu::CommandEncoder,
batch: &HudBatch,
size: [u32; 2],
scale: f32,
) {
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size));
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size, scale));
}
/// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own
@@ -233,7 +234,8 @@ impl PackText {
/// `GlintAlpha`, `GameTime`, `ScreenSize`), `Projection` (`GuiRenderer`'s orthographic GUI
/// projection, 1000 to 11000 deep, in GL's depth range), `DynamicTransforms` (the GUI's model view
/// moving 11000 back, colour modulator white) and an empty `Fog`.
fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
/// `scale`: target pixels per GUI pixel (`batch.scale`, times `HudRenderer::zoom`).
fn uniforms(batch: &HudBatch, size: [u32; 2], scale: f32) -> Vec<u8> {
let mut data = vec![0f32; 256];
let globals = 0;
data[globals + 3] = batch.glint_alpha;
@@ -241,7 +243,7 @@ fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
data[globals + 8] = size[0] as f32;
data[globals + 9] = size[1] as f32;
// Matrix4f.setOrtho(0, width, height, 0, near, far) with the GUI's scale, columns.
let (w, h) = (size[0] as f32 / batch.scale, size[1] as f32 / batch.scale);
let (w, h) = (size[0] as f32 / scale, size[1] as f32 / scale);
let (near, far) = (1000.0f32, 11000.0f32);
let projection = 64;
data[projection] = 2.0 / w;
@@ -287,7 +289,8 @@ mod tests {
fn the_gui_projection_maps_gui_pixels_to_the_screen() {
let mut batch = HudBatch::new([1920, 1080]);
batch.scale = 4.0;
let data: Vec<f32> = bytemuck::cast_slice(&uniforms(&batch, [1920, 1080])).to_vec();
let data: Vec<f32> =
bytemuck::cast_slice(&uniforms(&batch, [1920, 1080], batch.scale)).to_vec();
let column = |matrix: usize, c: usize| &data[matrix + 4 * c..matrix + 4 * c + 4];
let apply = |matrix: usize, v: [f32; 4]| -> [f32; 4] {
std::array::from_fn(|r| (0..4).map(|c| column(matrix, c)[r] * v[c]).sum())
@@ -13,9 +13,10 @@
//!
//! The buckets lie one after another in the mesh, in an order chosen so that
//! the buckets an eye can see are usually one or two runs of quads: one draw
//! each. Inside a bucket quads keep their model order, so coplanar quads
//! facing one way (an overlay on its base) still draw in that order.
use super::TerrainVertex;
//! each. Coplanar quads facing one way keep their model order, so an overlay
//! on its base still draws in that order. Solid cutout quads are otherwise
//! ordered from the outside toward the section interior to reduce overdraw.
use super::{OPAQUE_LAYER, TerrainVertex};
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
/// above (+Y, the commonest case), each pair of horizontal sides with a
@@ -138,11 +139,14 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
OTHER
}
/// Reorders `solid`'s quads by bucket, keeping their order inside each.
/// Reorders `solid`'s quads by bucket. Solid cutout quads are also ordered
/// front to back within each axis-facing bucket; stable sorting preserves the
/// mesher's order for coplanar overlays.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
let (quads, rest) = solid.as_chunks::<4>();
debug_assert!(rest.is_empty(), "solid vertices come in quads");
let buckets: Vec<u8> = quads.iter().map(|q| bucket(q) as u8).collect();
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
let mut counts = [0u32; 7];
let mut planes = [0f32; 7];
for (quad, &b) in quads.iter().zip(&buckets) {
@@ -163,7 +167,7 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
total += count;
*end = total;
}
if counts.iter().filter(|&&c| c > 0).count() > 1 {
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]);
let mut sorted = vec![quads[0]; quads.len()];
for (quad, &b) in quads.iter().zip(&buckets) {
@@ -171,6 +175,25 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sorted[*slot as usize] = *quad;
*slot += 1;
}
if has_cutout {
let mut start = 0;
for (i, bucket) in BUCKETS.iter().enumerate() {
let end = ends[i] as usize;
let slice = &mut sorted[start..end];
if let Bucket::Side(axis, positive) = *bucket {
slice.sort_by(|a, b| {
let a = a[0].pos[axis];
let b = b[0].pos[axis];
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
}
});
}
start = end;
}
}
*solid = sorted.into_flattened();
}
Sides { ends, planes }
@@ -368,4 +391,46 @@ mod tests {
before.iter().map(|v| v.layer).collect::<Vec<_>>()
);
}
#[test]
fn front_to_back_sort_orders_positive_faces_and_keeps_ties_stable() {
let mut solid: Vec<_> = [(1.0, 10), (3.0, 30), (3.0, 31)]
.into_iter()
.flat_map(|(x, layer)| {
quad(
[[x, 0.0, 0.0], [x, 1.0, 0.0], [x, 1.0, 1.0], [x, 0.0, 1.0]],
layer,
)
})
.collect();
sort(&mut solid);
let layers: Vec<u16> = solid
.as_chunks::<4>()
.0
.iter()
.map(|q| q[0].layer)
.collect();
assert_eq!(layers, [30, 31, 10]);
}
#[test]
fn front_to_back_sort_orders_negative_faces_toward_the_eye() {
let mut solid: Vec<_> = [(3.0, 30), (1.0, 10), (2.0, 20)]
.into_iter()
.flat_map(|(x, layer)| {
quad(
[[x, 0.0, 0.0], [x, 0.0, 1.0], [x, 1.0, 1.0], [x, 1.0, 0.0]],
layer,
)
})
.collect();
sort(&mut solid);
let layers: Vec<u16> = solid
.as_chunks::<4>()
.0
.iter()
.map(|q| q[0].layer)
.collect();
assert_eq!(layers, [10, 20, 30]);
}
}
+154 -7
View File
@@ -1236,6 +1236,8 @@ pub enum Page {
NativeMod(usize),
/// Horizon's shader packs, either kind (`shader_pack_list`).
ShaderPacks,
/// The options of `s.shader_packs[.0]`, a Horizon pack.
NativePackOptions(usize),
/// The picked `shaders/` pack's options (`shader_options`), one of its screens at a time.
ShaderOptions,
/// Horizon's graphics settings (`graphics_ui`).
@@ -1282,6 +1284,12 @@ enum Act {
OpenShaderPackFolder,
/// Open the picked pack's Shader Options page.
ShaderOptions,
/// Open the options page of the Horizon pack `s.shader_packs[.0]`.
NativePackOptions(usize),
/// Option `.1` of that pack: its next value.
NativePackOption(usize, usize),
/// Every option of that pack back to its default.
NativePackReset(usize),
/// Shader Options: the next value of an option, a screen of the pack, the next profile,
/// every option back to its default.
ShaderOption(String),
@@ -1411,6 +1419,9 @@ pub enum Event {
OpenShaderOptions,
/// The Shader Options page closed with changes: save them and load the pack again.
ShaderOptions,
/// The options of `shader_packs[.0]`, a Horizon pack, changed: save them (the pack reloads
/// with them).
NativePackOptions(usize),
/// Native mod `.0`'s page opened: the game asks the mod for its labels
/// again (they were made with the list, maybe in another language).
NativeSettings(usize),
@@ -1627,6 +1638,27 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
}
/// The open screen of the picked pack: its title, then its items two to a row (one when the
/// pack asks for a single column).
/// The options of the Horizon pack `s.shader_packs[i]`, two a row.
fn native_pack_option_rows(s: &Settings, i: usize) -> Vec<Row> {
let Some(pack) = s.shader_packs.get(i) else {
return Vec::new();
};
let mut widgets = pack
.options
.iter()
.zip(&pack.values)
.enumerate()
.map(|(o, (option, v))| {
let value = crate::shader_pack_list::show_value(&option.kind, *v);
let label = Text::literal(format!("{}: {value}", option.name));
button(0.0, 0.0, 150.0, label, Act::NativePackOption(i, o))
});
let mut rows = vec![Row::Note(pack.title.clone())];
while let Some(a) = widgets.next() {
rows.push(Row::Buttons(a, widgets.next()));
}
rows
}
fn shader_option_rows(s: &Settings) -> Vec<Row> {
let Some(options) = &s.shader_options else {
return vec![Row::Header("horizon.shader_options.loading")];
@@ -1712,6 +1744,14 @@ enum Row {
/// A widget's tooltip (`setTooltip`), unwrapped: drawn while hovered, and
/// its narration's hint.
fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
if let WKind::Button {
act: Act::NativePackOption(pack, option),
..
} = &wd.kind
{
let option = s.shader_packs.get(*pack)?.options.get(*option)?;
return (!option.description.is_empty()).then(|| Text::literal(&option.description));
}
let WKind::Button { act, .. } = &wd.kind else {
let WKind::Slider { opt, .. } = &wd.kind else {
return None;
@@ -2073,7 +2113,7 @@ impl OptionsUi {
Page::Mods => "horizon.pages.mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::ShaderOptions => "horizon.pages.shader_options",
Page::ShaderOptions | Page::NativePackOptions(_) => "horizon.pages.shader_options",
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
"options.videoTitle"
}
@@ -2502,13 +2542,19 @@ impl OptionsUi {
if s.shader_packs.is_empty() {
return vec![Row::Header("horizon.shader_packs.empty")];
}
let options = s.shader_packs.iter().any(|p| {
p.file == s.horizon.shader_pack
&& p.kind == crate::shader_pack_list::Kind::Classic
});
let options = options.then(|| {
let picked = s
.shader_packs
.iter()
.position(|p| p.file == s.horizon.shader_pack);
let options = picked.and_then(|i| {
let pack = &s.shader_packs[i];
let act = match pack.kind {
crate::shader_pack_list::Kind::Classic => Act::ShaderOptions,
_ if pack.options.is_empty() => return None,
crate::shader_pack_list::Kind::Horizon => Act::NativePackOptions(i),
};
let label = tr("horizon.shader_options.open");
Row::Buttons(button(0.0, 0.0, 310.0, label, Act::ShaderOptions), None)
Some(Row::Buttons(button(0.0, 0.0, 310.0, label, act), None))
});
options
.into_iter()
@@ -2526,6 +2572,7 @@ impl OptionsUi {
.collect()
}
Page::ShaderOptions => shader_option_rows(s),
Page::NativePackOptions(i) => native_pack_option_rows(s, i),
_ => {
let _ = s;
Vec::new()
@@ -3062,6 +3109,7 @@ impl OptionsUi {
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
Page::ShaderOptions => Some(("controls.reset", Act::ShaderReset)),
Page::NativePackOptions(i) => Some(("controls.reset", Act::NativePackReset(i))),
_ => None,
};
if let Some((label, act)) = folder {
@@ -3722,6 +3770,25 @@ impl OptionsUi {
options.cycle(&name, true);
events.push(Event::ButtonClick);
}
let native = widgets.iter().find_map(|wd| match wd.kind {
WKind::Button {
active: true,
act: Act::NativePackOption(pack, option),
..
} if ui::hovered((x, y), wd.x, wd.y, wd.w, wd.h)
&& !(wd.list && (y < top || y >= bottom)) =>
{
Some((pack, option))
}
_ => None,
});
if let Some((pack, option)) = native
&& let Some(entry) = s.shader_packs.get_mut(pack)
&& entry.step_option(option, true, false)
{
events.push(Event::ButtonClick);
events.push(Event::NativePackOptions(pack));
}
return events;
}
if ui::scrollbar_thumb(top, bottom, content, self.scroll).is_some()
@@ -3838,6 +3905,22 @@ impl OptionsUi {
options.reset();
}
}
Act::NativePackOptions(i) => {
self.push(Page::NativePackOptions(i));
}
Act::NativePackOption(pack, option) => {
if let Some(entry) = s.shader_packs.get_mut(pack)
&& entry.step_option(option, false, self.shift)
{
events.push(Event::NativePackOptions(pack));
}
}
Act::NativePackReset(pack) => {
if let Some(entry) = s.shader_packs.get_mut(pack) {
entry.reset_options();
events.push(Event::NativePackOptions(pack));
}
}
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::None => {}
@@ -4745,6 +4828,22 @@ impl OptionsUi {
options.reset();
}
}
Act::NativePackOptions(i) => {
self.push(Page::NativePackOptions(i));
}
Act::NativePackOption(pack, option) => {
if let Some(entry) = s.shader_packs.get_mut(pack)
&& entry.step_option(option, false, shift)
{
events.push(Event::NativePackOptions(pack));
}
}
Act::NativePackReset(pack) => {
if let Some(entry) = s.shader_packs.get_mut(pack) {
entry.reset_options();
events.push(Event::NativePackOptions(pack));
}
}
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::None => {}
@@ -5032,6 +5131,54 @@ mod tests {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
#[test]
fn a_horizon_packs_options_step_both_ways_from_the_picked_packs_button() {
let atlas = atlas();
let size = (960.0, 540.0);
let mut s = Settings::default();
s.horizon.shader_pack = "p".into();
s.shader_packs = vec![crate::shader_pack_list::Entry {
file: "p".into(),
title: "P".into(),
kind: crate::shader_pack_list::Kind::Horizon,
options: vec![horizon_shader_api::PackOption {
id: "a".into(),
name: "A".into(),
description: String::new(),
kind: horizon_shader_api::OptionKind::Float {
default: 1.0,
min: 0.0,
max: 2.0,
step: 0.5,
},
}],
values: vec![1.0],
}];
let mut ui = OptionsUi::default();
ui.show(Page::ShaderPacks);
let find = |ui: &mut OptionsUi, s: &Settings, want: &Act| {
let (widgets, ..) = ui.layout(s, size.0, size.1);
let w = widgets
.into_iter()
.find(|w| matches!(&w.kind, WKind::Button { act, .. } if act == want))
.expect("button");
(w.x + w.w / 2.0, w.y + w.h / 2.0)
};
let (x, y) = find(&mut ui, &s, &Act::NativePackOptions(0));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(ui.page(), Page::NativePackOptions(0));
let (x, y) = find(&mut ui, &s, &Act::NativePackOption(0, 0));
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert!(events.contains(&Event::NativePackOptions(0)));
assert_eq!(s.shader_packs[0].values, [1.5]);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
assert_eq!(s.shader_packs[0].values, [0.5]);
let (x, y) = find(&mut ui, &s, &Act::NativePackReset(0));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(s.shader_packs[0].values, [1.0]);
}
#[test]
fn resource_reload_error_blocks_options_until_dismissed() {
use winit::keyboard::KeyCode;
@@ -2,7 +2,9 @@
//! Settings > Shader Packs lists them: Horizon's own (docs/shaders.md) and those in the
//! `shaders/` format, side by side.
use std::path::Path;
use std::path::{Path, PathBuf};
use horizon_shader_api::{OptionKind, PackOption};
pub const DIR: &str = "shaderpacks";
@@ -14,13 +16,46 @@ pub enum Kind {
Classic,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
/// The folder's or file's name in `shaderpacks`: what the pick saves.
pub file: String,
/// The pack's own name for Horizon's packs, else `file`.
pub title: String,
pub kind: Kind,
/// A Horizon pack's `[[options]]`, and the value of each the player set (`values_path`).
pub options: Vec<PackOption>,
pub values: Vec<f64>,
}
impl Entry {
/// Saves the player's option values, for the pack in the folder `dir`.
pub fn save_values(&self, dir: &Path) {
let path = dir.join(&self.file);
if let Err(e) = save_values(&path, &self.options, &self.values) {
log::warn!("shader pack options of {}: {e}", self.file);
}
}
/// The option `i` one value on (`back`: one before), or at its default (`reset`). Whether
/// the pack has that option.
pub fn step_option(&mut self, i: usize, back: bool, reset: bool) -> bool {
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
return false;
};
*v = if reset {
o.kind.default_constant()
} else {
step_value(&o.kind, *v, back)
};
true
}
/// Every option back to its default.
pub fn reset_options(&mut self) {
let defaults = self.options.iter().map(|o| o.kind.default_constant());
self.values = defaults.collect();
}
}
/// The packs in `dir`, Horizon's first, each kind by title. A missing folder has none.
@@ -53,11 +88,15 @@ fn entry(path: &Path) -> Option<Entry> {
.ok()
.map(|p| p.manifest)
};
let title = manifest.map_or_else(|| file.clone(), |m| m.name);
let (title, options) =
manifest.map_or_else(|| (file.clone(), Vec::new()), |m| (m.name, m.options));
let values = load_values(path, &options);
return Some(Entry {
file,
title,
kind: Kind::Horizon,
options,
values,
});
}
let zip = path.is_file()
@@ -68,9 +107,127 @@ fn entry(path: &Path) -> Option<Entry> {
title: file.clone(),
file,
kind: Kind::Classic,
options: Vec::new(),
values: Vec::new(),
})
}
/// Where the option values of the Horizon pack at `pack` are kept: `<pack>.txt` beside it,
/// one `id=value` a line.
pub fn values_path(pack: &Path) -> PathBuf {
let mut path = pack.as_os_str().to_owned();
path.push(".txt");
PathBuf::from(path)
}
/// The value of each of `options` saved for the pack at `pack`, else its default; a value
/// out of the option's range is brought into it.
pub fn load_values(pack: &Path, options: &[PackOption]) -> Vec<f64> {
let text = std::fs::read_to_string(values_path(pack)).unwrap_or_default();
let saved: std::collections::HashMap<&str, f64> = text
.lines()
.filter_map(|l| l.split_once('='))
.filter_map(|(k, v)| Some((k.trim(), v.trim().parse().ok()?)))
.collect();
options
.iter()
.map(|o| {
saved
.get(o.id.as_str())
.map_or_else(|| o.kind.default_constant(), |&v| clamp(&o.kind, v))
})
.collect()
}
/// Saves `values` of `options`; values at their defaults are left out, and with none left
/// the file goes.
fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::Result<()> {
let text: String = options
.iter()
.zip(values)
.filter(|(o, v)| o.kind.default_constant() != **v)
.map(|(o, v)| format!("{}={v}\n", o.id))
.collect();
let path = values_path(pack);
if text.is_empty() {
match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
} else {
std::fs::write(path, text)
}
}
fn clamp(kind: &OptionKind, v: f64) -> f64 {
match kind {
OptionKind::Bool { .. } => f64::from(u8::from(v != 0.0)),
OptionKind::Float { min, max, .. } => v.clamp(f64::from(*min), f64::from(*max)),
OptionKind::Int { min, max, .. } => v.round().clamp(f64::from(*min), f64::from(*max)),
OptionKind::Choice { values, .. } => v.round().clamp(0.0, values.len() as f64 - 1.0),
}
}
/// The option's next value (`back`: the one before), wrapping around at the ends.
fn step_value(kind: &OptionKind, v: f64, back: bool) -> f64 {
let dir = if back { -1.0 } else { 1.0 };
match kind {
OptionKind::Bool { .. } => f64::from(u8::from(v == 0.0)),
OptionKind::Float { min, max, step, .. } => {
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
if !back && v >= max - step * 0.01 {
min
} else if back && v <= min + step * 0.01 {
max
} else {
// On the grid of steps from the minimum, so float error never piles up.
let n = ((v - min) / step).round() + dir;
(min + n * step).clamp(min, max)
}
}
OptionKind::Int { min, max, .. } => {
let (min, max) = (f64::from(*min), f64::from(*max));
let n = v.round() + dir;
if n > max {
min
} else if n < min {
max
} else {
n
}
}
OptionKind::Choice { values, .. } => {
let n = values.len() as f64;
(v.round() + dir).rem_euclid(n)
}
}
}
/// The value as the settings page shows it.
pub fn show_value(kind: &OptionKind, v: f64) -> String {
match kind {
OptionKind::Bool { .. } => crate::lang::get(if v != 0.0 {
"options.on"
} else {
"options.off"
}),
OptionKind::Float { step, .. } => {
let digits = if *step >= 1.0 {
0
} else if *step >= 0.1 {
1
} else {
2
};
format!("{v:.digits$}")
}
OptionKind::Int { .. } => format!("{v:.0}"),
OptionKind::Choice { values, .. } => {
values.get(v.round() as usize).cloned().unwrap_or_default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -106,4 +263,59 @@ mod tests {
assert!(scan(&d.join("missing")).is_empty());
std::fs::remove_dir_all(d).unwrap();
}
fn option(id: &str, kind: OptionKind) -> PackOption {
PackOption {
id: id.into(),
name: id.into(),
description: String::new(),
kind,
}
}
#[test]
fn values_step_wrap_and_round_trip() {
let float = OptionKind::Float {
default: 1.0,
min: 0.0,
max: 2.0,
step: 0.5,
};
assert_eq!(step_value(&float, 1.0, false), 1.5);
assert_eq!(step_value(&float, 2.0, false), 0.0);
assert_eq!(step_value(&float, 0.0, true), 2.0);
assert_eq!(show_value(&float, 1.5), "1.5");
let choice = OptionKind::Choice {
values: vec!["a".into(), "b".into(), "c".into()],
default: 0,
};
assert_eq!(step_value(&choice, 2.0, false), 0.0);
assert_eq!(step_value(&choice, 0.0, true), 2.0);
assert_eq!(show_value(&choice, 1.0), "b");
let int = OptionKind::Int {
default: 0,
min: -1,
max: 1,
};
assert_eq!(step_value(&int, 1.0, false), -1.0);
let on = OptionKind::Bool { default: true };
assert_eq!(step_value(&on, 1.0, false), 0.0);
let dir =
std::env::temp_dir().join(format!("horizon-shader-values-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let pack = dir.join("p.hshader");
let options = [option("a", float), option("b", choice)];
// Only what differs from the defaults is written; out of range comes back clamped.
save_values(&pack, &options, &[1.0, 2.0]).unwrap();
let text = std::fs::read_to_string(values_path(&pack)).unwrap();
assert_eq!(text, "b=2\n");
std::fs::write(values_path(&pack), "a=9\nb=1\nzzz=4\n").unwrap();
assert_eq!(load_values(&pack, &options), [2.0, 1.0]);
save_values(&pack, &options, &[1.0, 0.0]).unwrap();
assert!(!values_path(&pack).exists());
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+141
View File
@@ -158,6 +158,12 @@ pub fn profile_skin_url_nbt(profile: &horizon_world::nbt::Tag) -> Option<String>
unpack(&value, Signature::Unsigned, &TEXTURE_URIS).skin
}
/// `ResolvableProfile.skinPatch().body()` of a skull's `profile` NBT: the
/// `texture` id that replaces its skin.
pub fn profile_texture_nbt(profile: &horizon_world::nbt::Tag) -> Option<String> {
nbt_text(profile.get("texture"))
}
/// The profile's uuid as u128: `id` as an int array (block entity NBT) or a
/// uuid string (older and component forms). Used for `DefaultPlayerSkin.get`
/// when a head's skin is not downloaded yet.
@@ -467,6 +473,11 @@ pub fn head_skin(
downloads: &mut Downloader,
atlas: impl Fn(&str) -> bool,
) -> String {
if let Some(texture) = profile.get("texture").and_then(|t| t.as_str())
&& let Some(sprite) = patch_skin(texture, downloads, &atlas)
{
return sprite;
}
let p = parse_profile(profile);
let property = p.textures.or_else(|| {
let key = p.dynamic.as_ref()?;
@@ -495,6 +506,68 @@ pub fn head_skin(
crate::tab_list::default_skin(p.id).to_owned()
}
/// `PlayerSkin.Patch.apply` for a head: the sprite of a profile's `texture`
/// override (`ClientAsset.ResourceTexture`, a resource pack's
/// `<namespace>:textures/<path>.png`), which replaces the downloaded or
/// default skin. Vanilla textures are in the atlas already; another
/// namespace's loads into a skin slot, and `None` answers until it is there
/// (or when the packs lack it).
pub fn patch_skin(
texture: &str,
downloads: &mut Downloader,
atlas: impl Fn(&str) -> bool,
) -> Option<String> {
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
if !valid_texture_id(namespace, path) {
return None;
}
if namespace == "minecraft" && atlas(path) {
return Some(path.to_owned());
}
let location = format!("patch/{namespace}/{path}");
if atlas(&location) {
downloads.drawn(&location);
return Some(location);
}
downloads.resource(&location, namespace, path);
None
}
/// `Identifier`'s characters (`isValidNamespace`, `isValidPath`), and no
/// `.`, `..` or empty segment (`FileUtil.decomposePath`, as the packs read
/// it): a server's `texture` never names a file outside the packs.
fn valid_texture_id(namespace: &str, path: &str) -> bool {
let segment = |s: &str| !s.is_empty() && s != "." && s != "..";
!namespace.is_empty()
&& namespace
.chars()
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_' | '-' | '.'))
&& path
.chars()
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '/'))
&& segment(namespace)
&& path.split('/').all(segment)
}
/// A `width`x`height` RGBA image as the 64x64 skin it maps onto: each texel
/// the nearest of the image's at the same normalised UV.
fn resample_64(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
let (w, h) = (width as usize, height as usize);
if w == 0 || h == 0 || rgba.len() < w * h * 4 {
return None;
}
let mut out = vec![0u8; 64 * 64 * 4];
for y in 0..64 {
for x in 0..64 {
let (sx, sy) = (x * w / 64, y * h / 64);
let s = (sy * w + sx) * 4;
let d = (y * 64 + x) * 4;
out[d..d + 4].copy_from_slice(&rgba[s..s + 4]);
}
}
Some(out)
}
/// `MinecraftProfileTexture.getHash`: the base name of the URL's path.
pub fn texture_hash(url: &str) -> Option<String> {
let rest = url.split_once("://")?.1;
@@ -732,6 +805,10 @@ pub struct Downloader {
image_order: std::collections::VecDeque<String>,
/// Skins and capes drawn from the atlas since `take_drawn`.
drawn: std::collections::HashSet<String>,
/// The resource packs' root, where a profile's `texture` patch is read.
resource_root: std::path::PathBuf,
/// How each `patch/` texture of the resource packs loaded, by location.
resources: std::collections::HashMap<String, Download>,
}
/// `images` is bounded only so a long session on a busy server does not grow
/// without end; the disk cache holds everything it drops.
@@ -749,8 +826,48 @@ impl Downloader {
images: Default::default(),
image_order: Default::default(),
drawn: Default::default(),
resource_root: Default::default(),
resources: Default::default(),
}
}
/// The resource packs changed (or were first loaded) at `root`: patch
/// textures load again from there.
pub fn set_resource_root(&mut self, root: std::path::PathBuf) {
for location in std::mem::take(&mut self.resources).into_keys() {
self.images.remove(&location);
self.image_order.retain(|l| *l != location);
}
self.resource_root = root;
}
/// `ClientAsset.ResourceTexture`: loads `<namespace>:textures/<path>.png`
/// from the resource packs into a skin slot as `location`, once. A
/// texture of another size is sampled as the model's normalised UVs would
/// sample it (nearest texel) into the 64x64 slot.
fn resource(&mut self, location: &str, namespace: &str, path: &str) {
match self.resources.get(location) {
Some(Download::Failed) => return,
Some(_) => {
self.again(location);
return;
}
None => {}
}
let file = self
.resource_root
.join(format!("assets/{namespace}/textures/{path}.png"));
let image = std::fs::read(file)
.ok()
.and_then(|b| crate::hud::decode_bytes(&b).ok())
.and_then(|(w, h, rgba)| resample_64(w, h, &rgba));
let state = match image {
Some(rgba) => {
self.queue(location.to_owned(), rgba);
Download::Ready
}
None => Download::Failed,
};
self.resources.insert(location.to_owned(), state);
}
/// How the download of `url` stands, starting it the first time.
pub fn state(&mut self, kind: Kind, url: &str) -> Download {
while let Ok((key, image)) = self.done.try_recv() {
@@ -824,6 +941,15 @@ impl Downloader {
if self.queued.contains(location) {
return true;
}
if location.starts_with("patch/") {
let Some(image) = self.images.get(location).cloned() else {
// Dropped from memory: read from the packs again.
self.resources.remove(location);
return false;
};
self.queue(location.to_owned(), image);
return true;
}
let (digest, process): (_, fn(u32, u32, &[u8]) -> Option<Vec<u8>>) =
if let Some(digest) = location.strip_prefix("skins/") {
(digest, process_legacy_skin)
@@ -960,6 +1086,21 @@ pub fn sha1(data: &[u8]) -> [u8; 20] {
#[cfg(test)]
mod tests {
/// A profile's `texture` names a pack texture, never a file outside the
/// packs: bad characters and `..` segments draw nothing.
#[test]
fn a_texture_patch_stays_inside_the_packs() {
use super::valid_texture_id;
assert!(valid_texture_id("ethereal", "entity/plush_blank"));
assert!(valid_texture_id("minecraft", "entity/player/wide/steve"));
assert!(!valid_texture_id("ethereal", "../../../secret"));
assert!(!valid_texture_id("..", "entity/x"));
assert!(!valid_texture_id("ethereal", "entity//x"));
assert!(!valid_texture_id("Ethereal", "entity/x"));
assert!(!valid_texture_id("ethereal", "entity\\x"));
assert!(!valid_texture_id("", "entity/x"));
}
/// A pack reload swaps the atlas: its skin slots are blank, so a forgotten
/// table holds nothing and hands out slots from the start again, even for
/// skins drawn in the last frame.
@@ -25,6 +25,30 @@ 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> {
+68 -22
View File
@@ -215,6 +215,8 @@ enum Job {
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
/// off the main thread like vanilla's IOWorker.
Save(Vec<((i32, i32), Proto)>),
/// A proto chunk checked out for its light view (`light_region`).
LightView(i32, i32, Proto),
/// Meshes the renderer is done with (`Streaming::free`): their memory
/// is given back here, not in the frame.
Free(Vec<((i32, i32, i32), SectionMesh)>),
@@ -235,6 +237,8 @@ pub enum Done {
Lit(i32, i32, Vec<crate::chunk::Light>, u64),
Meshed(i32, i32, i32, SectionMesh, u64),
Saved(Vec<((i32, i32), Proto)>),
/// A proto chunk back from `Job::LightView`, with its view.
LightView(i32, i32, Proto, Option<Chunk>),
}
struct Queued {
priority: i64,
@@ -279,6 +283,8 @@ struct Shared {
/// 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 {
@@ -352,7 +358,6 @@ pub struct Streaming {
/// Columns generated and saved as they were left behind: new when they
/// next arrive, though read from the save (`World::loaded_chunks`).
dropped_new: FxHashSet<(i32, i32)>,
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
threads: Vec<thread::JoinHandle<()>>,
order: u64,
@@ -377,6 +382,8 @@ pub struct Streaming {
decorating_finals: HashSet<(i32, i32)>,
/// Save jobs in flight.
saving: usize,
/// Proto chunks checked out to a worker for their light view.
viewing: FxHashSet<(i32, i32)>,
/// Vanilla's decoration order around the player, and the centre and
/// radius it was last ranked for.
decoration_order: DecorationOrder,
@@ -618,8 +625,8 @@ impl Streaming {
done: Mailbox::default(),
meshes: Mutex::new(MeshPool::default()),
released: AtomicUsize::new(0),
workers: AtomicUsize::new(0),
});
let workers = threads.max(1);
let light_tables = context.light.clone();
let saves: Arc<dyn Saves> = generator.clone();
let threads = (0..threads.max(1))
@@ -673,7 +680,7 @@ impl Streaming {
// runs short: once every worker has freed its own,
// hand them back.
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
== workers
== shared.workers.load(Ordering::Relaxed)
{
// No worker needs retained mesh capacity now.
// Drop it outside the pool lock before asking
@@ -732,6 +739,10 @@ impl Streaming {
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);
@@ -780,20 +791,22 @@ impl Streaming {
shared.done.send(done);
}
})
.expect("spawn worker")
})
.collect();
let light_view = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.light_view(p))
};
// 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 non_air_sections = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.non_air_sections(p))
};
Self {
shared,
light_view,
non_air_sections,
threads,
order: 0,
@@ -808,6 +821,7 @@ impl Streaming {
decorating: 0,
decorating_finals: HashSet::new(),
saving: 0,
viewing: Default::default(),
decoration_order: DecorationOrder::default(),
order_area: None,
queue_basis: Default::default(),
@@ -1092,28 +1106,42 @@ impl Streaming {
/// The 3x3 a light job reads: the finished columns, and for a neighbour
/// that will not finish (past the area), its decorated proto chunk, as
/// vanilla lights its last FULL ring against neighbours at FEATURES.
/// A proto neighbour without its view yet is checked out to a worker,
/// which builds the view off the server thread (`Job::LightView`); the
/// region is asked for again once it is back.
fn light_region(&mut self, world: &World, x: i32, z: i32) -> Option<Box<Region>> {
let mut region: Region = Default::default();
let mut complete = true;
for dz in 0..3 {
for dx in 0..3 {
let n = (x + dx as i32 - 1, z + dz as i32 - 1);
region[dz][dx] = Some(match world.chunks.get(&n) {
Some(chunk) => chunk.clone(),
region[dz][dx] = match world.chunks.get(&n) {
Some(chunk) => Some(chunk.clone()),
None if !self.in_range(n.0, n.1, 1) && self.decorated.contains(&n) => {
if let Some(view) = self.light_views.get(&n) {
view.clone()
Some(view.clone())
} else {
let proto = self.protos.get(&n)?.as_ref()?;
let view = Arc::new((self.light_view)(proto)?);
self.light_views.insert(n, view.clone());
view
let proto = self.protos.get_mut(&n)?.take()?;
self.viewing.insert(n);
self.submit(Some((x, z)), 0, Job::LightView(n.0, n.1, proto));
complete = false;
None
}
}
None => return None,
});
};
}
}
Some(Box::new(region))
complete.then(|| Box::new(region))
}
/// A proto chunk back from its light view: the view waits in
/// `light_views` for the light jobs that asked for it.
fn receive_light_view(&mut self, x: i32, z: i32, proto: Proto, view: Option<Chunk>) {
self.viewing.remove(&(x, z));
self.protos.insert((x, z), Some(proto));
if let Some(view) = view {
self.light_views.insert((x, z), Arc::new(view));
}
}
/// Whether `light_region` would find all nine columns: the same walk
/// without building the snapshot, so the scheduler can rank the ready
@@ -1201,6 +1229,7 @@ impl Streaming {
pub fn busy(&self) -> bool {
!(self.generating.is_empty()
&& self.decorating == 0
&& self.viewing.is_empty()
&& self.finishing.is_empty()
&& self.lighting.is_empty()
&& self.meshing.is_empty()
@@ -1264,6 +1293,7 @@ impl Streaming {
}
Done::Generated(chunk) => self.receive_generated(chunk, world),
Done::Saved(protos) => self.receive_saved(protos),
Done::LightView(x, z, proto, view) => self.receive_light_view(x, z, proto, view),
Done::Lit(x, z, sections, seq) => {
if self.lighting.get(&(x, z)) != Some(&seq) {
continue;
@@ -1822,7 +1852,11 @@ impl Streaming {
return;
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
while self.decorating > 0
|| !self.finishing.is_empty()
|| self.saving > 0
|| !self.viewing.is_empty()
{
let left = deadline.saturating_duration_since(std::time::Instant::now());
match self.shared.done.recv_timeout(left) {
Some(Done::Decorated(x, z, region, finals, writes)) => {
@@ -1830,6 +1864,9 @@ impl Streaming {
}
Some(Done::Generated(chunk)) => self.receive_generated(chunk, world),
Some(Done::Saved(protos)) => self.receive_saved(protos),
Some(Done::LightView(x, z, proto, view)) => {
self.receive_light_view(x, z, proto, view)
}
Some(other) => self.deferred.push(other),
None => {
log::warn!("Horizon: chunk generation did not finish before saving");
@@ -1860,13 +1897,18 @@ impl Streaming {
}
Done::Generated(chunk) => self.receive_generated(chunk, world),
Done::Saved(protos) => self.receive_saved(protos),
Done::LightView(x, z, proto, view) => self.receive_light_view(x, z, proto, view),
other => self.deferred.push(other),
}
if started.elapsed() >= std::time::Duration::from_millis(2) {
return false;
}
}
if self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
if self.decorating > 0
|| !self.finishing.is_empty()
|| self.saving > 0
|| !self.viewing.is_empty()
{
return false;
}
let written: Vec<_> = self.written.drain().collect();
@@ -1940,7 +1982,11 @@ impl Streaming {
counts[1] += 1;
} else {
counts[2] += 1;
if self.light_region(world, key.0, key.1).is_some() {
if self.light_region(world, key.0, key.1).is_some()
|| (-1..=1).any(|dz| {
(-1..=1).any(|dx| self.viewing.contains(&(key.0 + dx, key.1 + dz)))
})
{
world.dirty_light.insert(key);
}
}
@@ -98,6 +98,25 @@ thread_local! {
static TARGETS: RefCell<(Vec<Target>, Vec<Target>, u64)> = const { RefCell::new((Vec::new(), Vec::new(), 0)) };
}
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
/// runs on this thread ([`take`] and [`put`] around each turn).
#[derive(Default)]
pub struct Saved(
(Vec<Widget>, Vec<Widget>, u64),
(Vec<Target>, Vec<Target>, u64),
);
/// This thread's buttons and targets, leaving none.
pub fn take() -> Saved {
Saved(WIDGETS.take(), TARGETS.take())
}
/// Bring back what [`take`] kept.
pub fn put(saved: Saved) {
WIDGETS.set(saved.0);
TARGETS.set(saved.1);
}
/// An interactive rectangle (GUI pixels) a controller can focus and click.
pub fn target(rect: [f32; 4]) {
target_as(rect, Kind::Button);
+7
View File
@@ -83,6 +83,13 @@ open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
# `above_menu_bar`); all already in the tree through winit and wgpu.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
# DirectX 12 driver (`graphicsApi`, graphics.rs).
+9
View File
@@ -5,6 +5,15 @@ fn main() {
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
println!("cargo:rerun-if-changed={ico}");
// The game's main thread gets 8 MB of stack on Windows as on macOS and Linux (Windows gives 1
// MB): splitscreen builds a joining player's `Game`, ~67 KB, deep in the event loop.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let arg = match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() {
Ok("msvc") => "/STACK:8388608",
_ => "-Wl,--stack,8388608",
};
println!("cargo:rustc-link-arg-bin=horizon-client={arg}");
}
#[cfg(windows)]
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let mut resource = winresource::WindowsResource::new();
@@ -133,7 +133,7 @@ fn main() -> Result<()> {
.collect();
let descriptor = BlockState {
name: name.clone(),
properties,
properties: properties.into(),
};
let Some(id) = registry.id(&descriptor) else {
states.insert(key.clone(), Value::Null);
@@ -291,6 +291,8 @@ pub fn main() -> horizon_client::Result<()> {
for d in items {
let _ = match d {
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
// The fixture's mobs keep no whole stacks.
drops::Drop::Stack(s) => writeln!(out, "drop {} {}", s.item, s.count),
drops::Drop::CarriedBlock(state) => {
let stacks = enderman::carried_block_drops(
&block_items,
@@ -179,7 +179,7 @@ fn state(registry: &BlockRegistry, text: &str) -> BlockStateId {
registry
.resolve(&BlockState {
name: name.to_owned(),
properties,
properties: properties.into(),
})
.unwrap_or_else(|e| panic!("{text}: {e}"))
}
@@ -1315,7 +1315,7 @@ pub fn main() -> horizon_client::Result<()> {
continue;
}
if let Some(Some((name, volume, pitch))) =
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random, None)
{
events.push(MobEvent::Sound(name, [0.0; 3], volume, pitch));
}
@@ -119,7 +119,7 @@ fn parse_state(spec: &str) -> BlockState {
.collect();
BlockState {
name: name.to_owned(),
properties,
properties: properties.into(),
}
}
+9
View File
@@ -1071,6 +1071,15 @@ impl Ticker<'_, '_, '_, '_> {
}
effects.advance(step);
let name = &*self.scene.facts.get(state).name;
crate::block_contact::emit(crate::block_contact::Event::Inside(
crate::block_contact::Contact {
entity: self.p.id as i32,
kind: self.p.kind.id(),
block: name,
state,
position: p,
},
));
if matches!(name, "minecraft:fire" | "minecraft:soul_fire") {
// BaseFireBlock.entityInside (its inFire hurt does nothing to arrows).
effects.apply(Effect::ClearFreeze);
+1 -3
View File
@@ -92,9 +92,7 @@ impl Banners {
});
}
banners.no_item_required = read_tag(root, "no_item_required", 0);
if let Some(raw) = fs::read(root.join("items.json")).ok().and_then(|b| {
serde_json::from_slice::<std::collections::BTreeMap<String, Value>>(&b).ok()
}) {
if let Ok(raw) = horizon_world::item_defaults::read(root) {
for (item, c) in raw {
let id: Arc<str> = Arc::from(item.as_str());
if let Some(dye) = c["minecraft:dye"].as_str() {
+201
View File
@@ -0,0 +1,201 @@
//! Synchronous mod-block contacts from the server's simulation code.
use horizon_core::position::BlockPos;
use horizon_world::registry::BlockStateId;
use std::{
cell::Cell,
sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
},
};
#[derive(Clone, Copy, Debug)]
pub struct Contact<'a> {
pub entity: i32,
pub kind: &'a str,
pub block: &'a str,
pub state: BlockStateId,
pub position: BlockPos,
}
#[derive(Clone, Copy, Debug)]
pub enum Event<'a> {
StepOn(Contact<'a>),
Inside(Contact<'a>),
FallOn(Contact<'a>, f64),
}
pub static HOOK: OnceLock<fn(&str, Event<'_>, &mut f32)> = OnceLock::new();
/// Whether the loaded blocks hold one outside `minecraft:` (a content mod's).
/// Without one no contact can dispatch, so the item and projectile sweeps
/// skip their per-tick work: every item and thrown thing on a server would
/// otherwise sweep its blocks each tick for nothing.
static MOD_BLOCKS: AtomicBool = AtomicBool::new(false);
/// Called when the block tables load.
pub fn set_mod_blocks(any: bool) {
MOD_BLOCKS.store(any, Ordering::Relaxed);
}
thread_local! {
static LEVEL: Cell<Option<&'static str>> = const { Cell::new(None) };
}
/// Scope authority to this thread and restore it even on an error or panic.
/// Nested guest actions may temporarily enter a different dimension.
pub fn in_level<R>(dimension: Option<&'static str>, f: impl FnOnce() -> R) -> R {
struct Restore(Option<&'static str>);
impl Drop for Restore {
fn drop(&mut self) {
LEVEL.set(self.0);
}
}
let _restore = Restore(LEVEL.replace(dimension));
f()
}
pub fn emit(event: Event<'_>) {
dispatch(event, &mut 0.0);
}
/// Damage is the calculated fall damage, before hurt handling and fall sounds.
pub fn fall(contact: Contact<'_>, distance: f64, mut damage: f32) -> f32 {
dispatch(Event::FallOn(contact, distance), &mut damage);
damage
}
fn dispatch(event: Event<'_>, damage: &mut f32) {
let contact = match event {
Event::StepOn(c) | Event::Inside(c) | Event::FallOn(c, _) => c,
};
if contact.block.starts_with("minecraft:") {
return;
}
if let Some(dimension) = LEVEL.get()
&& let Some(hook) = HOOK.get()
{
let original = *damage;
hook(dimension, event, damage);
if !damage.is_finite() {
*damage = original;
}
*damage = damage.max(0.0);
}
}
/// Reuse the entity block sweep for item and projectile ticks without an effect collector.
pub fn swept_inside<'a>(
entity: i32,
kind: &str,
from: horizon_core::geometry::Vec3,
to: horizon_core::geometry::Vec3,
bounds: horizon_core::geometry::Aabb,
mut block: impl FnMut(BlockPos) -> (BlockStateId, &'a str),
) {
if !MOD_BLOCKS.load(Ordering::Relaxed) || LEVEL.get().is_none() || HOOK.get().is_none() {
return;
}
let bounds = bounds.deflate(horizon_core::geometry::Vec3::new(1e-5, 1e-5, 1e-5));
let mut visited = std::collections::HashSet::new();
let _ =
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, step| {
if step >= 16 {
return false;
}
if visited.insert(p) {
let (state, name) = block(p);
emit(Event::Inside(Contact {
entity,
kind,
block: name,
state,
position: p,
}));
}
true
});
}
#[cfg(test)]
mod tests {
use super::*;
thread_local! {
static SEEN: Cell<usize> = const { Cell::new(0) };
}
fn hook(dimension: &str, event: Event<'_>, damage: &mut f32) {
assert_eq!(dimension, "minecraft:the_nether");
let c = match event {
Event::FallOn(c, distance) => {
assert_eq!(distance, 12.0);
*damage = match *damage {
99.0 => f32::NAN,
98.0 => -1.0,
_ => *damage * 0.2,
};
c
}
Event::Inside(c) | Event::StepOn(c) => c,
};
assert_eq!(
(c.entity, c.kind, c.state, c.position),
(
17,
"minecraft:zombie",
BlockStateId(9),
BlockPos::new(1, 2, 3)
)
);
SEEN.set(SEEN.get() + 1);
}
#[test]
fn contacts_require_server_scope_and_mod_blocks_and_restore_nested_levels() {
HOOK.set(hook).unwrap();
let c = Contact {
entity: 17,
kind: "minecraft:zombie",
block: "test:hay",
state: BlockStateId(9),
position: BlockPos::new(1, 2, 3),
};
assert_eq!(fall(c, 12.0, 10.0), 10.0);
in_level(Some("minecraft:the_nether"), || {
emit(Event::StepOn(c));
emit(Event::Inside(c));
assert_eq!(fall(c, 12.0, 10.0), 2.0);
in_level(None, || assert_eq!(fall(c, 12.0, 10.0), 10.0));
assert_eq!(fall(c, 12.0, 0.0), 0.0);
emit(Event::Inside(Contact {
block: "minecraft:hay_block",
..c
}));
});
assert_eq!(fall(c, 12.0, 10.0), 10.0);
assert_eq!(SEEN.get(), 4);
in_level(Some("minecraft:the_nether"), || {
assert_eq!(fall(c, 12.0, 99.0), 99.0);
assert_eq!(fall(c, 12.0, 98.0), 0.0);
use horizon_core::geometry::{Aabb, Vec3};
let to = Vec3::new(2.5, 2.0, 3.5);
let bounds = Aabb::new(Vec3::new(2.25, 2.0, 3.25), Vec3::new(2.75, 2.5, 3.75));
let block = |p| {
(
BlockStateId(9),
if p == c.position {
"test:hay"
} else {
"minecraft:air"
},
)
};
set_mod_blocks(true);
swept_inside(17, c.kind, Vec3::new(0.5, 2.0, 3.5), to, bounds, block);
assert_eq!(SEEN.get(), 7, "a block crossed mid-move is notified once");
let at = Vec3::new(1.5, 2.0, 3.5);
let bounds = bounds.move_by(Vec3::new(-1.0, 0.0, 0.0));
swept_inside(17, c.kind, at, at, bounds, block);
assert_eq!(SEEN.get(), 8, "stationary overlap still notifies");
});
let _ =
std::panic::catch_unwind(|| in_level(Some("minecraft:the_nether"), || panic!("test")));
assert_eq!(fall(c, 12.0, 10.0), 10.0);
}
}
@@ -77,14 +77,13 @@ pub fn identity(root: &Path) -> Result<[u8; 32], IdentityError> {
if !components.is_empty() {
hash = hash_entries(hash, "horizon:nbt_components/v1", &components);
// Defaults are not sent in a stack patch. Both peers must therefore
// agree on their values as well as the registered wire IDs.
let bytes = std::fs::read(root.join("items.json")).map_err(IdentityError::Read)?;
let items: BTreeMap<String, BTreeMap<String, serde_json::Value>> =
serde_json::from_slice(&bytes).map_err(IdentityError::Json)?;
// agree on their values as well as the registered wire IDs, native
// mods' changes included.
let items = horizon_world::item_defaults::read(root).map_err(IdentityError::Read)?;
let mut defaults = Vec::new();
for (item, components) in items {
for (name, value) in components {
if horizon_protocol::item::custom::id(&name).is_some() {
for (name, value) in components.as_object().into_iter().flatten() {
if horizon_protocol::item::custom::id(name).is_some() {
defaults.push((defaults.len() as u64, format!("{item}\0{name}\0{value}")));
}
}
+235
View File
@@ -44,10 +44,73 @@ impl Ingredient {
match v {
Value::String(s) => add(s),
Value::Array(a) => a.iter().filter_map(Value::as_str).for_each(&mut add),
Value::Object(o) => return Self::parse_object(o, items),
_ => return None,
}
(!list.is_empty()).then(|| Self::of(list))
}
/// NeoForge's ingredient types (`NeoForgeRegistries.Keys.INGREDIENT_TYPES`),
/// the standard shapes mods' recipes carry, each resolved to the flat item
/// list it matches. A kind outside these still fails the file: a mod's
/// own ingredient types come with their own hook, not the loader.
fn parse_object(o: &serde_json::Map<String, Value>, items: &Items) -> Option<Self> {
// Current NeoForge marks a custom ingredient with
// `neoforge:ingredient_type`; `type` is the older packs' key.
let kind = o
.get("neoforge:ingredient_type")
.or_else(|| o.get("type"))?
.as_str()?;
let children = || {
o.get("children")
.or_else(|| o.get("ingredients"))
.and_then(Value::as_array)
};
let kept = match kind {
// `CompoundIngredient`: any of its children matching the stack
// is enough — the union of their items.
"neoforge:compound" => {
let subs: Vec<Self> = children()?
.iter()
.map(|s| Self::parse(s, items))
.collect::<Option<_>>()?;
let mut seen = Vec::new();
for sub in &subs {
for item in &sub.1 {
if !seen.contains(item) {
seen.push(item.clone());
}
}
}
seen
}
// `IntersectionIngredient`: every child must match the same
// stack — the items all of them name.
"neoforge:intersection" => {
let subs: Vec<Self> = children()?
.iter()
.map(|s| Self::parse(s, items))
.collect::<Option<_>>()?;
subs.first()?
.1
.iter()
.filter(|i| subs[1..].iter().all(|s| s.0.contains(*i)))
.cloned()
.collect()
}
// The base's items without the subtracted's.
"neoforge:difference" => {
let base = Self::parse(o.get("base")?, items)?;
let gone = Self::parse(o.get("subtracted")?, items)?;
base.1
.iter()
.filter(|i| !gone.0.contains(*i))
.cloned()
.collect()
}
_ => return None,
};
(!kept.is_empty()).then(|| Self::of(kept))
}
pub fn test(&self, stack: &ItemStack) -> bool {
self.0.contains(&*stack.item)
}
@@ -1252,6 +1315,18 @@ impl Recipes {
let mut meta = HashMap::new();
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
// NeoForge's load conditions: `mod_loaded` asks which mods' data is
// in this root (`minecraft`, always, and the content mods').
// `mod_loaded` asks which mods are loaded, not which namespaces
// they own: a mod that also owns a namespace doesn't make that
// namespace another mod.
let mods: Vec<String> = {
let mut mods = horizon_items::content_namespaces::mod_ids(root);
if !mods.iter().any(|m| m == "minecraft") {
mods.push("minecraft".to_owned());
}
mods
};
for (name, p) in recipe_files(&root.join("data/minecraft/recipe"))? {
sources.push((name, fs::read(&p)?));
}
@@ -1291,6 +1366,13 @@ impl Recipes {
continue;
}
};
// A file whose load conditions don't hold is silently absent,
// as NeoForge's ICondition leaves it out of the manager.
if !horizon_world::conditions::conditions_hold(&v, &|id: &str| {
mods.iter().any(|m| m == id)
}) {
continue;
}
if let Some(kind) = custom_type(&v) {
let id = if name.contains(':') {
name
@@ -1759,6 +1841,99 @@ mod tests {
}
}
/// A mod's recipe whose ingredient is one of NeoForge's standard shapes
/// loads and carries the resolved items, where the plain string parser
/// used to drop the file whole.
#[test]
fn a_neoforge_ingredient_recipe_loads() {
let root =
std::env::temp_dir().join(format!("horizon-neo-ingredient-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
fs::create_dir_all(root.join("data/mymod/recipe")).unwrap();
fs::write(
root.join(horizon_items::content_namespaces::MARKER),
br#"["mymod"]"#,
)
.unwrap();
let recipe = r##"{
"type": "minecraft:crafting_shapeless",
"ingredients": [
{"neoforge:ingredient_type": "neoforge:compound",
"children": ["minecraft:oak_planks", "minecraft:birch_planks"]},
{"type": "neoforge:difference",
"base": ["minecraft:oak_planks", "minecraft:birch_planks"],
"subtracted": "minecraft:birch_planks"}
],
"result": {"id": "minecraft:stick", "count": 4}
}"##;
fs::write(root.join("data/mymod/recipe/treated.json"), recipe).unwrap();
let items = Items::for_tests(HashMap::new());
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
assert_eq!(recipes.crafting.len(), 1);
assert_eq!(recipes.crafting[0].0, "mymod:treated");
let _ = fs::remove_dir_all(&root);
}
/// NeoForge's standard ingredient shapes resolve to their flat item
/// lists; a kind the loader doesn't know still fails the file.
#[test]
fn neoforge_ingredient_shapes_parse() {
let mut tags = HashMap::new();
tags.insert(
"minecraft:planks".to_owned(),
["minecraft:oak_planks", "minecraft:birch_planks"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>(),
);
let items = Items::for_tests(tags);
let json = |s: &str| serde_json::from_str::<Value>(s).unwrap();
// Compound (`CompoundIngredient`): any child matching is enough —
// the union of their items, a tag beside an item gives both.
let union = Ingredient::parse(
&json(r##"{"neoforge:ingredient_type":"neoforge:compound","children":["#minecraft:planks","minecraft:stone"]}"##),
&items,
)
.unwrap();
// The tag's items come from a HashSet: their order is not fixed.
let mut all = union.1.clone();
all.sort();
assert_eq!(
all,
[
"minecraft:birch_planks",
"minecraft:oak_planks",
"minecraft:stone"
]
);
// Intersection: every child must match — only the items all name.
let both = Ingredient::parse(
&json(r##"{"type":"neoforge:intersection","children":["#minecraft:planks","minecraft:oak_planks"]}"##),
&items,
)
.unwrap();
assert_eq!(both.1, ["minecraft:oak_planks"]);
// Difference: the base without the subtracted.
let no_oak = Ingredient::parse(
&json(r##"{"type":"neoforge:difference","base":"#minecraft:planks","subtracted":"minecraft:oak_planks"}"##),
&items,
)
.unwrap();
assert_eq!(no_oak.1, ["minecraft:birch_planks"]);
// An empty intersection and an unknown kind fail the file, as today.
let empty = Ingredient::parse(
&json(
r#"{"type":"neoforge:intersection","children":["minecraft:oak_planks","minecraft:birch_planks"]}"#,
),
&items,
);
assert!(empty.is_none());
let unknown =
Ingredient::parse(&json(r#"{"type":"mymod:aspect","aspect":"fire"}"#), &items);
assert!(unknown.is_none());
}
fn ingredient(items: &[&str]) -> Ingredient {
Ingredient::of(items.iter().map(|s| (*s).to_owned()).collect())
}
@@ -1928,6 +2103,66 @@ mod tests {
assert_eq!((&*made.item, made.count), ("minecraft:stick", 1));
let _ = fs::remove_dir_all(&root);
}
/// A recipe whose `neoforge:conditions` hold loads, one whose don't is
/// silently absent, from the root's own files and a pack's alike.
#[test]
fn load_conditions_gate_recipes() {
let root = std::env::temp_dir().join(format!("horizon-conditions-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
fs::write(
root.join(horizon_items::content_namespaces::MARKER),
br#"["mymod"]"#,
)
.unwrap();
fs::write(
root.join(horizon_items::content_namespaces::MOD_IDS_MARKER),
br#"["mymod"]"#,
)
.unwrap();
let file = |conditions: &str, tag: &str| {
format!(
r#"{{"type":"mymod:crusher","tag":"{tag}","neoforge:conditions":{conditions}}}"#
)
.into_bytes()
};
// `mymod` is in the root, `ic2` is not.
fs::write(
root.join("data/minecraft/recipe/yes.json"),
file(r#"[{"type":"neoforge:mod_loaded","modid":"mymod"}]"#, "yes"),
)
.unwrap();
fs::write(
root.join("data/minecraft/recipe/no.json"),
file(r#"[{"type":"neoforge:mod_loaded","modid":"ic2"}]"#, "no"),
)
.unwrap();
fs::write(
root.join("data/minecraft/recipe/inverted.json"),
file(
r#"[{"type":"neoforge:not","value":{"type":"neoforge:mod_loaded","modid":"ic2"}}]"#,
"inverted",
),
)
.unwrap();
let items = Items::for_tests(HashMap::new());
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
let ids = &recipes.custom["mymod:crusher"];
assert_eq!(
ids.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
["minecraft:inverted", "minecraft:yes"]
);
// A pack's gated file answers the same gate.
let packs = [(
"recipe/minecraft:pack_yes".to_owned(),
vec![file(r#"[{"type":"neoforge:true"}]"#, "pack_yes")],
)];
let recipes = Recipes::load(&root, &items, &PackOverlay { files: &packs }).unwrap();
let ids = &recipes.custom["mymod:crusher"];
assert!(ids.iter().any(|r| r.id == "minecraft:pack_yes"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn cook_finds_the_first_recipe_the_scan_finds() {
let recipes = cooking_book();
+131
View File
@@ -0,0 +1,131 @@
//! `DebugLevelSource`: the `minecraft:debug` generator of the Debug world preset,
//! a grid of every block state floating over a layer of barriers.
use crate::{
chunk::{Chunk, LevelShape},
streaming::ChunkGenerator,
};
use horizon_world::{nbt::Tag, registry::BlockRegistry};
/// `DebugLevelSource.HEIGHT`: the layer the block states stand in.
pub const HEIGHT: i32 = 70;
/// `DebugLevelSource.BARRIER_HEIGHT`.
pub const BARRIER_HEIGHT: i32 = 60;
/// The debug world's generator: every block state in registry order
/// (`ALL_BLOCKS`) on a square grid, one every other block, at `HEIGHT`.
pub struct DebugTerrain {
shape: LevelShape,
biome: u16,
barrier: u32,
/// `ALL_BLOCKS.size()`: block states are numbered in that order, so the
/// state at an index is the state with that id.
states: u32,
}
impl DebugTerrain {
/// The generator of a world whose overworld is `minecraft:debug`.
pub fn from_settings(
settings: &Tag,
registry: &BlockRegistry,
biomes: &crate::biome::Biomes,
shape: LevelShape,
) -> Option<Self> {
let generator = settings
.get("dimensions")?
.get("minecraft:overworld")?
.get("generator")?;
if crate::save::text(generator.get("type"))?.trim_start_matches("minecraft:") != "debug" {
return None;
}
Some(Self {
shape,
// `FixedBiomeSource(plains)`.
biome: biomes.id("minecraft:plains"),
barrier: registry.default_state("minecraft:barrier")?.0,
states: u32::try_from(registry.len()).ok()?,
})
}
/// `DebugLevelSource.getBlockStateFor`: the state id at a block column
/// (air, id 0, where the grid has none).
pub fn state_for(&self, x: i32, z: i32) -> u32 {
grid_state(self.states, x, z)
}
}
/// `DebugLevelSource.GRID_WIDTH` and `GRID_HEIGHT` for `count` block states:
/// `Mth.ceil(Mth.sqrt(size))` and `ceil(size / width)`.
fn grid(count: u32) -> (i32, i32) {
let width = (count as f32).sqrt().ceil() as i32;
let height = (count as f32 / width as f32).ceil() as i32;
(width, height)
}
/// `getBlockStateFor(worldX, worldZ)` as a state id.
fn grid_state(count: u32, mut x: i32, mut z: i32) -> u32 {
let (width, height) = grid(count);
if x > 0 && z > 0 && x % 2 != 0 && z % 2 != 0 {
x /= 2;
z /= 2;
if x <= width && z <= height {
let index = (x * width + z).unsigned_abs();
if index < count {
return index;
}
}
}
0
}
impl ChunkGenerator for DebugTerrain {
type Worker = ();
fn worker(&self) {}
/// `DebugLevelSource.buildTerrain` builds nothing; `applyBiomeDecoration`
/// writes the barriers and the grid.
fn proto(&self, _: &mut (), cx: i32, cz: i32) -> crate::streaming::Proto {
let mut chunk = Chunk::with_layout(cx, cz, self.shape.column());
for s in &mut chunk.sections {
std::sync::Arc::make_mut(s).biomes = horizon_core::paletted::PalettedContainer::new(
crate::chunk::BIOME_STRATEGY,
u32::from(self.biome),
);
}
for z in 0..16 {
for x in 0..16 {
chunk.set(x, BARRIER_HEIGHT, z, self.barrier);
let state = self.state_for(cx * 16 + x, cz * 16 + z);
if state != 0 {
chunk.set(x, HEIGHT, z, state);
}
}
}
for s in &mut chunk.sections {
std::sync::Arc::make_mut(s).compact();
}
Box::new(chunk)
}
/// `spawnOriginalMobs` is empty.
fn finish(&self, proto: crate::streaming::Proto) -> Chunk {
*proto.downcast::<Chunk>().expect("debug terrain proto")
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `getBlockStateFor`: only odd, positive columns hold a state, in the order
/// `x / 2 * width + z / 2`, and nothing past the last state.
#[test]
fn the_grid_holds_a_state_every_other_block() {
// 1000 states: a 32 wide grid, 32 high (ceil(1000 / 32) = 32).
assert_eq!(grid(1000), (32, 32));
assert_eq!(grid(1), (1, 1));
assert_eq!(grid(26_000), (162, 161));
// Even, zero and negative columns are air.
for (x, z) in [(2, 1), (1, 2), (0, 1), (1, 0), (-1, 1), (1, -1)] {
assert_eq!(grid_state(1000, x, z), 0, "{x} {z}");
}
// (1, 1) is index 0 (air), (3, 5) is 1 * 32 + 2 = 34.
assert_eq!(grid_state(1000, 1, 1), 0);
assert_eq!(grid_state(1000, 3, 5), 34);
// x = 2 * 32 + 1 maps to x / 2 = 32: index 32 * 32 + z / 2, past the 1000 states.
assert_eq!(grid_state(1000, 65, 1), 0);
assert_eq!(grid_state(1000, 61, 3), 30 * 32 + 1);
}
}
+143
View File
@@ -306,6 +306,37 @@ impl ItemEntity {
&mut self.velocity,
);
}
if !no_physics {
// Entity.applyEffectsFromBlocks: stepOn, then checkInsideBlocks.
if self.on_ground {
let p = BlockPos::containing(self.position.add_vector(Vec3::new(
0.0,
-f64::from(0.2_f32),
0.0,
)));
let state = world.block(p.x, p.y, p.z);
crate::block_contact::emit(crate::block_contact::Event::StepOn(
crate::block_contact::Contact {
entity: self.id as i32,
kind: "minecraft:item",
block: &facts.get(state).name,
state,
position: p,
},
));
}
crate::block_contact::swept_inside(
self.id as i32,
"minecraft:item",
self.previous,
self.position,
self.bounds(),
|p| {
let state = world.block(p.x, p.y, p.z);
(state, &facts.get(state).name)
},
);
}
if self.velocity.subtract(old_velocity).length_squared() > 0.01 {
self.needs_sync = true;
}
@@ -1018,9 +1049,15 @@ pub struct ArmorStand {
pub health: f32,
pub id: u32,
pub position: Vec3,
/// `Entity.xo`, `yo`, `zo`: the position at the start of the tick, which
/// the renderer blends toward `position` by the partial tick.
pub previous: Vec3,
pub velocity: Vec3,
pub on_ground: bool,
/// `yRot`, which the body follows (`ArmorStand.tickHeadTurn`: `yBodyRot`).
pub yaw: f32,
/// `yBodyRotO`: the body's yaw at the start of the tick.
pub previous_yaw: f32,
/// mainhand, offhand, feet, legs, chest, head (EquipmentSlot order).
pub equipment: [Option<crate::items::ItemStack>; 6],
pub small: bool,
@@ -1041,6 +1078,9 @@ pub struct ArmorStand {
/// rides, which seats it every tick (`positionRider`), as NPC plugins hang
/// a second name over a player's head.
pub vehicle: Option<u32>,
/// `ArmorStand.disabledSlots`: bit `filterFlag` locks a slot, bit
/// `filterFlag + 8` takes from it and bit `filterFlag + 16` puts into it.
pub disabled_slots: i32,
}
impl ArmorStand {
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
@@ -1056,15 +1096,50 @@ impl ArmorStand {
[-1.0, 0.0, -1.0],
[1.0, 0.0, 1.0],
];
/// Where the renderer draws the stand `alpha` of the way through the tick
/// (`EntityRenderer`'s `Mth.lerp(partialTick, xo, x)` and so on).
pub fn render_position(&self, alpha: f64) -> Vec3 {
self.previous.lerp(self.position, alpha)
}
/// The body's yaw `alpha` of the way through the tick
/// (`LivingEntityRenderer.solveBodyRot`: `Mth.rotLerp(partialTick,
/// yBodyRotO, yBodyRot)`).
pub fn render_yaw(&self, alpha: f32) -> f32 {
self.previous_yaw + alpha * crate::camera::wrap_degrees(self.yaw - self.previous_yaw)
}
/// A server's stand on its client, each tick:
/// `setOldPosAndRot` (`ClientLevel.tickNonPassenger`), then the step of
/// its `SteppedInterpolationHandler` (`step`: the position and yaw it is
/// put at, when one is due). The yaw
/// turns the short way and stays continuous, so a frame between ticks
/// never spins it.
pub fn interpolate(&mut self, step: Option<(Vec3, f32)>) {
self.previous = self.position;
self.previous_yaw = self.yaw;
if let Some((at, yaw)) = step {
self.position = at;
self.yaw += crate::camera::wrap_degrees(yaw - self.yaw);
}
}
/// Put there at once, with nothing to blend from
/// (`Entity.snapTo`).
pub fn snap(&mut self, at: Vec3, yaw: f32) {
self.position = at;
self.previous = at;
self.yaw = yaw;
self.previous_yaw = yaw;
}
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
Self {
fire_ticks: 0,
health: 20.0,
id,
position,
previous: position,
velocity: Vec3::ZERO,
on_ground: true,
yaw,
previous_yaw: yaw,
equipment: Default::default(),
small: false,
show_arms: false,
@@ -1076,8 +1151,29 @@ impl ArmorStand {
last_hit: i64::MIN / 2,
pose: Self::DEFAULT_POSE,
vehicle: None,
disabled_slots: 0,
}
}
/// `EquipmentSlot.getFilterFlag` of an `equipment` index (main hand, off
/// hand, feet, legs, chest, head).
fn filter_flag(slot: usize) -> u32 {
[0, 5, 1, 2, 3, 4][slot]
}
/// `ArmorStand.isDisabled`: the slot is locked, or it is a hand and the
/// stand has no arms.
pub fn is_disabled(&self, slot: usize) -> bool {
self.disabled_slots & (1 << Self::filter_flag(slot)) != 0 || (slot < 2 && !self.show_arms)
}
/// The `swapItem` locks: taking from a filled slot or putting into an
/// empty one can each be disabled.
pub fn swap_locked(&self, slot: usize) -> bool {
let offset = if self.equipment[slot].is_some() {
8
} else {
16
};
self.disabled_slots & (1 << (Self::filter_flag(slot) + offset)) != 0
}
pub fn environment_tick(
&mut self,
world: &World,
@@ -1367,6 +1463,53 @@ mod fire_environment_tests {
}
}
#[cfg(test)]
mod armor_stand_render_tests {
use super::*;
/// A falling stand is drawn between last tick's position and this one's,
/// so it slides down every frame instead of jumping once a tick (#4416).
#[test]
fn a_falling_stand_is_drawn_between_its_ticks() {
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 70.0, 0.5), 0.0);
// A new stand has no move behind it.
assert_eq!(stand.render_position(0.5), stand.position);
let fall = |s: &ArmorStand| Some((Vec3::new(0.5, s.position.y - 1.0, 0.5), 0.0));
stand.interpolate(fall(&stand));
assert_eq!(stand.render_position(0.0).y, 70.0);
assert_eq!(stand.render_position(0.25).y, 69.75);
assert_eq!(stand.render_position(1.0).y, 69.0);
stand.interpolate(fall(&stand));
assert_eq!(stand.render_position(0.5).y, 68.5);
// Landed: no step is due, so it stays put, and so does every frame.
stand.interpolate(None);
assert_eq!(stand.render_position(0.5).y, 68.0);
}
/// A turning stand's body turns between ticks too, the short way round.
#[test]
fn a_turning_stand_is_drawn_between_its_ticks() {
let mut stand = ArmorStand::new(1, Vec3::ZERO, 170.0);
assert_eq!(stand.render_yaw(0.5), 170.0);
// To -170 is 20 degrees on through 180, not 340 back through 0.
stand.interpolate(Some((Vec3::ZERO, -170.0)));
assert_eq!(stand.render_yaw(0.0), 170.0);
assert_eq!(stand.render_yaw(0.5), 180.0);
assert_eq!(stand.render_yaw(1.0), 190.0);
stand.interpolate(None);
assert_eq!(stand.render_yaw(0.5), 190.0);
}
/// A snap (a move too far to interpolate) leaves nothing to blend from.
#[test]
fn a_snapped_stand_is_drawn_where_it_landed() {
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 70.0, 0.5), 0.0);
stand.interpolate(Some((Vec3::new(0.5, 69.0, 0.5), 0.0)));
stand.snap(Vec3::new(200.5, 64.0, 0.5), 90.0);
assert_eq!(stand.render_position(0.5), Vec3::new(200.5, 64.0, 0.5));
assert_eq!(stand.render_yaw(0.5), 90.0);
}
}
#[cfg(test)]
mod merge_tests {
use super::*;
+302 -14
View File
@@ -511,6 +511,10 @@ pub fn mob_to_nbt(
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
if is_animal(id) {
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
// Animal.addAdditionalSaveData: the player who fed it.
if let Some(cause) = m.extra.love_cause {
map.insert("LoveCause".into(), Tag::IntArray(cause.to_vec()));
}
}
let species = m.species();
// MushroomCow saves its variant under `Type` (below), not `variant`.
@@ -538,7 +542,15 @@ pub fn mob_to_nbt(
"minecraft:fox" => {
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
map.insert("Type".into(), string(kind));
map.entry("Trusted".into()).or_insert(Tag::List(Vec::new()));
// The trusted UUIDs (`Fox.TRUSTED_LIST_CODEC`: a list of int arrays).
map.insert(
"Trusted".into(),
Tag::List(
crate::mobs::fox::trusted_entities(m)
.map(|uuid| Tag::IntArray(uuid.to_vec()))
.collect(),
),
);
for (key, bit) in [
("Sleeping", crate::mobs::fox::SLEEPING),
("Sitting", crate::mobs::fox::SITTING),
@@ -575,7 +587,10 @@ pub fn mob_to_nbt(
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
"minecraft:rabbit" => {
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
map.insert(
"MoreCarrotTicks".into(),
Tag::Int(m.extra.more_carrot_ticks),
);
}
"minecraft:goat" => {
map.insert(
@@ -1141,6 +1156,25 @@ pub fn mob_to_nbt(
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
// A worn piece or held item that kept its damage, name or
// components is saved with them.
if slot != "mainhand"
&& item.is_some()
&& let Some(stack) = crate::mobs::finalize::stored_stack(
m,
[
crate::mobs::finalize::MAINHAND,
crate::mobs::finalize::OFFHAND,
crate::mobs::finalize::HEAD,
crate::mobs::finalize::CHEST,
crate::mobs::finalize::LEGS,
crate::mobs::finalize::FEET,
][index],
)
{
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
// Enchanted or trimmed gear is saved with its components.
if let (Some(id), Some(g)) = (item, crate::mobs::gear::gear(m))
&& (!g.slots[index].is_empty() || g.trims[index].is_some())
@@ -1405,6 +1439,10 @@ pub fn mob_from_nbt(
crate::mobs::gear::set(&mut m, index, stack.enchantments().to_vec());
let trim = stack.data().and_then(|d| d.trim.clone());
crate::mobs::gear::set_trim(&mut m, index, trim);
// The damage, name and components stay with the slot.
if index != crate::mobs::gear::MAIN {
crate::mobs::gear::set_stack(&mut m, index, stack);
}
}
}
} else {
@@ -1463,6 +1501,8 @@ pub fn mob_from_nbt(
}
if is_animal(&m.kind.id) {
m.extra.in_love = number(tag.get("InLove")).unwrap_or(0) as i32;
// Animal.readAdditionalSaveData: the player who fed it into love.
m.extra.love_cause = crate::save::uuid_from_tag(tag.get("LoveCause"));
}
m.name_visible = flag(tag, "CustomNameVisible");
m.no_ai = flag(tag, "NoAI");
@@ -1592,6 +1632,17 @@ pub fn mob_from_nbt(
match m.species() {
crate::mobs::Species::Fox => {
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
// Fox.readAdditionalSaveData: clearTrusted, then addTrustedEntity for
// each of "Trusted".
crate::mobs::fox::clear_trusted(&mut m);
if let Some(Tag::List(trusted)) = tag.get("Trusted") {
for uuid in trusted
.iter()
.filter_map(|t| crate::save::uuid_from_tag(Some(t)))
{
crate::mobs::fox::add_trusted_entity(&mut m, uuid);
}
}
// Fox.readAdditionalSaveData: the sleeping, sitting and crouching flags.
for (key, bit) in [
("Sleeping", crate::mobs::fox::SLEEPING),
@@ -1633,6 +1684,10 @@ pub fn mob_from_nbt(
}
_ => {}
}
// `Rabbit.readAdditionalSaveData`.
if m.species() == crate::mobs::Species::Rabbit {
m.extra.more_carrot_ticks = number(tag.get("MoreCarrotTicks")).unwrap_or(0) as i32;
}
// `Ocelot.readAdditionalSaveData`.
if m.species() == crate::mobs::Species::Ocelot {
m.extra.trusting = flag(tag, "Trusting");
@@ -2155,6 +2210,7 @@ pub fn arrow_from_nbt(
p.velocity = motion(tag);
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.owner_uuid = owner;
let a = p.arrow.as_mut()?;
a.life = number(tag.get("life")).unwrap_or(0) as i16 as i32;
a.shake = (number(tag.get("shake")).unwrap_or(0) as i8 as u8).into();
@@ -2198,26 +2254,111 @@ pub fn fireball_to_nbt(
"acceleration_power",
Tag::Double(p.acceleration.to_bits()),
);
// Projectile: LeftOwner only when set; HasBeenShot once it has ticked
// (a fireball saved before its first tick keeps what it was loaded with).
if let Tag::Compound(map) = &mut tag {
map.remove(&"LeftOwner".into());
}
if p.left_owner {
put(&mut tag, "LeftOwner", Tag::Byte(1));
}
if p.age > 0 || tag.get("HasBeenShot").is_none() {
put(&mut tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
}
put_projectile(&mut tag, p, owner);
if let Some(power) = power {
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
}
Some(tag)
}
/// `Projectile.addAdditionalSaveData`: Owner, LeftOwner only when set, and
/// HasBeenShot once it has ticked (one saved before its first tick keeps
/// what it was loaded with).
fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
if let Tag::Compound(map) = tag {
map.remove(&"LeftOwner".into());
}
if p.left_owner {
put(tag, "LeftOwner", Tag::Byte(1));
}
if p.age > 0 || tag.get("HasBeenShot").is_none() {
put(tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
}
if let Some(owner) = owner {
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
put(tag, "Owner", Tag::IntArray(owner.to_vec()));
}
}
/// The thrown item projectiles Horizon saves: each entity type's id is also
/// the item it saves when none was loaded (`getDefaultItem`).
const THROWABLES: [(ProjectileKind, &str); 3] = [
(ProjectileKind::Snowball, "minecraft:snowball"),
(ProjectileKind::Egg, "minecraft:egg"),
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
];
fn throwable_item(kind: &ProjectileKind) -> Option<&'static str> {
THROWABLES
.iter()
.find(|(k, _)| k == kind)
.map(|&(_, id)| id)
}
/// Whether its chunk saves it: the kinds `arrow_to_nbt`, `fireball_to_nbt`
/// and [`throwable_to_nbt`] write.
pub fn saved_with_chunk(p: &Projectile) -> bool {
matches!(
p.kind,
ProjectileKind::LargeFireball { .. } | ProjectileKind::SmallFireball
) || (p.arrow.is_some() && matches!(p.kind, ProjectileKind::Arrow { .. }))
|| throwable_item(&p.kind).is_some()
}
/// `ThrowableItemProjectile.addAdditionalSaveData`: `Projectile`'s keys and
/// the `Item` it flies as.
pub fn throwable_to_nbt(
p: &Projectile,
uuid: [i32; 4],
owner: Option<[i32; 4]>,
raw: Option<&Tag>,
) -> Option<Tag> {
let item = throwable_item(&p.kind)?;
let base = Base {
id: p.kind.id(),
uuid,
position: p.position,
motion: p.velocity,
rotation: [p.yaw, p.pitch],
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
put_projectile(&mut tag, p, owner);
// Entity.saveWithoutId writes NoGravity only when set.
if p.no_gravity {
put(&mut tag, "NoGravity", Tag::Byte(1));
}
// the loaded item stays (a blue egg's, a renamed one's).
if tag.get("Item").is_none() {
put(
&mut tag,
"Item",
item_to_nbt(&crate::items::ItemStack::new(item, 1)),
);
}
Some(tag)
}
/// `ThrowableItemProjectile.readAdditionalSaveData` for the kinds
/// [`throwable_to_nbt`] writes; the owner is the player when it is theirs.
pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let id = kind(tag)?;
let (kind, _) = THROWABLES.iter().find(|(_, i)| *i == id)?.clone();
let owner = owner_uuid(tag);
let from_player = player.is_some() && owner == player;
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
p.velocity = motion(tag);
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.owner_uuid = owner;
p.no_gravity = flag(tag, "NoGravity");
Some(p)
}
/// `Projectile`'s `Owner`.
pub fn owner_uuid(tag: &Tag) -> Option<[i32; 4]> {
uuid_from_tag(tag.get("Owner"))
}
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let kind = match kind(tag)?.as_str() {
"minecraft:fireball" => ProjectileKind::LargeFireball {
@@ -2234,6 +2375,7 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
p.velocity = motion(tag);
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.owner_uuid = owner;
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
Some(p)
}
@@ -2498,6 +2640,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
} else {
put(&mut tag, "equipment", Tag::compound(equipment));
}
put(&mut tag, "DisabledSlots", Tag::Int(a.disabled_slots));
for (key, value) in [
("Small", a.small),
("ShowArms", a.show_arms),
@@ -2572,6 +2715,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
}
a.small = flag(tag, "Small");
a.show_arms = flag(tag, "ShowArms");
a.disabled_slots = number(tag.get("DisabledSlots")).unwrap_or(0) as i32;
a.no_base_plate = flag(tag, "NoBasePlate");
a.invisible = flag(tag, "Invisible");
a.marker = flag(tag, "Marker");
@@ -3519,6 +3663,34 @@ mod sheep_nbt_tests {
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
/// `Rabbit.addAdditionalSaveData` and `readAdditionalSaveData`: `MoreCarrotTicks`.
#[test]
fn a_rabbit_keeps_how_full_of_carrots_it_is() {
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 types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let mut m = Mob::new(
1,
Arc::new(data.types["minecraft:rabbit"].clone()),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
m.extra.more_carrot_ticks = 33;
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
assert_eq!(number(tag.get("MoreCarrotTicks")), Some(33));
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert_eq!(back.extra.more_carrot_ticks, 33);
}
/// `Ocelot.addAdditionalSaveData` and `readAdditionalSaveData`: `Trusting`.
#[test]
fn an_ocelot_keeps_its_trust() {
@@ -4084,6 +4256,85 @@ mod fireball_nbt_tests {
assert_eq!(fireball_from_nbt(&tag, None).unwrap().kind, kind);
}
}
#[test]
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
let player = Some([5, 6, 7, 8]);
for (kind, id) in [
(ProjectileKind::Snowball, "minecraft:snowball"),
(ProjectileKind::Egg, "minecraft:egg"),
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
] {
let mut p =
Projectile::new(kind.clone(), Vec3::new(1.5, 70.0, -2.5), Some(Who::Player));
(p.velocity, p.left_owner, p.age) = (Vec3::new(0.3, -0.1, 1.2), true, 4);
[p.yaw, p.pitch] = [30.0, -10.0];
let tag = throwable_to_nbt(&p, [1, 2, 3, 4], player, None).unwrap();
assert_eq!(super::kind(&tag).as_deref(), Some(id));
// ThrowableItemProjectile: the item it flies as, by its default.
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!((&*item.item, item.count), (id, 1));
assert_eq!(number(tag.get("HasBeenShot")), Some(1));
assert_eq!(uuid_from_tag(tag.get("Owner")), player);
let back = throwable_from_nbt(&tag, player).unwrap();
assert_eq!((back.kind, back.owner), (kind, Some(Who::Player)));
assert_eq!((back.position, back.velocity), (p.position, p.velocity));
assert_eq!(
([back.yaw, back.pitch], back.left_owner),
([30.0, -10.0], true)
);
// an owner who is not here keeps their UUID through a moved resave.
let mut away = throwable_from_nbt(&tag, Some([9; 4])).unwrap();
assert_eq!((away.owner, away.owner_uuid), (None, player));
away.position.y += 3.0;
let resaved = throwable_to_nbt(&away, [1; 4], away.owner_uuid, None).unwrap();
assert_eq!(uuid_from_tag(resaved.get("Owner")), player);
}
// the chunk saves arrows, fireballs and these, and so takes them with it.
let kinds = [
(ProjectileKind::SmallFireball, true),
(ProjectileKind::Snowball, true),
(ProjectileKind::WindCharge, false),
(ProjectileKind::Trident, false),
];
for (kind, saved) in kinds {
let p = Projectile::new(kind, Vec3::ZERO, None);
assert_eq!(saved_with_chunk(&p), saved, "{:?}", p.kind);
}
// a loaded item stays, and a new projectile leaves LeftOwner out.
let raw = Tag::compound([
("id", Tag::String("minecraft:egg".into())),
("Pos", doubles(&[0.0, 64.0, 0.0])),
(
"Item",
item_to_nbt(&crate::items::ItemStack::new("minecraft:blue_egg", 1)),
),
]);
let egg = throwable_from_nbt(&raw, None).unwrap();
let tag = throwable_to_nbt(&egg, [1; 4], None, Some(&raw)).unwrap();
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!(&*item.item, "minecraft:blue_egg");
assert!(tag.get("LeftOwner").is_none() && tag.get("Owner").is_none());
assert_eq!(number(tag.get("HasBeenShot")), Some(0));
let wind = Projectile::new(ProjectileKind::WindCharge, Vec3::ZERO, None);
assert!(throwable_to_nbt(&wind, [1; 4], None, None).is_none());
// NoGravity is loaded and saved again (a summoned pearl that floats).
let floating = Tag::compound([
("id", Tag::String("minecraft:ender_pearl".into())),
("Pos", doubles(&[0.0, 64.0, 0.0])),
("NoGravity", Tag::Byte(1)),
]);
let pearl = throwable_from_nbt(&floating, None).unwrap();
assert!(pearl.no_gravity);
let tag = throwable_to_nbt(&pearl, [1; 4], None, None).unwrap();
assert_eq!(number(tag.get("NoGravity")), Some(1));
assert!(
throwable_to_nbt(&egg, [1; 4], None, None)
.unwrap()
.get("NoGravity")
.is_none()
);
}
}
#[cfg(test)]
@@ -4136,6 +4387,43 @@ mod neutral_anger_tests {
mod held_item_name_tests {
use super::*;
#[test]
fn a_worn_piece_keeps_its_damage_and_name_through_the_save() {
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 types: HashMap<_, _> = data
.types
.into_iter()
.map(|(id, kind)| (id, Arc::new(kind)))
.collect();
let mut mob = Mob::new(1, Arc::clone(&types["minecraft:zombie"]), Vec3::ZERO, 0.0);
let mut helmet = crate::items::ItemStack::new("minecraft:iron_helmet", 1);
helmet.damage = 40;
helmet.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Hat".into()));
crate::mobs::finalize::set_item_stack(&mut mob, crate::mobs::finalize::HEAD, helmet);
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None);
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
let worn = crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
assert_eq!(worn.damage, 40);
assert_eq!(worn.custom_name().as_deref(), Some("Hat"));
// Replacing the item forgets the old stack.
let mut loaded = loaded;
crate::mobs::finalize::set_item(
&mut loaded,
crate::mobs::finalize::HEAD,
"minecraft:iron_helmet",
);
let fresh =
crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
assert_eq!(fresh.damage, 0);
assert!(fresh.custom_name().is_none());
}
#[test]
fn replacing_a_named_item_does_not_restore_its_old_name_on_save() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
File diff suppressed because it is too large. Load diff
@@ -457,6 +457,8 @@ impl Game<'_> {
EntityView {
kind: "minecraft:player".into(),
position: [p.position.x, p.position.y, p.position.z],
// Entity.isOnFire on a server: remainingFireTicks (players aren't fire immune).
on_fire: p.fire_ticks > 0,
sneaking: p.pose == horizon_client::pose::Pose::Crouching,
// Input.shift: the held key, as `BlockItem.place`'s sneaking reads it.
input_sneak: self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft),
+48 -9
View File
@@ -522,7 +522,9 @@ impl Game<'_> {
let slot = match &held {
Some(h) => {
let slot = self.equipment_slot(h);
if slot < 2 && !a.show_arms {
// ArmorStand.interactAt: a disabled slot, or a hand without
// arms, is FAIL.
if a.is_disabled(slot) {
return true;
}
slot
@@ -550,6 +552,18 @@ impl Game<'_> {
}
}
};
// An empty hand takes from the main hand when the clicked slot is
// disabled (`isDisabled(clickedSlot) ? slot : clickedSlot`).
let slot = if held.is_none() && a.is_disabled(slot) {
0
} else {
slot
};
// ArmorStand.swapItem: taking from a filled slot or putting into an
// empty one can be disabled; the click then passes.
if a.swap_locked(slot) {
return false;
}
let current = self.armor_stands[i].equipment[slot].clone();
if held.is_none() && current.is_none() {
return false;
@@ -722,6 +736,8 @@ impl Game<'_> {
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
for (i, a) in self.armor_stands.iter_mut().enumerate() {
a.previous = a.position;
a.previous_yaw = a.yaw;
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
continue;
}
@@ -785,11 +801,10 @@ impl Game<'_> {
let dragon = a.equipment[5]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:dragon_head");
let position = super::entity_culling::at(a.position);
if !self.standing_entity_in_view(
camera,
position,
position,
super::entity_culling::at(a.previous),
super::entity_culling::at(a.position),
alpha,
width,
height,
@@ -798,7 +813,9 @@ impl Game<'_> {
) {
continue;
}
let p = glam::DVec3::new(a.position.x, a.position.y, a.position.z) - camera;
// EntityRenderer: drawn between last tick's position and this one's.
let at = a.render_position(alpha);
let p = super::entity_culling::at(at) - camera;
// setupRotations: 180 - body yaw, plus the wobble for 5 ticks after a hit.
let wiggle = (self.world.game_time - a.last_hit) as f32 + alpha as f32;
let wobble = if wiggle < 5.0 {
@@ -807,13 +824,15 @@ impl Game<'_> {
0.0
};
let base = glam::Mat4::from_translation(p.as_vec3())
* glam::Mat4::from_rotation_y((180.0 - a.yaw + wobble).to_radians())
* glam::Mat4::from_rotation_y(
(180.0 - a.render_yaw(alpha as f32) + wobble).to_radians(),
)
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
let light = self.world.light(
a.position.x.floor() as i32,
(a.position.y + 0.5).floor() as i32,
a.position.z.floor() as i32,
at.x.floor() as i32,
(at.y + 0.5).floor() as i32,
at.z.floor() as i32,
);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
self.armor_stand_vertices(a, base, packed, out);
@@ -1322,6 +1341,26 @@ mod swap_tests {
ItemStack::new(item, count)
}
#[test]
fn disabled_slots_lock_by_filter_flag() {
let mut a = ArmorStand::new(1, horizon_core::geometry::Vec3::ZERO, 0.0);
// Hands need arms.
assert!(a.is_disabled(0) && a.is_disabled(1));
a.show_arms = true;
assert!(!a.is_disabled(0) && !a.is_disabled(1));
// Bit 4 locks the head (equipment index 5), bit 5 the off hand (1).
a.disabled_slots = (1 << 4) | (1 << 5);
assert!(a.is_disabled(5) && a.is_disabled(1));
assert!(!a.is_disabled(4) && !a.is_disabled(0));
// Taking (+8) from a filled slot and putting (+16) into an empty one.
a.disabled_slots = (1 << (3 + 8)) | (1 << (1 + 16));
assert!(!a.swap_locked(4), "an empty chest slot is not take-locked");
a.equipment[4] = Some(stack("minecraft:iron_chestplate", 1));
assert!(a.swap_locked(4));
assert!(a.swap_locked(2), "an empty feet slot is put-locked");
a.equipment[2] = Some(stack("minecraft:iron_boots", 1));
assert!(!a.swap_locked(2));
}
#[test]
fn creative_swap_gives_the_worn_piece_to_the_hand() {
let swap = swap_with_equipment_slot(
&stack("minecraft:diamond_chestplate", 1),
@@ -778,6 +778,15 @@ impl Game<'_> {
return None;
};
let skin = skull.skin.as_ref()?;
// PlayerSkin.Patch: a `texture` override replaces the profile's skin.
if let Some(texture) = &skin.texture
&& let Some(sprite) =
skins::patch_skin(texture, &mut self.skin_downloads.borrow_mut(), |location| {
self.gui.atlas.sprites.contains_key(location)
})
{
return Some(sprite);
}
// The texture a packed or resolved `textures` property fetches: the
// download of a static profile, or the one the services answer for a
// `Dynamic` (name- or id-only) profile.
+4 -1
View File
@@ -28,7 +28,10 @@ fn parse_state(text: &str) -> Result<BlockState> {
let (k, v) = pair.split_once('=').ok_or("properties are key=value")?;
properties.insert(k.to_owned(), v.to_owned());
}
Ok(BlockState { name, properties })
Ok(BlockState {
name,
properties: properties.into(),
})
}
impl Game<'_> {
+2
View File
@@ -1308,6 +1308,8 @@ impl Game<'_> {
let destroyed = creative || b.damage > 40.0;
if destroyed {
self.eject_boat_mobs(i, true);
let id = self.boats[i].id;
self.release_vehicle_container(id);
let b = self.boats.remove(i);
let at = [b.position.x, b.position.y, b.position.z];
// AbstractBoat.remove: a destroyed boat drops its lead.
+1
View File
@@ -1432,6 +1432,7 @@ impl Game<'_> {
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.clone(),
size: None,
scaled: true,
});
self.ui.screenshot_message = Some(path);
}
+59 -12
View File
@@ -80,6 +80,9 @@ pub(super) struct CombatTracker {
pending: Vec<(String, (u32, Text), Option<Text>)>,
/// `pending` for a direct entity with no causing entity: only its name.
pending_direct: Vec<(String, Text)>,
/// The lengths of `pending` and `pending_direct` before the first
/// attacker noted for the hurt being dealt now (`forget_noted`).
noted_since: Option<(usize, usize)>,
/// `LivingEntity.lastHurtByMob` and its timestamp (the kill credit).
last_hurt_by: Option<((u32, Text), i64)>,
/// The mob the player last hurt (`getLastHurtMob`) and when.
@@ -145,6 +148,23 @@ pub(super) struct CombatTracker {
/// end of the last one).
fall_before: f64,
}
impl CombatTracker {
fn mark(&mut self) {
let marks = (self.pending.len(), self.pending_direct.len());
self.noted_since.get_or_insert(marks);
}
/// The player's hurt is over: when a native mod canceled it, the
/// attackers noted for it go, as no combat entry takes them and the next
/// hit of the same damage type must not be credited to them.
pub(super) fn forget_noted(&mut self, canceled: bool) {
if let Some((attackers, directs)) = self.noted_since.take()
&& canceled
{
self.pending.truncate(attackers);
self.pending_direct.truncate(directs);
}
}
}
impl Game<'_> {
/// `LivingEntity.makeDrownParticles`: eight bubbles around the entity,
/// moving with it.
@@ -197,18 +217,6 @@ impl Game<'_> {
self.note_named_attacker(damage_type, mob, name);
}
}
/// How many attackers wait for the damage they caused, for
/// [`Game::drop_pending_since`].
pub(super) fn pending_marks(&self) -> (usize, usize) {
(self.combat.pending.len(), self.combat.pending_direct.len())
}
/// Forgets the attackers noted since `marks`: their hit didn't land (a
/// native mod canceled it), so no combat entry takes them, and the next
/// hit of the same damage type must not be credited to them.
pub(super) fn drop_pending_since(&mut self, marks: (usize, usize)) {
self.combat.pending.truncate(marks.0);
self.combat.pending_direct.truncate(marks.1);
}
/// `note_attacker` for an entity that is not one of `self.mobs` (the
/// ender dragon).
pub(super) fn note_named_attacker(&mut self, damage_type: &str, id: u32, name: Text) {
@@ -223,6 +231,7 @@ impl Game<'_> {
name: Text,
item: Option<Text>,
) {
self.combat.mark();
self.combat
.pending
.push((damage_type.to_owned(), (id, name), item));
@@ -231,6 +240,7 @@ impl Game<'_> {
/// nothing caused it (a splash potion nobody threw): the death message
/// names it instead (`getLocalizedDeathMessage`). It is no kill credit.
pub(super) fn note_direct_entity(&mut self, damage_type: &str, name: Text) {
self.combat.mark();
self.combat
.pending_direct
.push((damage_type.to_owned(), name));
@@ -314,6 +324,7 @@ impl Game<'_> {
}
self.combat.pending.clear();
self.combat.pending_direct.clear();
self.combat.noted_since = None;
self.combat.projectile_direct = None;
self.combat.projectile_attacker = None;
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
@@ -741,7 +752,43 @@ mod held_item_tests {
assert!(!has_custom_name(&gone));
}
}
#[cfg(test)]
mod noted_tests {
use super::CombatTracker;
use horizon_client::text::Text;
/// A canceled hit forgets what was noted for it, also before its hurt
/// started (a PvP blow, `/damage`), and nothing a landed hit noted.
#[test]
fn a_canceled_hit_forgets_only_its_own_attackers() {
let mut c = CombatTracker::default();
let arrow = |c: &mut CombatTracker, id| {
c.mark();
c.pending.push((
"minecraft:arrow".into(),
(id, Text::literal("skeleton")),
None,
));
};
arrow(&mut c, 1);
c.forget_noted(false);
arrow(&mut c, 2);
c.mark();
c.pending_direct
.push(("minecraft:arrow".into(), Text::literal("arrow")));
c.forget_noted(true);
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1]);
assert!(c.pending_direct.is_empty());
// A hit noted and landed, then one noted and canceled: the landed
// one keeps its attacker.
c.forget_noted(false);
arrow(&mut c, 3);
c.forget_noted(false);
arrow(&mut c, 4);
c.forget_noted(true);
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1, 3]);
}
}
#[cfg(test)]
mod stuck_tests {
use super::Stuck;
+31 -16
View File
@@ -3,6 +3,7 @@
mod admin;
pub(super) mod compute;
mod data;
pub(super) use data::place_summoned;
mod effects;
pub(super) mod execute;
pub(super) mod extra;
@@ -1227,21 +1228,11 @@ impl Game<'_> {
static RARITY: std::sync::OnceLock<std::collections::HashMap<String, String>> =
std::sync::OnceLock::new();
let map = RARITY.get_or_init(|| {
let v: serde_json::Value = serde_json::from_slice(
&std::fs::read(self.options.resources.join("items.json")).unwrap_or_default(),
)
.unwrap_or_default();
v.as_object()
.map(|o| {
o.iter()
.filter_map(|(k, c)| {
c["minecraft:rarity"]
.as_str()
.map(|r| (k.clone(), r.to_owned()))
})
.collect()
})
horizon_world::item_defaults::read(&self.options.resources)
.unwrap_or_default()
.into_iter()
.filter_map(|(k, c)| c["minecraft:rarity"].as_str().map(|r| (k, r.to_owned())))
.collect()
});
map.get(item).cloned().unwrap_or_else(|| "common".into())
}
@@ -1951,11 +1942,15 @@ impl Game<'_> {
Target::Boat(id) => {
// AbstractBoat.remove: a killed boat drops its lead.
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
self.release_vehicle_container(id);
let b = self.boats.remove(i);
self.boat_removed(&b);
}
}
Target::Cart(id) => self.carts.retain(|c| c.id != id),
Target::Cart(id) => {
self.release_vehicle_container(id);
self.carts.retain(|c| c.id != id);
}
Target::Other(id) => self.others.retain(|o| o.id != id),
Target::Crystal(id) => self.kill_crystal(id),
Target::Dragon(id) => self.kill_dragon(id),
@@ -2106,11 +2101,19 @@ impl Game<'_> {
let cart_kind = super::minecarts::CartKind::from_id(&kind)
.unwrap_or(super::minecarts::CartKind::Rideable);
self.next_entity += 1;
let cart = super::minecarts::Minecart::new(
let mut cart = super::minecarts::Minecart::new(
self.next_entity,
cart_kind,
Vec3::new(pos[0], pos[1], pos[2]),
);
// SummonCommand.createEntity loads even an empty compound, so a
// spawner minecart's BaseSpawner.load runs: SpawnPotentials becomes
// WeightedList.of(new SpawnData()).
if cart.spawner.is_some() {
use horizon_client::mobs::spawner::{Spawner, SpawnerExt};
let empty = horizon_world::nbt::Tag::Compound(Default::default());
cart.spawner = Some(Box::new(Spawner::load(&empty)));
}
self.carts.push(cart);
name = Text::translate(&format!("entity.minecraft.{k}"), &[]);
}
@@ -2236,6 +2239,10 @@ impl Game<'_> {
return Ok(0);
};
let state = self.block_state(&b)?;
// SetBlockCommand.setBlock: `ServerLevel.isDebug` fails it.
if self.world_debug {
return Err(err("commands.setblock.failed", &[]));
}
let current = self.world.block(pos[0], pos[1], pos[2]);
if ctx.has("keep") && current != self.world.air {
return Err(err("commands.setblock.failed", &[]));
@@ -2438,6 +2445,10 @@ impl Game<'_> {
if area > i64::from(limit) {
return Err(err("commands.fill.toobig", &[&limit, &area]));
}
// FillCommand.fillBlocks: `ServerLevel.isDebug` fails it.
if self.world_debug {
return Err(err("commands.fill.failed", &[]));
}
let state = self.block_state(&block)?;
let air = self.world.air;
let (hollow, outline, destroy, strict, keep) = (
@@ -2518,6 +2529,10 @@ impl Game<'_> {
return Err(err("argument.pos.unloaded", &[]));
}
}
// CloneCommands.clone: the destination level being a Debug world fails it.
if self.world_debug {
return Err(err("commands.clone.failed", &[]));
}
let filter = match ctx.get("filter") {
Some(Arg::Block(f)) => Some(f.clone()),
_ => None,
+31 -30
View File
@@ -28,6 +28,36 @@ fn set_key(tag: &mut Tag, key: &str, value: Tag) {
c.insert(JavaString::from(key), value);
}
}
/// `EntityType.loadEntityRecursive` snaps every entity of the stack,
/// passengers included, to the position (`/summon`, `native_summon`).
pub(in crate::game) fn place_summoned(tag: &mut Tag, pos: [f64; 3]) {
let p = pos.map(|v| Tag::Double(v.to_bits()));
set_key(tag, "Pos", Tag::List(p.to_vec()));
// Entity.load: an absent Fire reads as 0 (until the entity moves).
if tag.get("Fire").is_none() {
set_key(tag, "Fire", Tag::Short(0));
}
if tag.get("Rotation").is_none() {
set_key(
tag,
"Rotation",
Tag::List(vec![
Tag::Float(0.0f32.to_bits()),
Tag::Float(0.0f32.to_bits()),
]),
);
}
if let Tag::Compound(c) = tag
&& let Some(Tag::List(riders)) = c.get_mut(&"Passengers".into())
{
for r in riders {
if matches!(r, Tag::Compound(_)) {
place_summoned(r, pos);
}
}
}
}
impl Game<'_> {
// ---- entity data ---------------------------------------------------
@@ -762,36 +792,7 @@ impl Game<'_> {
return Err(err("argument.nbt.expected.compound", &[]));
}
set_key(&mut tag, "id", Tag::String(kind.into()));
// EntityType.loadEntityRecursive snaps every entity of the stack,
// passengers included, to the position.
fn place(tag: &mut Tag, pos: [f64; 3]) {
let p = pos.map(|v| Tag::Double(v.to_bits()));
set_key(tag, "Pos", Tag::List(p.to_vec()));
// Entity.load: an absent Fire reads as 0 (until the entity moves).
if tag.get("Fire").is_none() {
set_key(tag, "Fire", Tag::Short(0));
}
if tag.get("Rotation").is_none() {
set_key(
tag,
"Rotation",
Tag::List(vec![
Tag::Float(0.0f32.to_bits()),
Tag::Float(0.0f32.to_bits()),
]),
);
}
if let Tag::Compound(c) = tag
&& let Some(Tag::List(riders)) = c.get_mut(&"Passengers".into())
{
for r in riders {
if matches!(r, Tag::Compound(_)) {
place(r, pos);
}
}
}
}
place(&mut tag, pos);
place_summoned(&mut tag, pos);
let from = self.mobs.len();
let canceled = self.persist.joins_canceled;
let target = self.spawn_entity_from_nbt(tag);
@@ -186,10 +186,13 @@ impl Game<'_> {
_ => Some(None),
};
self.for_player(target, |g| {
if g.spectator() {
return false;
}
if let Some(name) = named {
g.note_direct_entity(&ty, name);
}
!g.spectator() && g.hurt_player_from(amount, from, &ty, attacker)
g.hurt_player_from(amount, from, &ty, attacker)
})
.unwrap_or(false)
}
+10
View File
@@ -644,6 +644,7 @@ impl Game<'_> {
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.trim().into(),
size: None,
scaled: false,
});
self.pending_screenshot = Some(reply.clone());
Ok(None)
@@ -895,6 +896,15 @@ impl Game<'_> {
);
ok()
}
// A splitscreen player leaves splitscreen; only P1's game ends the process.
"quit" if self.options.split_guest => {
if self.remote.is_some() {
self.leave_server(None)?;
} else if let Some(title) = self.title.as_mut() {
title.escape();
}
ok()
}
"quit" => {
self.begin_closing_save(true)?;
ok()
+146 -28
View File
@@ -89,6 +89,12 @@ pub(super) struct ProjWorld<'a, 'w> {
pub targets: Vec<(Who, Aabb)>,
}
impl ProjectileWorld for ProjWorld<'_, '_> {
fn block_contacts(&self, entity: i32, kind: &str, from: Vec3, to: Vec3, bounds: Aabb) {
horizon_client::block_contact::swept_inside(entity, kind, from, to, bounds, |p| {
let state = self.w.world.block(p.x, p.y, p.z);
(state, &self.w.facts.get(state).name)
});
}
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
let mut scene = SceneCollisions {
world: self.w.world,
@@ -1060,7 +1066,14 @@ impl Game<'_> {
bindings.retain(|id, _| live.contains(id));
host.mob_players = bindings;
}
self.mobs.append(&mut mobs);
// The mobs added during the tick (into the emptied `self.mobs`) come
// first, as `append` put them; with none, the ticked list goes back
// as it is instead of being copied into a new one, every mob a tick.
if self.mobs.is_empty() {
self.mobs = mobs;
} else {
self.mobs.append(&mut mobs);
}
self.died = died;
// ... and entity event 60 goes out as it is: the poof.
for (id, at, width, height) in gone {
@@ -1201,7 +1214,13 @@ impl Game<'_> {
self.villager_murdered(m, slain_by_player, killer.flatten());
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
let killer_entity = self.killer_loot_entity(killer.flatten());
self.drop_death_loot(&mut dying, by_player, killer_entity, consumed, pos);
// LAST_DAMAGE_PLAYER only while the player-hurt memory lasts
// (LivingEntity.dropFromLootTable's playerKilled): a mob that dies to
// the environment long after a player's hit names no player.
let player = killer
.filter(|_| m.ai.last_hurt_by_player > 0)
.and_then(|conn| self.killer_uuid(conn));
self.drop_death_loot(&mut dying, player, by_player, killer_entity, consumed, pos);
self.create_wither_rose(m);
if m.species() == Species::CopperGolem {
// CopperGolem.dropEquipment: the preserved equipment too.
@@ -1318,9 +1337,26 @@ impl Game<'_> {
}
own(self)
}
/// The UUID of the player `last_hurt_by_player` keeps: the guest on
/// connection `conn` (None once they left), else the player in place
/// (None on a server, which has none of its own).
fn killer_uuid(&mut self, conn: Option<u32>) -> Option<u128> {
match conn {
// During that guest's own turn, the hub is out of `lan`.
Some(conn) if self.lan_acting != Some(conn) => {
Some(self.lan.as_ref()?.hub.guest(conn)?.uuid)
}
Some(_) => Some(self.native_acting_id()),
None if self.lan_acting.is_none() && self.player.is_some() => {
Some(self.native_acting_id())
}
None => None,
}
}
fn drop_death_loot(
&mut self,
m: &mut Mob,
player_uuid: Option<u128>,
by_player: bool,
killer: horizon_world::loot::LootEntity,
consumed: bool,
@@ -1403,22 +1439,48 @@ impl Game<'_> {
}
}
let raider = horizon_client::mobs::raider::is_raider(m.species());
// The item drops come before the carried block and the orbs, so the
// mods get them all at once and they still spawn in that order.
let mut stacks = Vec::new();
let mut rest = Vec::new();
for d in items {
match d {
drops::Drop::Item(stack) => {
let item = if raider && stack.item == horizon_client::mobs::raider::BANNER {
drops::Drop::Item(stack) => stacks.push(
if raider && stack.item == horizon_client::mobs::raider::BANNER {
horizon_client::mobs::raider::ominous_banner()
} else {
from_loot(&stack)
};
// Every death drop is its own ItemEntity, and the drop
// entity's own random (not the level's) gives it the
// constructor's horizontal push (its motion otherwise
// stays [0, 0.2, 0] and the drops land stacked at the
// mob's centre, without disturbing the level random that
// the orbs' merge ids and block ticks read next).
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
}
},
),
drops::Drop::Stack(stack) => stacks.push(stack),
other => rest.push(other),
}
}
let kind = super::native_hooks::mob_kind(m);
let table = drops::death_loot_table(m, death.mob_drops).filter(|t| loot.has_table(t));
let context = super::native_hooks::NativeLootDrops {
table: table.as_deref().unwrap_or_default(),
dimension: self.dim.id(),
origin: [m.position.x, m.position.y, m.position.z],
block: None,
entity: Some((m.id, &kind)),
player: player_uuid,
tool: None,
explosion: None,
};
self.native_loot_drops(&context, &mut stacks);
stacks.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
for item in stacks {
// Every death drop is its own ItemEntity, and the drop entity's
// own random (not the level's) gives it the constructor's
// horizontal push (its motion otherwise stays [0, 0.2, 0] and
// the drops land stacked at the mob's centre, without disturbing
// the level random that the orbs' merge ids and block ticks read
// next).
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
}
for d in rest {
match d {
drops::Drop::CarriedBlock(state) => {
// Enderman.dropCustomDeathLoot: spawnAtLocation for each
// of the carried block's silk-touch drops.
@@ -1434,7 +1496,7 @@ impl Game<'_> {
}
}
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
drops::Drop::Orb(_) => {}
drops::Drop::Item(_) | drops::Drop::Stack(_) | drops::Drop::Orb(_) => {}
}
}
}
@@ -1599,6 +1661,15 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
}
// Projectile.setOwner: the acting player's or the mob's UUID, which
// its save keeps even once the owner is gone.
if p.owner_uuid.is_none() {
p.owner_uuid = match p.owner {
Some(Who::Player) => Some(self.acting_player_uuid()),
Some(Who::Mob(id)) => Some(self.uuid_for(id)),
None => None,
};
}
self.creatures.projectiles.push(p);
}
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
@@ -2081,6 +2152,27 @@ impl Game<'_> {
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
// above trampled farmland breaks and drops.
MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?,
MobEvent::CropRaided {
pos,
state,
old,
mob,
} => {
self.level_set_with_flags(
pos,
state,
horizon_world::level::flags::UPDATE_CLIENTS,
)?;
if let Some(old) = old {
self.post_block_game_event(
"minecraft:block_change",
pos,
Some(Who::Mob(mob)),
None,
);
self.level_event_at(2001, pos, old.0 as i32);
}
}
MobEvent::CrackTurtleEgg { pos } => {
let state = self.world.block(pos[0], pos[1], pos[2]);
let eggs = self
@@ -2218,13 +2310,14 @@ impl Game<'_> {
self.destroy_particles(pos, old);
}
if drop {
let drops = world_items!(self).block_drops_in(
let mut drops = world_items!(self).block_drops_in(
self.pack.assets.registry,
old,
None,
&mut self.world.random,
Some((&mut self.rand_sequences, self.level.seed)),
);
self.native_block_drops(pos, old, None, None, None, &mut drops);
for d in drops {
self.pop_resource(pos, d);
}
@@ -2587,7 +2680,7 @@ impl Game<'_> {
&mut self,
feet: [f64; 3],
conn: Option<u32>,
bindings: &std::collections::HashMap<u32, u32>,
bindings: &horizon_client::fast_hash::FxHashMap<u32, u32>,
) {
// A client of someone else's world has the server's mobs only to show.
if self.remote.is_some() || !self.ui.rules.bool("forgive_dead_players") {
@@ -2735,6 +2828,15 @@ impl Game<'_> {
if p.species() == Species::Fox {
child.extra.main_hand = None;
}
// Fox.FoxBreedGoal.breed: the baby trusts the players who fed its
// parents into love.
if p.species() == Species::Fox {
horizon_client::mobs::fox::trust_breeders(
child,
p.extra.love_cause,
q.as_ref().and_then(|q| q.extra.love_cause),
);
}
// Goat.getBreedOffspring: GoatAi.initMemories, then the baby
// screams if the parent a coin toss picks does, or on a 2% roll.
if p.species() == Species::Goat {
@@ -2966,6 +3068,7 @@ impl Game<'_> {
let rocket = std::mem::take(&mut self.combat.rocket_hit);
// ServerPlayer.canHarmPlayer refused another player's projectile.
if self.creative || self.combat.refuse_player_hit {
self.combat.forget_noted(false);
return false;
}
// A projectile entity dealt it: `#is_projectile`, or a firework
@@ -2999,7 +3102,6 @@ impl Game<'_> {
}),
};
// The causing entity for the death message.
let marks = self.pending_marks();
let owner = self.combat.projectile_owner.take();
if let Some(a) = attacker.or(owner) {
self.note_attacker(damage_type, a);
@@ -3067,9 +3169,7 @@ impl Game<'_> {
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned
// early (invulnerable, or inside the hurt cooldown).
// A hit a native mod canceled records no damage (hurtServer never ran).
if outcome.canceled {
self.drop_pending_since(marks);
}
self.combat.forget_noted(outcome.canceled);
if outcome.hurt || outcome.sound {
let source = attacker
.or(owner)
@@ -3152,14 +3252,21 @@ impl Game<'_> {
self.give_player_effect("minecraft:wither", 40, 0);
}
pub(super) fn hurt_player_by(&mut self, damage_type: &str, amount: f32) {
if self.creative {
return;
}
if let Some(player) = self.player.as_mut()
let mut canceled = false;
if !self.creative
&& let Some(player) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(damage_type)
&& let Ok(o) = player.hurt_blocking(
&self.motion.health,
self.world.health_rules,
id,
amount,
None,
)
{
let _ = player.hurt(&self.motion.health, self.world.health_rules, id, amount);
canceled = o.canceled;
}
self.combat.forget_noted(canceled);
}
/// `Player.aiStep` touching arrows in its box grown by (1, 0.5, 1):
/// `AbstractArrow.playerTouch`, then `Player.take`'s pickup sound at the
@@ -3411,9 +3518,10 @@ impl Game<'_> {
} = hit
&& let Some(j) = wind_charge_hit(id)
{
let (owner, guest) = (p.owner, p.guest);
let (owner, owner_uuid, guest) = (p.owner, p.owner_uuid, p.guest);
let look = self.owner_look(owner, guest);
super::deflect::aim_deflect(&mut projectiles[j], look, owner, guest);
let wind = &mut projectiles[j];
super::deflect::aim_deflect(wind, look, owner, owner_uuid, guest);
}
}
let mut idx = 0;
@@ -5258,7 +5366,7 @@ impl Game<'_> {
{
// Animal.mobInteract: breed adults, speed up babies, then
// playEatingSound (silent unless the species overrides it).
match feed_animal(m, &mut self.world.random) {
match feed_animal(m, &mut self.world.random, Some(acting)) {
Some(sound) => {
consume = true;
eat = sound;
@@ -5312,6 +5420,7 @@ impl Game<'_> {
spread,
speed: 0.2,
count: count as i32,
randomization: 0,
});
}
// PlayerInteractTrigger: the item used on the mob, once the interaction took.
@@ -5577,6 +5686,15 @@ impl Game<'_> {
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
// `Mob.checkAndHandleImportantInteractions` then calls
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
// who used the egg (a fox egg used on a block trusts nobody).
if species == Species::Fox {
let spawner = self.acting_player_uuid();
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
}
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
true
+7 -2
View File
@@ -976,8 +976,13 @@ impl Game<'_> {
(_, true) => ((0.25, 0.9875), 0.9875),
_ => ((0.5, 1.975), 1.7775),
};
let p = a.position;
g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw));
g.hitbox(
at(a.previous, a.position),
size,
eye,
true,
(0.0, a.render_yaw(alpha)),
);
}
for b in &self.boats {
let h = super::boats::HEIGHT as f32;
@@ -768,6 +768,7 @@ impl Game<'_> {
let lists = Arc::new(Mutex::new(lists));
self.server_lists = Some(lists.clone());
log("INFO", &format!("Preparing level \"{level_name}\""));
log::info!("{}", horizon_threads::mem_line("preparing"));
let folder = world_dir.to_string_lossy().into_owned();
// MinecraftServer.loadLevel's start: nothing is loaded yet.
self.native_server_about_to_start(true);
@@ -781,6 +782,7 @@ impl Game<'_> {
game_mode == 1,
None,
)?)?;
log::info!("{}", horizon_threads::mem_line("load_world"));
if new_world {
self.level.hardcore = props.hardcore;
}
@@ -814,6 +816,7 @@ impl Game<'_> {
// Nothing is kept around the spawn while nobody is on.
self.streaming.radius = -1;
self.settle_spawn()?;
log::info!("{}", horizon_threads::mem_line("settle_spawn"));
let gate_lists = lists.clone();
let gate: LoginGate = Arc::new(move |address, uuid, name| {
let user = NameAndId {
@@ -922,6 +925,7 @@ impl Game<'_> {
log("WARN", "Perhaps a server is already running on that port?");
return Ok(());
}
log::info!("{}", horizon_threads::mem_line("lan_host"));
self.sync_ops(&lists);
let anticheat = load_anticheat();
let chat_filter = load_chat_filter();
@@ -959,6 +963,21 @@ impl Game<'_> {
started.elapsed().as_secs_f64()
),
);
log::info!("{}", horizon_threads::mem_line("done"));
std::thread::spawn(|| {
for t in [10, 30] {
std::thread::sleep(std::time::Duration::from_secs(if t == 10 {
10
} else {
20
}));
log::info!("{}", horizon_threads::mem_line(&format!("idle{t}s")));
if t == 10 && std::env::var_os("SP2_RELEASE").is_some() {
horizon_threads::release_free_memory();
log::info!("{}", horizon_threads::mem_line("released"));
}
}
});
// DedicatedServer.initServer ends with saveEverything(false, true, true).
self.save_game()?;
self.log_saved_levels();
+9 -8
View File
@@ -34,14 +34,14 @@ pub(super) fn aim_deflect(
p: &mut Projectile,
look: Option<Vec3>,
owner: Option<Who>,
owner_uuid: Option<[i32; 4]>,
guest: Option<u32>,
) {
if let Some(look) = look {
p.velocity = look;
p.needs_sync = true;
}
p.owner = owner;
p.guest = guest;
(p.owner, p.owner_uuid, p.guest) = (owner, owner_uuid, guest);
p.acceleration = 0.1;
}
@@ -196,14 +196,14 @@ impl Game<'_> {
/// `Projectile.deflect(AIM_DEFLECT, player, player, true)`.
pub(super) fn deflect(&mut self, i: usize) {
let dir = view_vector(self.yaw, self.pitch);
let guest = self.lan_acting;
let (guest, owner_uuid) = (self.lan_acting, Some(self.acting_player_uuid()));
let p = &mut self.creatures.projectiles[i];
p.velocity = Vec3::new(dir[0], dir[1], dir[2]);
p.yaw = dir[0].atan2(dir[2]).to_degrees() as f32;
p.pitch = dir[1].atan2(dir[0].hypot(dir[2])).to_degrees() as f32;
p.owner = Some(Who::Player);
// On a server, the guest who struck it (their projectile now).
p.guest = guest;
(p.guest, p.owner_uuid) = (guest, owner_uuid);
// Not into the one who turned it on its way out.
p.left_owner = false;
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
@@ -274,9 +274,9 @@ mod tests {
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
let mut p = wind_charge();
let look = Vec3::new(0.0, -1.0, 0.0);
aim_deflect(&mut p, Some(look), Some(Who::Player), Some(3));
aim_deflect(&mut p, Some(look), Some(Who::Player), Some([4; 4]), Some(3));
assert_eq!(p.velocity, look);
assert_eq!(p.owner, Some(Who::Player));
assert_eq!((p.owner, p.owner_uuid), (Some(Who::Player), Some([4; 4])));
assert_eq!(p.guest, Some(3));
assert_eq!(p.acceleration, 0.1);
assert!(p.needs_sync);
@@ -285,9 +285,10 @@ mod tests {
#[test]
fn with_no_owner_here_it_keeps_its_movement_but_changes_hands() {
let mut p = wind_charge();
aim_deflect(&mut p, None, None, None);
p.owner_uuid = Some([7; 4]);
aim_deflect(&mut p, None, None, None, None);
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
assert_eq!(p.owner, None);
assert_eq!((p.owner, p.owner_uuid), (None, None));
assert_eq!(p.acceleration, 0.1);
assert!(!p.needs_sync);
}
@@ -5,6 +5,10 @@ use horizon_client::discord_presence::{self as presence, Dimension, Play, Status
impl Game<'_> {
/// Each frame: only a change reaches the presence thread.
pub(super) fn update_discord_presence(&self) {
// One Discord status per computer: P1's, not a splitscreen player's.
if self.options.split_guest {
return;
}
presence::set(self.discord_status());
}
fn discord_status(&self) -> Status {
+12 -18
View File
@@ -67,6 +67,15 @@ fn y_rot(d: Direction) -> f32 {
_ => 270.0,
}
}
/// `DirectionalPlaceContext.getRotation`: `get2DDataValue() * 90`, so a
/// vertical direction (`data2d = -1`) gives -90 where `toYRot` gives 270: the
/// same horizontal facing, but the raw value a mod's placement hook reads.
fn place_rotation(d: Direction) -> f32 {
match d {
Direction::Up | Direction::Down => -90.0,
_ => y_rot(d),
}
}
fn block_box(p: Pos) -> Aabb {
Aabb::new(
Vec3::new(f64::from(p.0), f64::from(p.1), f64::from(p.2)),
@@ -507,7 +516,7 @@ impl Game<'_> {
/// Drop one item as DefaultDispenseItemBehavior does. For a dispenser hook:
/// return this remainder in its answer with `CLICK`; the caller plays the
/// answer's event and smoke once. A client, another dimension or an unloaded
/// chunk keeps the input stack unchanged. API @API:dispenser_behaviour@.
/// chunk keeps the input stack unchanged. API 67.
#[allow(dead_code)]
pub(super) fn native_dispense_default(
&mut self,
@@ -1252,22 +1261,7 @@ impl Game<'_> {
BucketContent::Fluid(FluidKind::Water) | BucketContent::MobWater
)
{
// The water evaporates in a cloud of smoke.
let c = Self::center(target);
for _ in 0..8 {
let r = self.particles.random();
let p = [
f64::from(target.0) + (r.next_double() * 2.0 - 1.0),
f64::from(target.1) + (r.next_double() * 2.0 - 1.0),
f64::from(target.2) + (r.next_double() * 2.0 - 1.0),
];
let _ = c;
self.particles.add(
&ParticleOptions::simple(ParticleType::LargeSmoke),
p,
[0.0; 3],
);
}
self.evaporation_smoke([target.0, target.1, target.2]);
}
if let Some(kind) = mob {
// MobBucketItem.checkExtraContent: the mob (from the bucket, so
@@ -1581,7 +1575,7 @@ impl Game<'_> {
f64::from(front.2) + 0.5,
),
inside: false,
yaw: y_rot(facing),
yaw: place_rotation(facing),
pitch: 0.0,
sneaking: false,
item: stack.item.to_string(),
+6 -2
View File
@@ -2436,13 +2436,17 @@ impl Game<'_> {
}
let (health, rules) = (&self.motion.health, self.world.health_rules);
let id = health.damage.id("minecraft:mob_attack");
let mut canceled = false;
if let Some(p) = self.player.as_mut() {
push_seen_with_hurt(p, push, |p| {
if let Some(id) = id.filter(|_| hurts) {
let _ = p.hurt(health, rules, id, 5.0);
if let Some(id) = id.filter(|_| hurts)
&& let Ok(o) = p.hurt_blocking(health, rules, id, 5.0, None)
{
canceled = o.canceled;
}
});
}
self.combat.forget_noted(canceled);
}
/// Damage from the dragon, which is its causing entity
/// (`DamageSources.mobAttack(dragon)`, `indirectMagic(cloud, dragon)`): the
@@ -175,6 +175,7 @@ impl Game<'_> {
};
let Some(m) = self.mobs.get(mob) else { return };
let kind = m.kind.id.clone();
let victim = m.id;
let ctx = EffectContext {
this: Some(&kind),
attacker: Some("minecraft:player"),
@@ -187,6 +188,7 @@ impl Game<'_> {
// ignite (fire aspect) and apply_mob_effect (bane of arthropods).
let mut ignite = 0.0f32;
let mut mob_effects = Vec::new();
let mut hook_calls: Vec<(String, String, Value, u32)> = Vec::new();
for (id, level) in held.enchantments() {
let Some(def) = world_stations!(self).enchantments.get(id) else {
continue;
@@ -197,14 +199,17 @@ impl Game<'_> {
.flatten()
{
let e = &effect["effect"];
let kind_of = e["type"].as_str();
if effect["enchanted"] != "attacker"
|| effect["affected"] != "victim"
|| !matches!(
kind_of,
Some("minecraft:ignite" | "minecraft:apply_mob_effect")
)
{
if effect["enchanted"] != "attacker" || effect["affected"] != "victim" {
continue;
}
// A bare type name is minecraft's own, as a bare item id is.
let kind_of = match e["type"].as_str() {
Some(k) if k.contains(':') => k.to_owned(),
Some(k) => format!("minecraft:{k}"),
None => continue,
};
let own = !kind_of.starts_with("minecraft:");
if !own && !matches!(&*kind_of, "minecraft:ignite" | "minecraft:apply_mob_effect") {
continue;
}
let mut random = self.world.random.clone();
@@ -218,7 +223,11 @@ impl Game<'_> {
if !ok {
continue;
}
if kind_of == Some("minecraft:ignite") {
// A mod's own kind: its hook answers, after this loop (the
// stations borrow is held here).
if own {
hook_calls.push((id.to_string(), kind_of, e.clone(), *level));
} else if kind_of == "minecraft:ignite" {
ignite =
ignite.max(horizon_client::enchant::level_value(&e["duration"], *level));
} else {
@@ -235,6 +244,9 @@ impl Game<'_> {
for (effect, level) in mob_effects {
self.apply_mob_effect(mob, &effect, level);
}
for (id, kind_of, data, level) in hook_calls {
self.native_enchant_effect(&id, &kind_of, &data, level, victim);
}
self.post_attack_bursts(&held, &kind);
}
/// `EnchantmentHelper.doPostPiercingAttackEffects`: the
@@ -17,13 +17,14 @@ const WOODS: [&str; 11] = [
"pale_oak",
"test_wood",
];
const CARTS: [&str; 6] = [
const CARTS: [&str; 7] = [
"minecart",
"chest_minecart",
"hopper_minecart",
"furnace_minecart",
"tnt_minecart",
"command_block_minecart",
"spawner_minecart",
];
fn fixture() -> Value {
@@ -178,7 +179,7 @@ fn entity_model_batches_preserve_parts_missing_layers_and_load_failures() {
let mut all = fixture();
fs::write(&file, serde_json::to_vec(&all).unwrap()).unwrap();
assert_equivalent(&root.0);
assert_eq!(optimized(&root.0).len(), 42);
assert_eq!(optimized(&root.0).len(), 43);
all.as_object_mut()
.unwrap()
.remove("minecraft:armor_stand_small#main");
@@ -187,7 +188,7 @@ fn entity_model_batches_preserve_parts_missing_layers_and_load_failures() {
.remove("minecraft:boat/oak#main");
fs::write(&file, serde_json::to_vec(&all).unwrap()).unwrap();
assert_equivalent(&root.0);
assert_eq!(optimized(&root.0).len(), 40);
assert_eq!(optimized(&root.0).len(), 41);
let document = EntityModelDocument::load(&root.0).unwrap();
let mut first = document.model("minecraft:armor_stand#main").unwrap();
first.children.clear();
+3 -1
View File
@@ -434,6 +434,7 @@ impl Game<'_> {
pushed = (!p.health.sync_velocity).then_some(push);
}
}
self.combat.forget_noted(outcome.canceled);
if raised {
self.after_block(outcome, None, false);
}
@@ -941,7 +942,7 @@ impl Game<'_> {
// tables' survives_explosion and explosion_decay draw from their
// random sequence.
let decay = (interaction == BlockInteraction::DestroyWithDecay).then_some(radius);
let drops = world_items!(self).block_drops_exploded(
let mut drops = world_items!(self).block_drops_exploded(
self.pack.assets.registry,
state,
None,
@@ -951,6 +952,7 @@ impl Game<'_> {
decay,
false,
);
self.native_block_drops(pos, state, None, None, Some(radius), &mut drops);
for mut stack in drops {
if &*stack.item == "minecraft:air" || stack.count == 0 {
continue;
@@ -490,6 +490,7 @@ impl Game<'_> {
spread,
speed,
count: count as i32,
randomization: 0,
});
}
/// `FishingHook.tick`.
+1
View File
@@ -156,6 +156,7 @@ impl Game<'_> {
};
let tilt = self.settings.file.double("damageTiltStrength");
glam::Mat4::from_cols_array(&bob_matrix(&state, tilt, self.settings.view_bobbing).m)
* self.native_shake_matrix(partial)
}
/// `AbstractClientPlayer.tick` and `aiStep` (`ClientAvatarState.tick`,
/// `updateBob`), `rideTick`'s `resetBob` and `LocalPlayer.move`'s walked
+88 -6
View File
@@ -60,6 +60,9 @@ struct Flags {
entity_distance: AtomicU32,
/// `MinecraftServer.isCurrentlySaving`: the server is autosaving.
saving: AtomicBool,
/// Splitscreen players joined through memory (offline uuids): let in although the world is
/// not open to the LAN, and the game no longer pauses while they play.
split_players: Mutex<Vec<u128>>,
/// The generators of the levels the server has run, by dimension: what
/// F3's chunk generation entry asks `ServerChunkCache.getGenerator`.
terrain: Mutex<
@@ -120,7 +123,42 @@ impl Integrated {
}
/// `IntegratedServer.tickServer` skips the world while the game is paused.
pub fn set_paused(&self, paused: bool) {
self.flags.paused.store(paused, Ordering::Relaxed);
let split = !self.flags.split_players.lock().unwrap().is_empty();
self.flags.paused.store(paused && !split, Ordering::Relaxed);
}
/// Splitscreen: `name` left; the game pauses again once every such player has. The owner's
/// permissions it was given go with it (a LAN player could otherwise join under that name).
pub fn leave_split(&self, name: &str) {
let uuid = horizon_protocol::crypto::offline_uuid(name);
self.flags
.split_players
.lock()
.unwrap()
.retain(|&u| u != uuid);
self.call(move |g| {
if let Some(host) = g.lan.as_mut() {
host.hub.ops.remove(&uuid);
}
});
}
/// Splitscreen: a connection through memory for another player on this machine, who gets
/// the owner's permissions. `None` once the server has stopped.
pub fn join_split(&self, name: &str) -> Option<horizon_protocol::connection::Pipe> {
let uuid = horizon_protocol::crypto::offline_uuid(name);
self.flags.split_players.lock().unwrap().push(uuid);
let pipe = self
.call(move |g| {
let host = g.lan.as_mut()?;
let owner = host.owner.as_ref()?.uuid;
let level = host.hub.ops.get(&owner).copied().unwrap_or(0);
host.hub.ops.insert(uuid, level);
host.hub.lan.connect_local().ok()
})
.flatten();
if pipe.is_none() {
self.leave_split(name);
}
pipe
}
/// The window's render and simulation distances and its Entity Distance
/// option, for the server to follow.
@@ -679,14 +717,25 @@ impl Game<'_> {
};
let owner = launch.owner;
let published = flags.clone();
let local_players = flags.clone();
let net = NetConfig {
// No network until it is opened to the LAN; the memory connection
// is not compressed (`Connection.isMemoryConnection`).
listen: false,
online: horizon_client::lan::OnlineMode::lan(),
gate: Some(Arc::new(move |_, uuid, _| {
(uuid != owner && published.published.load(Ordering::Relaxed) == 0)
.then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
// Registered local seats share this computer; the vanilla LAN capacity still
// applies to other connections when the world is published.
bypasses_limit: Some(Arc::new(move |uuid| {
local_players.split_players.lock().unwrap().contains(&uuid)
})),
gate: Some(Arc::new(move |address, uuid, _| {
refused(
uuid == owner,
published.published.load(Ordering::Relaxed) != 0,
published.split_players.lock().unwrap().contains(&uuid),
address == horizon_client::lan::conn::MEMORY_ADDRESS,
)
.then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
})),
levels: self.level_shapes,
owner_profile: launch.owner_profile.clone(),
@@ -1033,12 +1082,17 @@ impl Game<'_> {
/// `IntegratedServer.setWorldAllowCommands`: the owner is an operator
/// while the world allows commands; everyone's level is sent again.
pub(super) fn owner_allow_commands(&mut self, allow: bool) {
let Some(owner) = self.serving.as_ref().map(|s| s.owner) else {
let Some((owner, flags)) = self.serving.as_ref().map(|s| (s.owner, s.flags.clone())) else {
return;
};
let published = self.published_port().is_some();
if let Some(host) = self.lan.as_mut() {
host.hub.ops.insert(owner, if allow { 4 } else { 0 });
let level = if allow { 4 } else { 0 };
host.hub.ops.insert(owner, level);
// Splitscreen seats have the owner's permissions (`join_split`): they follow it.
for &seat in flags.split_players.lock().unwrap().iter() {
host.hub.ops.insert(seat, level);
}
if published {
host.hub.config.guest_commands = allow && host.settings.guest_commands;
}
@@ -1144,10 +1198,38 @@ fn server_game(
Game::new(res, pack, gui, options, settings, audio, None)
}
/// Who the integrated server turns away: anyone but the owner until it is opened to the LAN,
/// except a splitscreen seat. A seat's name is let in only through memory, so a LAN player
/// logging in under it never gets its place or the owner's permissions it holds.
fn refused(owner: bool, published: bool, split_seat: bool, memory: bool) -> bool {
if split_seat {
!memory
} else {
!owner && !published
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_splitscreen_seat_is_let_in_only_through_memory() {
// (owner, published, seat, memory)
assert!(!refused(false, false, true, true), "a local seat joins");
assert!(
refused(false, true, true, false),
"a LAN player under a seat's name"
);
assert!(refused(false, false, true, false));
assert!(
!refused(false, true, false, false),
"anyone once open to the LAN"
);
assert!(refused(false, false, false, false), "nobody else before");
assert!(!refused(true, false, false, true), "the owner");
}
#[test]
fn shutdown_request_does_not_join_a_server_still_saving() {
let flags = Arc::new(Flags::default());
+14 -3
View File
@@ -407,22 +407,33 @@ impl Game<'_> {
p.y + (q.y - p.y) * alpha,
p.z + (q.z - p.z) * alpha,
];
(item, at, alpha as f32)
(item, at, alpha as f32, true)
});
// ItemPickupParticle: the render state extracted at partial tick 1.
let taken = self.pickups.live.iter().filter_map(|p| match &p.taken {
super::pickups::Taken::Item(item) => {
Some((&**item, super::pickups::position(p, alpha), 1.0))
Some((&**item, super::pickups::position(p, alpha), 1.0, false))
}
_ => None,
});
for (item, at, partial) in live.chain(taken) {
for (item, at, partial, entity) in live.chain(taken) {
let entity_start = out.len();
let q = item.position;
let pos = glam::DVec3::from_array(at) - camera;
if pos.length_squared() > 64.0 * 64.0 {
continue;
}
// EntityRenderer.shouldRender: the item's 0.25 box, grown by 0.5,
// in the view frustum. Checked before its model is resolved.
if entity && let Some(view) = &self.view_frustum {
let feet = (glam::DVec3::new(q.x, q.y, q.z) - camera).as_vec3();
let half = glam::Vec3::new(0.125 + 0.5, 0.5, 0.125 + 0.5);
let lo = feet - half;
let hi = feet + glam::Vec3::new(half.x, 0.25 + 0.5, half.z);
if !view.intersects(lo, hi) {
continue;
}
}
// `extractItemGroupRenderState`: GROUND, seeded by the entity id.
let owner = owner_at([q.x, q.y, q.z], 0.0, "minecraft:item");
let state = self.resolve_item(
+44 -36
View File
@@ -171,7 +171,8 @@ pub(super) struct LanHost {
/// see; the players' hurt time by entity id.
mob_state: HashMap<super::Dim, HashMap<u32, (i32, bool, Option<i32>)>>,
/// The guest each mob sees as its player, when not the host (game/lan_mobs.rs).
pub mob_players: HashMap<u32, u32>,
/// Looked up per mob each tick: a fast hasher (never iterated).
pub mob_players: horizon_client::fast_hash::FxHashMap<u32, u32>,
/// An integrated server's owner (game/integrated.rs).
pub owner: Option<Owner>,
/// The players' locator bars, one waypoint manager per level by
@@ -1403,6 +1404,7 @@ impl Game<'_> {
let host_entity = self.next_entity as i32;
let mut hub = Hub::new(lan, sync, encoder, config);
hub.flat_world = self.world_flat;
hub.debug_world = self.world_debug;
let port = hub.lan.port;
self.world.block_log = Some(Vec::new());
self.world.light_log = Some(Default::default());
@@ -1420,7 +1422,7 @@ impl Game<'_> {
play: HashMap::new(),
broken: Vec::new(),
mob_state: HashMap::new(),
mob_players: HashMap::new(),
mob_players: Default::default(),
owner: None,
waypoints: Default::default(),
waypoint_dead: Default::default(),
@@ -1482,6 +1484,25 @@ impl Game<'_> {
/// `PlayerList.removeAll`: every guest leaves in turn, the vehicle it
/// alone rides with it (`PlayerList.remove`), and is saved.
pub(super) fn save_leaving_guests(&mut self, host: &mut LanHost) {
// Every open menu closes first, in its guest's level
// (`ServerPlayer.disconnect` -> `doCloseContainer`), so the cursor,
// the trade and the inputs are back in the file that is saved.
let back = self.dim;
let open: Vec<(u32, super::Dim)> = host
.hub
.guests
.iter()
.filter(|g| g.placed)
.map(|g| (g.conn, super::Dim::from_id(&g.dimension)))
.collect();
for (conn, dim) in open {
if self.enter_level(Some(host), dim).is_ok() {
self.guest_close_all(host, conn);
}
}
if let Err(e) = self.enter_level(Some(host), back) {
log::error!("could not reopen {}: {e}", back.id());
}
for i in 0..host.hub.guests.len() {
let mut g = host.hub.guests.remove(i);
self.take_parent_vehicle(host, &mut g);
@@ -1490,15 +1511,13 @@ impl Game<'_> {
}
}
/// Save a guest when they leave or the world closes (after
/// `take_parent_vehicle`): an integrated server's owner with the world
/// (`save_game`), anyone else in their file.
/// `take_parent_vehicle`, `PlayerList.save`): an integrated server's owner
/// into the level (`/data`) and both its files (`playerdata::save_owner`),
/// anyone else into their own file.
pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) {
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) && guest.placed {
// The world's own player state rides in the level (`/data`).
playerdata::store(guest, &mut self.level, None);
}
if guest.placed {
// PlayerList.save/remove: every player saves to their own file.
if guest.placed && host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
playerdata::save_owner(guest, &mut self.level, &self.world_root);
} else if guest.placed {
playerdata::save(guest, &self.world_root);
}
self.save_guest_records(host, guest);
@@ -1815,7 +1834,7 @@ impl Game<'_> {
fn lan_entities(
&self,
host_entity: Option<i32>,
mob_players: &HashMap<u32, u32>,
mob_players: &horizon_client::fast_hash::FxHashMap<u32, u32>,
hub: &mut Hub,
out: &mut States,
) {
@@ -1979,15 +1998,17 @@ impl Game<'_> {
}
}
HostEvent::Left(mut guest) => {
// The container they had open loses an opener.
if let Some(pos) = guest.menu.as_ref().and_then(|m| m.pos)
&& !guest.spectator()
{
// In the level they were in.
// The menu they had open closes, in the level they were in
// (`ServerPlayer.disconnect` -> `doCloseContainer`).
if guest.placed {
let back = self.dim;
let theirs = super::Dim::from_id(&guest.dimension);
if self.enter_level(Some(&mut host), theirs).is_ok() {
self.container_openers_changed(pos, false);
let conn = guest.conn;
let at = host.hub.guests.len();
host.hub.guests.push(*guest);
self.guest_close_all(&mut host, conn);
*guest = host.hub.guests.remove(at);
}
if let Err(e) = self.enter_level(Some(&mut host), back) {
log::error!("could not reopen {}: {e}", back.id());
@@ -2352,28 +2373,15 @@ impl Game<'_> {
let force = host.hub.config.force_game_mode;
let root = self.world_root.clone();
let in_place = host.hub.config.dimension.clone();
let mut owner = host.owner.as_mut();
let owner = host.owner.as_mut();
let Some(g) = host.hub.guest_mut(conn) else {
return;
};
let is_owner = owner.as_ref().is_some_and(|o| o.uuid == g.uuid);
// PlayerDataStorage.load: the player's own file first; the world's
// own player state is the fallback for a first join.
let known = if is_owner && playerdata::load(g, &root) {
true
} else {
let saved = if is_owner {
owner.as_mut().and_then(|o| o.saved.take())
} else {
None
};
match saved {
Some(level) => {
playerdata::apply(g, &level);
level.position.is_some()
}
None => playerdata::load(g, &root),
}
// PlayerList.loadPlayerData: the owner gets the world's player file,
// everyone else their own.
let known = match owner.filter(|o| o.uuid == g.uuid) {
Some(o) => playerdata::load_owner(g, &root, o.saved.take().as_deref()),
None => playerdata::load(g, &root),
};
if !known || force {
g.game_mode = default_mode;
+123 -14
View File
@@ -295,7 +295,9 @@ impl Game<'_> {
f: impl FnOnce(&mut Self) -> R,
) -> Option<R> {
let swap = self.enter_guest(host, conn)?;
let result = f(self);
let dimension =
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(self.dim.id());
let result = horizon_client::block_contact::in_level(dimension, || f(self));
self.leave_guest(host, swap);
Some(result)
}
@@ -1182,8 +1184,7 @@ impl Game<'_> {
return false;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
self.guest_close_all(host, conn);
let hidden = self.native_hidden_slots(pos, items.len());
host.hub
.open_container(conn, menu, title, pos, slots, items);
@@ -1231,8 +1232,7 @@ impl Game<'_> {
return false;
};
// ServerPlayer.openMenu closes the menu that was open.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
self.guest_close_all(host, conn);
host.hub
.open_container(conn, menu, title, pos, slots, items);
true
@@ -1253,6 +1253,38 @@ impl Game<'_> {
}
}
/// Closes whatever menu the guest has open, the way each `ContainerClose`
/// does (`ServerPlayer.doCloseContainer`, `AbstractContainerMenu.removed`):
/// the container's lid, a vehicle's stand-in (its items go back to the
/// vehicle), the trade (written back to the villager), the crafting table,
/// station and mount menus, and the cursor stack back to the inventory.
/// Nothing is sent to the client: this is for a guest who is leaving or
/// whose server is closing. The guest must be in `host.hub.guests` and the
/// level they are in must be the one in place.
pub(super) fn guest_close_all(&mut self, host: &mut LanHost, conn: u32) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
if let Some((window, slots)) = host.hub.guests[gi]
.menu
.as_ref()
.map(|m| (m.window, m.slots))
{
self.guest_container_closed(host, conn);
if let Slots::Block(key) = slots
&& key.1 == super::minecarts::CART_KEY_Y
{
self.close_guest_vehicle_menu(host, conn, key);
}
host.hub.container_closed(conn, window);
}
self.lan_merchant_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
self.lan_station_closed(host, conn, None);
self.lan_mount_closed(host, conn, None);
self.guest_carried_back(host, gi);
}
/// `ContainerEntity`'s `Container.stopOpen` when a vehicle menu closes
/// (`AbstractChestBoat.stopOpen` / `MinecartChest.stopOpen`): the menu's
/// slots go back to the vehicle, the shared placeholder leaves
@@ -1297,15 +1329,7 @@ impl Game<'_> {
.guests
.iter()
.filter(|g| host.hub.here(g))
.filter(|g| {
g.menu.as_ref().is_some_and(|m| {
(m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos))
|| m.pos.is_some_and(|k| {
k.1 == super::minecarts::CART_KEY_Y
&& !self.chest_vehicle_still_valid(k.2, g)
})
})
})
.filter(|g| !self.container_menu_valid(g))
.map(|g| {
let vehicle = g.menu.as_ref().and_then(|m| match m.slots {
Slots::Block(key) if key.1 == super::minecarts::CART_KEY_Y => Some(key),
@@ -1326,6 +1350,78 @@ impl Game<'_> {
}
}
/// `stillValid` of the guest's open container: its blocks are still there
/// and in reach, or its vehicle exists and is in reach. True with none open.
fn container_menu_valid(&self, g: &horizon_client::lan::guest::Guest) -> bool {
g.menu.as_ref().is_none_or(|m| {
!(m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos))
&& !m.pos.is_some_and(|k| {
k.1 == super::minecarts::CART_KEY_Y && !self.chest_vehicle_still_valid(k.2, g)
})
})
}
/// `AbstractContainerMenu.stillValid` of whatever menu the guest has open
/// (a container or vehicle, a trade, the crafting table, a station, a
/// lectern, a mount's inventory). True when none is open.
fn guest_menu_still_valid(&self, host: &LanHost, conn: u32) -> bool {
let Some(g) = host.hub.guest(conn) else {
return true;
};
if !self.container_menu_valid(g) {
return false;
}
let Some(p) = host.play.get(&conn) else {
return true;
};
p.crafting
.table
.as_ref()
.is_none_or(|t| self.lan_table_valid(g, t.3))
&& p.station
.as_ref()
.is_none_or(|s| self.lan_station_valid(g, s))
&& p.lectern
.as_ref()
.is_none_or(|l| self.lan_lectern_valid(g, l.key))
&& p.merchant
.as_ref()
.and_then(|m| m.merchant.mob)
.is_none_or(|id| self.lan_merchant_valid(g, id))
&& p.mount_window
.as_ref()
.is_none_or(|m| self.lan_mount_valid(g, m.mob))
}
/// The check before a menu click: a menu that is no longer valid closes
/// (returning what it held, as `ContainerClose` does) and the client's
/// window with it, so the click lands on nothing
/// (`ServerGamePacketListenerImpl.handleContainerClick` and the others
/// ask `stillValid` first).
fn guest_validate_menu(&mut self, host: &mut LanHost, conn: u32) {
if self.guest_menu_still_valid(host, conn) {
return;
}
let mut windows: Vec<i32> = host
.hub
.guest(conn)
.and_then(|g| g.menu.as_ref().map(|m| m.window))
.into_iter()
.collect();
if let Some(p) = host.play.get(&conn) {
windows.extend(p.crafting.table.as_ref().map(|t| t.0));
windows.extend(p.station.as_ref().map(|s| s.window));
windows.extend(p.lectern.as_ref().map(|l| l.window));
windows.extend(p.merchant.as_ref().map(|m| m.window));
windows.extend(p.mount_window.as_ref().map(|m| m.window));
}
self.guest_close_all(host, conn);
for container in windows {
host.hub
.send_to(conn, &C::ContainerClose(play::ContainerClose { container }));
}
}
/// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest
/// boat is still there and within the guest's entity interaction range
/// plus 4 of its box.
@@ -1437,6 +1533,19 @@ impl Game<'_> {
{
return;
}
if matches!(
packet,
S::ContainerClick(_)
| S::ContainerButtonClick(_)
| S::ContainerSlotStateChanged(_)
| S::RenameItem(_)
| S::SetBeacon(_)
| S::SelectTrade(_)
| S::SelectBundleItem(_)
| S::PlaceRecipe(_)
) {
self.guest_validate_menu(host, conn);
}
let spectator = host.hub.guests[gi].spectator();
match packet {
S::PlayerAction(a) => match a.action {
@@ -116,20 +116,22 @@ impl Game<'_> {
/// `/attribute` changed.
pub(super) fn mob_attributes(&self, m: &Mob, out: &mut Vec<AttributeSnapshot>) {
let kind = m.kind.id.as_str();
if let Some(a) = self.synced_attribute(kind, "minecraft:movement_speed") {
instance(out, a, finalize::base_value(m, Attr::MovementSpeed));
// Movement speed is synced for nearly every mob, and read up to three
// times below: looked up once per mob and tick.
let speed = self
.synced_attribute(kind, Attr::MovementSpeed.id())
.map(|a| (a, finalize::base_value(m, Attr::MovementSpeed)));
if let Some((a, base)) = speed {
instance(out, a, base);
}
if let Some(s) = m.extra.attributes.as_deref() {
for (attr, v) in &s.bases {
if let Some(a) = self.synced_attribute(kind, &format!("minecraft:{}", attr.name()))
{
if let Some(a) = self.synced_attribute(kind, attr.id()) {
instance(out, a, *v).base = *v;
}
}
for x in &s.modifiers {
if let Some(a) =
self.synced_attribute(kind, &format!("minecraft:{}", x.attr.name()))
{
if let Some(a) = self.synced_attribute(kind, x.attr.id()) {
add_modifier(
out,
a,
@@ -162,18 +164,17 @@ impl Game<'_> {
if rolled
&& !jump_base
&& m.extra.vector[0] > 0.0
&& let Some(a) = self.synced_attribute(kind, "minecraft:jump_strength")
&& let Some(a) = self.synced_attribute(kind, Attr::JumpStrength.id())
{
instance(out, a, m.extra.vector[0]).base = m.extra.vector[0];
}
if let (Some(a), Some((amount, Op::AddMultipliedBase))) = (
self.synced_attribute(kind, "minecraft:movement_speed"),
finalize::transient(m, Attr::MovementSpeed),
) {
if let (Some((a, base)), Some((amount, Op::AddMultipliedBase))) =
(speed, finalize::transient(m, Attr::MovementSpeed))
{
add_modifier(
out,
a,
finalize::base_value(m, Attr::MovementSpeed),
base,
AttributeModifier {
id: "minecraft:baby".into(),
amount,
@@ -182,14 +183,11 @@ impl Game<'_> {
);
}
// LivingEntity.setSprinting: SPEED_MODIFIER_SPRINTING (a dashing cat or ocelot).
if let (true, Some(a)) = (
m.extra.sprinting,
self.synced_attribute(kind, "minecraft:movement_speed"),
) {
if let (true, Some((a, base))) = (m.extra.sprinting, speed) {
add_modifier(
out,
a,
finalize::base_value(m, Attr::MovementSpeed),
base,
AttributeModifier {
id: "minecraft:sprinting".into(),
amount: f64::from(0.3f32),
@@ -21,7 +21,7 @@ pub(super) struct GuestCrafting {
/// The inventory menu; its grid lives in the hub's guest.
inventory: Option<Box<Menu>>,
/// The open crafting table: its window, state id, menu and block.
table: Option<(i32, i32, Box<Menu>, [i32; 3])>,
pub(super) table: Option<(i32, i32, Box<Menu>, [i32; 3])>,
/// The open furnace's or brewing stand's window and menu (`AbstractFurnaceMenu`,
/// `BrewingStandMenu`); its slots live in the block entity and the hub's
/// container menu keeps the client in sync.
@@ -436,10 +436,8 @@ impl Game<'_> {
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
self.lan_crafting_closed(host, conn, None);
self.lan_station_closed(host, conn, None);
// The container it replaces loses its opener.
self.guest_container_closed(host, conn);
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable("container.crafting", &[]);
let Some(window) = host.hub.open_window(conn, "minecraft:crafting", title) else {
return;
@@ -509,7 +507,7 @@ impl Game<'_> {
/// `CraftingMenu.stillValid`: the block is still a crafting table and the
/// guest is within reach of it.
fn lan_table_valid(&self, g: &Guest, pos: [i32; 3]) -> bool {
pub(super) fn lan_table_valid(&self, g: &Guest, pos: [i32; 3]) -> bool {
let name = &self
.res
.facts()
@@ -15,8 +15,8 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
/// A guest's open mount menu.
pub(super) struct GuestMount {
window: i32,
mob: u32,
pub(super) window: i32,
pub(super) mob: u32,
columns: usize,
state: i32,
/// The menu's slots and the carried stack the client was last sent.
@@ -31,10 +31,8 @@ impl Game<'_> {
return;
};
let columns = super::mount_menu::mount_columns(&self.mobs[index]);
self.lan_mount_closed(host, conn, None);
self.lan_station_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
self.guest_container_closed(host, conn);
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let Some(window) = host.hub.open_mount_window(conn, columns as i32, mob as i32) else {
return;
};
@@ -182,10 +180,28 @@ impl Game<'_> {
closed
}
/// `AbstractMountInventoryMenu.stillValid`: the mount is alive and within
/// the entity interaction range (3) plus 4 of the guest's eyes.
pub(super) fn lan_mount_valid(&self, g: &horizon_client::lan::guest::Guest, mob: u32) -> bool {
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
self.mobs.iter().find(|m| m.id == mob).is_some_and(|m| {
let (w, h) = (f64::from(m.width()) / 2.0, f64::from(m.height()));
let p = [m.position.x, m.position.y, m.position.z];
let lo = [p[0] - w, p[1], p[2] - w];
let hi = [p[0] + w, p[1] + h, p[2] + w];
let d: f64 = (0..3)
.map(|k| (eye[k] - eye[k].clamp(lo[k], hi[k])).powi(2))
.sum();
m.alive() && d < 7.0 * 7.0
})
}
/// Each tick: a mount menu whose mount died or is out of reach closes
/// (`AbstractMountInventoryMenu.stillValid`: alive, within the entity
/// interaction range plus 4 of the eyes), and the others follow the
/// mount's and the inventory's slots.
/// mount's and the inventory's slots. Only the level the player is in
/// checks its menu (`ServerPlayer.tick`): another level ticking has
/// none of its mobs (#5331).
pub(super) fn tick_lan_mount_menus(&mut self, host: &mut LanHost) {
let open: Vec<(u32, u32, i32)> = host
.play
@@ -196,19 +212,10 @@ impl Game<'_> {
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
continue;
};
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let valid = self.mobs.iter().find(|m| m.id == mob).is_some_and(|m| {
let (w, h) = (f64::from(m.width()) / 2.0, f64::from(m.height()));
let p = [m.position.x, m.position.y, m.position.z];
let lo = [p[0] - w, p[1], p[2] - w];
let hi = [p[0] + w, p[1] + h, p[2] + w];
let d: f64 = (0..3)
.map(|k| (eye[k] - eye[k].clamp(lo[k], hi[k])).powi(2))
.sum();
// Player.ENTITY_INTERACTION_RANGE 3, plus 4.
m.alive() && d < 7.0 * 7.0
});
if valid {
if !host.hub.here(g) {
continue;
}
if self.lan_mount_valid(g, mob) {
self.lan_send_mount(host, conn);
} else {
self.lan_mount_closed(host, conn, Some(window));
+84 -12
View File
@@ -20,6 +20,8 @@ pub(super) struct QueuedParticle {
pub spread: [f64; 3],
pub speed: f64,
pub count: i32,
/// The packet's `RandomizationType` (0 DEFAULT, 1 ALTERNATIVE).
pub randomization: i32,
}
fn send_native_nearby(
@@ -38,6 +40,31 @@ fn send_native_nearby(
}
}
/// One of a packet's `count` particles (`handleParticleEvent`): its position
/// and speed. DEFAULT (0) draws both from a Gaussian; ALTERNATIVE (1) and
/// ALTERNATIVE_WITH_SPEED (2) spread over `[0, dist)`, the first keeping
/// the speeds as they are, the second scaling each by `nextDouble`.
fn spread(
r: &mut impl RandomSource,
randomization: i32,
pos: [f64; 3],
dist: [f64; 3],
speed: [f64; 3],
) -> ([f64; 3], [f64; 3]) {
if matches!(randomization, 1 | 2) {
let at = [0, 1, 2].map(|k| pos[k] + r.next_double() * dist[k]);
let v = if randomization == 2 {
[0, 1, 2].map(|k| speed[k] * r.next_double())
} else {
speed
};
(at, v)
} else {
let at = [0, 1, 2].map(|k| pos[k] + r.next_gaussian() * dist[k]);
(at, [0, 1, 2].map(|k| r.next_gaussian() * speed[k]))
}
}
/// An ARGB colour as the options' RGBA.
fn rgba(argb: i32) -> [f32; 4] {
let c = |shift: i32| ((argb >> shift) & 255) as f32 / 255.0;
@@ -334,17 +361,7 @@ impl Game<'_> {
return;
}
for _ in 0..p.count {
let r = self.particles.random();
let at = [
p.pos[0] + r.next_gaussian() * d[0],
p.pos[1] + r.next_gaussian() * d[1],
p.pos[2] + r.next_gaussian() * d[2],
];
let v = [
r.next_gaussian() * s[0],
r.next_gaussian() * s[1],
r.next_gaussian() * s[2],
];
let (at, v) = spread(self.particles.random(), p.randomization, p.pos, d, s);
add(&mut self.particles, at, v);
}
}
@@ -409,6 +426,7 @@ impl Game<'_> {
spread,
speed,
count,
randomization,
} = request;
let Some(particle) = self.network_particle(&options, state) else {
return;
@@ -424,7 +442,7 @@ impl Game<'_> {
dist: spread.map(|d| d as f32),
max_speed: [speed as f32; 3],
count,
randomization: 0,
randomization,
})
.encode();
// BlockPos.closerToCenterThan(at, 32): from the centre of the
@@ -469,6 +487,33 @@ impl Game<'_> {
}
}
/// `BucketItem.emptyContents` where water evaporates: `sendParticles(
/// LARGE_SMOKE, x, y, z, 8, 1, 1, 1, 0, ALTERNATIVE)` from the block's
/// corner, so each puff lands inside the block. The window draws its own.
pub(super) fn evaporation_smoke(&mut self, pos: [i32; 3]) {
let options = ParticleOptions::simple(ParticleType::LargeSmoke);
let at = pos.map(f64::from);
for _ in 0..8 {
let r = self.particles.random();
let p = [
at[0] + r.next_double(),
at[1] + r.next_double(),
at[2] + r.next_double(),
];
self.particles.add(&options, p, [0.0; 3]);
}
self.lan_send_particles(QueuedParticle {
dimension: self.dim.id(),
options,
state: None,
at,
spread: [1.0; 3],
speed: 0.0,
count: 8,
randomization: 1,
});
}
/// The particles guests' turns put off.
pub(super) fn flush_lan_particles(&mut self) {
for request in std::mem::take(&mut self.creatures.lan_particles) {
@@ -616,3 +661,30 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::spread;
use horizon_core::random::LegacyRandom;
/// The Nether's evaporation smoke (ALTERNATIVE, dist 1, speed 0) lands
/// inside the block from its corner and stands still; DEFAULT spreads
/// both ways.
#[test]
fn alternative_particles_spread_from_the_corner_over_dist() {
let mut r = LegacyRandom::new(1);
let mut below = false;
for _ in 0..200 {
let (at, v) = spread(&mut r, 1, [3.0, 64.0, -7.0], [1.0; 3], [0.0; 3]);
assert!((3.0..4.0).contains(&at[0]), "{at:?}");
assert!((64.0..65.0).contains(&at[1]), "{at:?}");
assert!((-7.0..-6.0).contains(&at[2]), "{at:?}");
assert_eq!(v, [0.0; 3]);
let (_, v) = spread(&mut r, 2, [0.0; 3], [0.0; 3], [0.5, 0.0, 2.0]);
assert!((0.0..0.5).contains(&v[0]) && v[1] == 0.0 && (0.0..2.0).contains(&v[2]));
let (at, _) = spread(&mut r, 0, [0.0; 3], [1.0; 3], [0.0; 3]);
below |= at.iter().any(|&c| c < 0.0);
}
assert!(below, "DEFAULT is a Gaussian around the position");
}
}
+4 -5
View File
@@ -548,11 +548,10 @@ impl Game<'_> {
let (xd, zd) = sweep.knockback;
let attacker = self.player_attacker(host, sweep.attacker);
let land = |g: &mut Self| {
let hurt = g.mob_hits_player(sweep.damage, sweep.from, "minecraft:player_attack");
if hurt {
if let Some((id, name)) = &attacker {
g.note_named_attacker("minecraft:player_attack", *id, name.clone());
}
if let Some((id, name)) = &attacker {
g.note_named_attacker("minecraft:player_attack", *id, name.clone());
}
if g.mob_hits_player(sweep.damage, sweep.from, "minecraft:player_attack") {
g.knock_back_player(0.4, xd, zd);
}
};
+35 -21
View File
@@ -20,7 +20,7 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
/// A guest's open workstation menu.
pub(super) struct GuestStation {
window: i32,
pub(super) window: i32,
state: i32,
kind: StationKind,
pos: [i32; 3],
@@ -34,9 +34,9 @@ pub(super) struct GuestStation {
/// A guest's open lectern: `LecternMenu` over the block entity, made on the
/// menu engine for each click (`Game::lectern_menu`).
pub(super) struct GuestLectern {
window: i32,
pub(super) window: i32,
state: i32,
key: (i32, i32, i32),
pub(super) key: (i32, i32, i32),
/// The book and page the client was last sent (`broadcastChanges`).
sent: Option<(Option<ItemStack>, i32)>,
}
@@ -69,10 +69,8 @@ impl Game<'_> {
let Some((menu_type, kind, _)) = menu_of(station.kind) else {
return false;
};
self.lan_station_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
// The container it replaces loses its opener.
self.guest_container_closed(host, conn);
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable(station.kind.title_key(), &[]);
let Some(window) = host.hub.open_window(conn, menu_type, title) else {
return true;
@@ -151,9 +149,8 @@ impl Game<'_> {
/// `LecternBlock.openScreen` for a guest: `minecraft:lectern` over the
/// block entity, with its book and page.
pub(super) fn lan_open_lectern(&mut self, host: &mut LanHost, conn: u32, key: (i32, i32, i32)) {
self.lan_station_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
self.guest_container_closed(host, conn);
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable("container.lectern", &[]);
let Some(window) = host.hub.open_window(conn, "minecraft:lectern", title) else {
return;
@@ -644,10 +641,7 @@ impl Game<'_> {
let Some(gs) = host.play.get(&conn).and_then(|p| p.station.as_ref()) else {
continue;
};
let [x, y, z] = gs.pos;
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
let block = StationKind::from_block(name) == Some(gs.kind);
if !(block && g.within_block_range(gs.pos, 4.0)) {
if !self.lan_station_valid(g, gs) {
let window = gs.window;
self.lan_station_closed(host, conn, Some(window));
host.hub.send_to(
@@ -669,6 +663,32 @@ impl Game<'_> {
}
impl Game<'_> {
/// A station menu's `stillValid`: its block is still that station and the
/// guest is within `isWithinBlockInteractionRange(pos, 4)`.
pub(super) fn lan_station_valid(
&self,
g: &horizon_client::lan::guest::Guest,
gs: &GuestStation,
) -> bool {
let [x, y, z] = gs.pos;
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
StationKind::from_block(name) == Some(gs.kind) && g.within_block_range(gs.pos, 4.0)
}
/// `LecternMenu.stillValid` (`Container.stillValidBlockEntity`): the block
/// entity still holds a book and the guest is within the interaction
/// range and its buffer.
pub(super) fn lan_lectern_valid(
&self,
g: &horizon_client::lan::guest::Guest,
key: (i32, i32, i32),
) -> bool {
matches!(
self.world.block_entities.get(&key),
Some(BlockEntity::Lectern(l)) if l.book.is_some()
) && g.within_block_range([key.0, key.1, key.2], 4.0)
}
/// The guests' lecterns each tick: `stillValid` (the block entity, its
/// book, the reach) closes them, and `broadcastChanges` sends a book or
/// page another player changed.
@@ -685,13 +705,7 @@ impl Game<'_> {
if !host.hub.here(g) {
continue;
}
let has_book = matches!(
self.world.block_entities.get(&key),
Some(BlockEntity::Lectern(l)) if l.book.is_some()
);
// Container.stillValidBlockEntity: the interaction range and its buffer.
let near = g.within_block_range([key.0, key.1, key.2], 4.0);
if has_book && near {
if self.lan_lectern_valid(g, key) {
self.lan_send_lectern(host, conn, false);
} else {
self.lan_station_closed(host, conn, Some(window));
+28 -20
View File
@@ -57,9 +57,8 @@ impl Game<'_> {
state.session.update_special_prices();
}
let title = self.merchant_display_name(&state);
// The container or workstation the trading screen replaces is closed.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let Some(window) = host.hub.open_merchant_window(conn, title) else {
self.merchant_closed(&mut state);
return;
@@ -350,28 +349,37 @@ impl Game<'_> {
self.merchant_closed(&mut gm.merchant);
true
}
/// `Merchant.stillValid` for a trader: it is alive and the guest is within
/// the entity interaction range plus 4 of its box.
pub(super) fn lan_merchant_valid(
&self,
g: &horizon_client::lan::guest::Guest,
id: u32,
) -> bool {
self.mobs
.iter()
.find(|m| m.id == id)
.is_some_and(|m| m.alive() && guest_within_entity_range(g, &m.bounds(), 4.0))
}
/// `Villager.customServerAiStep` for the guests' trading villagers: the
/// TRADE reputation event, then the prices follow.
pub(super) fn lan_merchant_tick(&mut self, host: &mut LanHost) {
// `AbstractVillager.stillValid`: the trade screen closes once the trader
// is gone or dead, or the guest is more than 4 blocks past its reach.
let far: Vec<(u32, i32)> =
host.play
.iter()
.filter_map(|(conn, p)| {
let gm = p.merchant.as_ref()?;
let id = gm.merchant.mob?;
let g = host
.hub
.guests
.iter()
.find(|g| g.conn == *conn && host.hub.here(g))?;
let valid = self.mobs.iter().find(|m| m.id == id).is_some_and(|m| {
m.alive() && guest_within_entity_range(g, &m.bounds(), 4.0)
});
(!valid).then_some((*conn, gm.window))
})
.collect();
let far: Vec<(u32, i32)> = host
.play
.iter()
.filter_map(|(conn, p)| {
let gm = p.merchant.as_ref()?;
let id = gm.merchant.mob?;
let g = host
.hub
.guests
.iter()
.find(|g| g.conn == *conn && host.hub.here(g))?;
(!self.lan_merchant_valid(g, id)).then_some((*conn, gm.window))
})
.collect();
for (conn, window) in far {
if self.lan_merchant_closed(host, conn, Some(window)) {
host.hub.send_to(
+31 -5
View File
@@ -246,7 +246,14 @@ impl Game<'_> {
return true;
}
}
if self.mobs[index].extra.leash == Some(Holder::Player) {
// Mob.checkAndHandleImportantInteractions: only the player the lead
// is tied to unties it (`getLeashHolder() == player`).
if self.mobs[index].extra.leash == Some(Holder::Player)
&& self.mobs[index]
.extra
.leash_player
.is_none_or(|p| p == self.acting_player_uuid())
{
let creative = self.creative;
self.break_leash(id, !creative, false);
self.lead_game_event("minecraft:entity_interact", at);
@@ -301,12 +308,31 @@ impl Game<'_> {
.filter(|&i| self.mobs[i].extra.leash == Some(Holder::Knot(knot_pos)))
.collect();
let existing_boats = self.boats_on(Holder::Knot(knot_pos));
// `LeashFenceKnotEntity.interact` runs only when the ray meets the knot
// before the post: the knot's box (`recalculateBoundingBox`, 0.375 wide,
// y + 0.375 to y + 0.875) is wider than the post, so a hit point on the
// post inside that box went through the knot first. Any other click on
// the fence is `FenceBlock.useWithoutItem` / `LeadItem.useOn`, which only
// ties the player's own mobs (`LeadItem.bindPlayerMobs`).
let knot_hit = !(existing.is_empty() && existing_boats.is_empty()) && {
let box_ = leash::knot_state(knot_pos);
let half = box_.width / 2.0 + 1.0e-6;
(hit.point[0] - box_.position.x).abs() <= half
&& (hit.point[2] - box_.position.z).abs() <= half
&& hit.point[1] >= box_.position.y - 1.0e-6
&& hit.point[1] <= box_.position.y + box_.height + 1.0e-6
// The knot is an entity: a hit on it counts only within the
// entity interaction range (`filterHitResult`), shorter than
// the block range the fence was hit at.
&& self.eye_position().is_some_and(|eye| {
let d = [0, 1, 2].map(|i| hit.point[i] - eye[i]);
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] <= self.entity_reach * self.entity_reach
})
};
let held_item = self.held().map(|s| s.item.to_string());
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
// Shears snip every lead on the knot.
if held_item.as_deref() == Some("minecraft:shears")
&& !(existing.is_empty() && existing_boats.is_empty())
{
if knot_hit && held_item.as_deref() == Some("minecraft:shears") {
for i in existing {
let id = self.mobs[i].id;
self.break_leash(id, true, false);
@@ -335,7 +361,7 @@ impl Game<'_> {
attached = true;
}
}
if !attached && !sneaking {
if !attached && !sneaking && knot_hit {
let player = self.player_center();
let holder = self.acting_player_uuid();
for i in existing {
@@ -82,6 +82,13 @@ impl Game<'_> {
}
}
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
// A mod block the level answered bone meal for: the port knows no
// `BonemealableBlock.Type` for it, and vanilla's `GROWER` is the
// one a block growing itself has, so the sparks stay inside it
// rather than spreading over its neighbours.
None if !facts.get(state).name.starts_with("minecraft:") => {
(p, count, 0.5, self.block_shape_top(p), true)
}
None => return,
};
self.spawn_particles(at, count, xz, height, floating, ParticleType::HappyVillager);
+17 -12
View File
@@ -411,18 +411,23 @@ impl Game<'_> {
}
self.update_dedicated_streaming();
self.update_world();
result = if paused {
self.tick_lan(true);
Ok(())
} else if k == 0 {
self.tick()
} else {
let running = running.unwrap_or_else(|| self.clock.world_runs());
if running {
self.tick_weather_effects();
}
self.tick_world(running)
};
result = horizon_client::block_contact::in_level(
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(dim.id()),
|| {
if paused {
self.tick_lan(true);
Ok(())
} else if k == 0 {
self.tick()
} else {
let running = running.unwrap_or_else(|| self.clock.world_runs());
if running {
self.tick_weather_effects();
}
self.tick_world(running)
}
},
);
if result.is_err() {
break;
}
+1 -1
View File
@@ -47,7 +47,7 @@ impl Game<'_> {
pub(super) fn tick_thunder(&mut self) {
// isRaining (rain level > 0.2) and isThundering (> 0.9, sky light, no ceiling).
let (rain, thunder) = self.world.weather.levels(1.0);
if rain <= 0.2 || thunder <= 0.9 || self.dim != super::Dim::Overworld {
if rain <= 0.2 || thunder <= 0.9 || self.dim != super::Dim::Overworld || self.world_debug {
return;
}
let mut scratch = std::mem::take(&mut self.spawn_scratch);
@@ -183,7 +183,7 @@ impl Game<'_> {
}
let state = horizon_world::chunk::BlockState {
name: name.to_owned(),
properties,
properties: properties.into(),
};
match self.pack.assets.registry.resolve(&state) {
Ok(id) => self.level_set(pos, id).is_ok(),
+136 -2
View File
@@ -33,6 +33,9 @@ pub(super) enum CartKind {
/// `MinecartCommandBlock`: its `BaseCommandBlock` is `Minecart::command`,
/// run by a powered activator rail (`game/command_minecarts.rs`).
CommandBlock,
/// `MinecartSpawner`: a `BaseSpawner` that ticks where the cart is
/// (`Minecart::spawner`). Only `/summon` makes one: there is no item.
Spawner,
}
impl CartKind {
pub fn from_id(id: &str) -> Option<Self> {
@@ -43,6 +46,7 @@ impl CartKind {
"furnace_minecart" => Self::Furnace,
"tnt_minecart" => Self::Tnt,
"command_block_minecart" => Self::CommandBlock,
"spawner_minecart" => Self::Spawner,
_ => return None,
})
}
@@ -54,6 +58,15 @@ impl CartKind {
Self::Furnace => "minecraft:furnace_minecart",
Self::Tnt => "minecraft:tnt_minecart",
Self::CommandBlock => "minecraft:command_block_minecart",
Self::Spawner => "minecraft:spawner_minecart",
}
}
/// `AbstractMinecart.getDropItem` and `getPickResult`: the item a cart drops and
/// is picked as (a spawner minecart's is a plain minecart: there is no item of its own).
pub fn item(self) -> &'static str {
match self {
Self::Spawner => "minecraft:minecart",
kind => kind.id(),
}
}
pub(super) fn slots(self) -> usize {
@@ -70,7 +83,7 @@ impl CartKind {
/// an empty cart.
pub fn interact_passes(self, sneaking: bool, ridden: bool, game_master: bool) -> bool {
match self {
Self::Tnt => true,
Self::Tnt | Self::Spawner => true,
Self::CommandBlock => !game_master,
Self::Rideable => sneaking || ridden,
Self::Chest | Self::Hopper | Self::Furnace => false,
@@ -119,6 +132,9 @@ pub(super) struct Minecart {
/// An activator rail asked for the command during this tick; it runs once
/// the cart is back in the level (`command_minecarts.rs`).
pub command_due: bool,
/// Spawner minecarts: the cart's `BaseSpawner` (`Delay`, `SpawnData`,
/// `SpawnPotentials` and the rest as a spawner block saves them).
pub spawner: Option<Box<horizon_client::mobs::spawner::Spawner>>,
/// The synched `DATA_ID_COMMAND_NAME` and `DATA_ID_LAST_OUTPUT`, which
/// follow the command block only at `onUpdated`.
pub synced_command: String,
@@ -255,6 +271,8 @@ impl Minecart {
tick_count: 0,
last_activated: 0,
command_due: false,
// A fresh `BaseSpawner`, as `MinecartSpawner`'s constructor makes.
spawner: (kind == CartKind::Spawner).then(Default::default),
synced_command: String::new(),
synced_output: String::new(),
}
@@ -293,6 +311,7 @@ impl CartModels {
CartKind::Furnace,
CartKind::Tnt,
CartKind::CommandBlock,
CartKind::Spawner,
];
let models = kinds
.into_iter()
@@ -906,6 +925,18 @@ impl Game<'_> {
}
}
}
// MinecartSpawner.tick: `BaseSpawner.serverTick` at the cart's block.
CartKind::Spawner => {
if let Some(mut spawner) = c.spawner.take() {
let key = (
c.position.x.floor() as i32,
c.position.y.floor() as i32,
c.position.z.floor() as i32,
);
let _ = self.server_tick_spawner_of(key, &mut spawner, Some(c.id));
c.spawner = Some(spawner);
}
}
_ => {}
}
true
@@ -963,6 +994,7 @@ impl Game<'_> {
if let Some(i) = self.carts.iter().position(|x| x.id == c.id) {
self.eject_cart_mobs(i);
}
self.release_vehicle_container(c.id);
self.carts.retain(|x| x.id != c.id);
// ExplosionInteraction.TNT: the tnt_explosion_drop_decay rule.
let interaction = self.destroy_type("tnt_explosion_drop_decay");
@@ -1226,6 +1258,7 @@ impl Game<'_> {
}
} else {
self.eject_cart_mobs(i);
self.release_vehicle_container(id);
self.carts.remove(i);
}
}
@@ -1331,6 +1364,7 @@ impl Game<'_> {
self.push_from_cart(i);
} else {
self.eject_cart_mobs(i);
self.release_vehicle_container(c.id);
self.carts.remove(i);
self.riding = None;
}
@@ -1539,6 +1573,8 @@ impl Game<'_> {
// client opens the editor, `open_cart_command_block`), anyone else
// PASSes to the held item. SUCCESS swings on the client only.
CartKind::CommandBlock => return self.can_use_game_master_blocks(),
// MinecartSpawner has no interact of its own (`Entity.interact` passes).
CartKind::Spawner => return false,
}
self.swing_arm();
true
@@ -1577,6 +1613,19 @@ impl Game<'_> {
self.store_cart_items(key);
self.world.block_entities.remove(&key);
}
/// A chest or hopper minecart or a chest boat is about to be removed
/// (`Containers.dropContents` / `AbstractChestBoat.remove`): what an open
/// menu edited goes back to the vehicle, so it is what drops or travels
/// with it, and the menu's stand-in leaves the level. A menu that is
/// still open over it then has nothing left to take.
pub(super) fn release_vehicle_container(&mut self, id: u32) {
let key = (i32::MAX - 15, CART_KEY_Y, id as i32);
self.store_cart_items(key);
self.world.block_entities.remove(&key);
if self.container == Some(key) {
self.container = None;
}
}
/// The menu of a chest or hopper minecart or a chest boat works on the
/// vehicle's own items (`ContainerEntity`): the placeholder under `key`
/// that the menu edits is copied back to the vehicle.
@@ -1644,6 +1693,8 @@ impl Game<'_> {
}
if burning && self.carts[i].kind == CartKind::Tnt {
self.eject_cart_mobs(i);
let id = self.carts[i].id;
self.release_vehicle_container(id);
let c = self.carts.remove(i);
self.explode_cart(&c, arrow_velocity.length_squared());
return;
@@ -1669,6 +1720,10 @@ impl Game<'_> {
if !tnt_prime {
self.eject_cart_mobs(i);
}
let id = self.carts[i].id;
if !tnt_prime {
self.release_vehicle_container(id);
}
let mut c = self.carts.remove(i);
if c.kind == CartKind::Tnt && (fire || speed >= f64::from(0.01f32)) {
// MinecartTNT.destroy: prime instead of breaking.
@@ -1685,7 +1740,7 @@ impl Game<'_> {
self.spawn_item(s.clone(), at, [0.0, 0.1, 0.0], 10);
}
if !creative {
self.spawn_item(ItemStack::new(c.kind.id(), 1), at, [0.0, 0.1, 0.0], 10);
self.spawn_item(ItemStack::new(c.kind.item(), 1), at, [0.0, 0.1, 0.0], 10);
}
}
}
@@ -1805,6 +1860,8 @@ impl Game<'_> {
),
CartKind::Tnt => registry.default_state("minecraft:tnt"),
CartKind::CommandBlock => registry.default_state("minecraft:command_block"),
// MinecartSpawner.getDefaultDisplayBlockState.
CartKind::Spawner => registry.default_state("minecraft:spawner"),
}
}
/// `AbstractMinecartRenderer` (old render): on the rail between the
@@ -2027,6 +2084,16 @@ impl Minecart {
}
}
}
// BaseSpawner.save: the delays, ranges, SpawnData and SpawnPotentials.
CartKind::Spawner => {
if let Some(spawner) = &self.spawner {
for (key, value) in
horizon_client::mobs::spawner::SpawnerExt::save(&**spawner)
{
put(key, value);
}
}
}
CartKind::Rideable => {}
}
// A loaded loot table goes back while the items are still its roll;
@@ -2071,6 +2138,12 @@ impl Minecart {
c.synced_command.clone_from(&c.command.command);
c.synced_output = c.command.last_output_string();
}
// MinecartSpawner.readAdditionalSaveData: BaseSpawner.load.
if kind == CartKind::Spawner {
c.spawner = Some(Box::new(horizon_client::mobs::spawner::SpawnerExt::load(
tag,
)));
}
Some(c)
}
}
@@ -2103,6 +2176,67 @@ mod tests {
assert_eq!(display_offset(CartKind::Rideable), 6.0);
}
/// `MinecartSpawner`: its `BaseSpawner` is saved and loaded with the cart (the delay, the
/// ranges and the `SpawnData`), it drops and is picked as a plain minecart, and it shows a
/// spawner block.
#[test]
fn a_spawner_cart_keeps_its_spawner_and_drops_a_minecart() {
use horizon_world::nbt::Tag;
let saved = Tag::compound([
("id", Tag::String("minecraft:spawner_minecart".into())),
(
"Pos",
Tag::List(
[0.5f64, 40.5, 0.5]
.map(|v| Tag::Double(v.to_bits()))
.to_vec(),
),
),
("Delay", Tag::Short(37)),
("MaxSpawnDelay", Tag::Short(120)),
("SpawnRange", Tag::Short(2)),
(
"SpawnData",
Tag::compound([(
"entity",
Tag::compound([("id", Tag::String("minecraft:zombie".into()))]),
)]),
),
]);
let mut cart = Minecart::from_nbt(&saved, 1, CartKind::Spawner).unwrap();
let spawner = cart.spawner.as_mut().expect("a spawner cart has a spawner");
assert_eq!(
(
spawner.spawn_delay,
spawner.max_spawn_delay,
spawner.spawn_range
),
(37, 120, 2)
);
// No raw tag to copy from: what comes out is the cart's own save.
spawner.spawn_delay = 41;
let out = cart.to_nbt([0; 4], None);
assert_eq!(out.get("Delay"), Some(&Tag::Short(41)));
assert_eq!(out.get("MaxSpawnDelay"), Some(&Tag::Short(120)));
assert_eq!(
out.get("SpawnData")
.and_then(|d| d.get("entity"))
.and_then(|e| e.get("id")),
Some(&Tag::String("minecraft:zombie".into()))
);
assert_eq!(CartKind::Spawner.item(), "minecraft:minecart");
assert_eq!(
CartKind::from_id("minecraft:spawner_minecart"),
Some(CartKind::Spawner)
);
// A summoned one has a spawner of its own.
assert!(
Minecart::new(2, CartKind::Spawner, Vec3::ZERO)
.spawner
.is_some()
);
}
#[test]
fn a_cow_boards_an_empty_cart_and_an_iron_golem_does_not() {
assert!(pickup_instead_of_push(Species::Cow, false, false));
+21 -27
View File
@@ -14,10 +14,7 @@ use horizon_client::{
mobs::{
Mob, Species,
equip::{self, Facts, ItemTags, Slot},
finalize::{
self, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments,
stored_drop_chance,
},
finalize::{self, set_drop_chance, set_item_stack, stored_drop_chance},
},
};
use horizon_core::{geometry::Vec3, random::RandomSource};
@@ -92,27 +89,29 @@ impl Game<'_> {
if !replace {
return None;
}
// shortcut: a mob's equipment is one item per slot here, so a hand
// takes one item of a stack where vanilla takes the whole stack;
// upgrade with a per-slot count in `Extra`.
let taken = 1;
// `EquipmentSlot.limit`: an armour slot takes one item, a hand the
// whole stack.
let taken = if slot.is_armor() {
1
} else {
stack.count.max(1)
};
let m = &mut self.mobs[index];
let old = finalize::item(m, slot.index());
let old = finalize::item_stack(m, slot.index());
let chance = stored_drop_chance(m, slot.index());
let old_enchantments = slot_enchantments(m, slot.index());
// `DropChances.byEquipment` against the roll the equip makes.
let drop_old = old.is_some() && (m.random.next_float() - 0.1).max(0.0) < chance;
set_item(m, slot.index(), item);
// The stack's enchantments come with it: a Sharpness V sword stays sharp.
set_slot_enchantments(m, slot.index(), stack.enchantments().to_vec());
// The whole stack comes with it: its enchantments (a Sharpness V sword
// stays sharp), damage, name and components.
let mut equipped = stack.clone();
equipped.count = taken;
set_item_stack(m, slot.index(), equipped);
// `setItemSlotAndDropWhenKilled`, then `persistenceRequired`.
set_drop_chance(m, slot.index(), 2.0);
m.persistent = true;
let at = [m.position.x, m.position.y, m.position.z];
if drop_old && let Some(old) = old {
let mut dropped = ItemStack::new(&old, 1);
dropped.set_crafting_enchantments(old_enchantments);
self.spawn_item(dropped, at, super::dispenser::new_item_motion(), 10);
self.spawn_item(old, at, super::dispenser::new_item_motion(), 10);
}
Some(taken)
}
@@ -120,9 +119,10 @@ impl Game<'_> {
/// `Mob.canReplaceCurrentItem` for what `m` wears or holds in `slot`.
fn can_replace(&self, index: usize, stack: &ItemStack, slot: Slot) -> bool {
let m = &self.mobs[index];
let Some(current) = finalize::item(m, slot.index()) else {
let Some(worn_stack) = finalize::item_stack(m, slot.index()) else {
return true;
};
let current = worn_stack.item.clone();
let species = m.species();
// `Drowned.canReplaceCurrentItem` never parts with a nautilus shell.
if species == Species::Drowned && &*current == "minecraft:nautilus_shell" {
@@ -140,21 +140,15 @@ impl Game<'_> {
// Binding's, the only enchantment that carries one.
prevents_armor_change: stack.enchantment_level("minecraft:binding_curse") > 0,
};
let worn_enchantments = slot_enchantments(m, slot.index());
let (armor, toughness, attack) = self.attributes(m, &current, slot);
// shortcut: what a mob already wears keeps no damage value or name here
// (`Extra` records neither), so a worn piece always compares as fresh;
// upgrade with a stack per equipment slot.
let worn = Facts {
armor,
armor_toughness: toughness,
attack_damage: attack,
enchantments: worn_enchantments.len(),
damage: 0,
custom_name: false,
prevents_armor_change: worn_enchantments
.iter()
.any(|(id, level)| &**id == "minecraft:binding_curse" && *level > 0),
enchantments: worn_stack.enchantments().len(),
damage: worn_stack.damage as i32,
custom_name: worn_stack.custom_name().is_some(),
prevents_armor_change: worn_stack.enchantment_level("minecraft:binding_curse") > 0,
};
let preferred = equip::preferred_weapon_tag(species).map(|tag| {
(
@@ -319,6 +319,16 @@ impl Game<'_> {
v.extend((0..open.columns * 3).map(|i| CHEST + i));
Some(v)
}
/// How many slots `mount_slots` lists, counted without building it
/// (`Game::slot_count` asks every frame).
pub(super) fn mount_slot_count(&self) -> Option<usize> {
let open = self
.mount_menu
.open
.as_ref()
.filter(|_| self.mount_active())?;
Some(36 + usize::from(open.show_saddle) + usize::from(open.show_armor) + open.columns * 3)
}
pub(super) fn mount_slot_position(&self, i: usize) -> Option<(f32, f32)> {
let open = self
.mount_menu
+25 -6
View File
@@ -17,6 +17,15 @@ use horizon_core::{
random::RandomSource,
};
fn steering_item(species: Species, main: Option<&str>, offhand: Option<&str>) -> bool {
let item = match species {
Species::Pig => "minecraft:carrot_on_a_stick",
Species::Strider => "minecraft:warped_fungus_on_a_stick",
_ => return false,
};
main == Some(item) || offhand == Some(item)
}
/// `passengerAttachments` minus the player's 0.6 vehicle attachment.
fn seat(s: Species) -> f64 {
let attach = match s {
@@ -367,12 +376,11 @@ impl Game<'_> {
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
// LocalPlayer.aiStep: the riding jump charges while space is held.
let held = self.held().map(|s| s.item.clone());
let steer = match self.mobs[i].species() {
Species::Pig => held.as_deref() == Some("minecraft:carrot_on_a_stick"),
Species::Strider => held.as_deref() == Some("minecraft:warped_fungus_on_a_stick"),
_ => false,
};
let steer = steering_item(
self.mobs[i].species(),
self.held().map(|s| s.item.as_ref()),
self.offhand.as_ref().map(|s| s.item.as_ref()),
);
let jump = self.charge_mount_jump(i, keys);
let m = &mut self.mobs[i];
if jump > 0.0 {
@@ -839,6 +847,17 @@ mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn steering_accepts_the_correct_item_in_either_hand() {
let carrot = "minecraft:carrot_on_a_stick";
let fungus = "minecraft:warped_fungus_on_a_stick";
assert!(steering_item(Species::Pig, Some(carrot), None));
assert!(steering_item(Species::Pig, None, Some(carrot)));
assert!(steering_item(Species::Strider, None, Some(fungus)));
assert!(!steering_item(Species::Pig, Some(fungus), None));
assert!(!steering_item(Species::Horse, Some(carrot), Some(fungus)));
}
/// A lava sea up to y 30 with full blocks placed in it; air above.
struct Sea {
blocks: HashMap<[i32; 3], ()>,
+4
View File
@@ -10,6 +10,10 @@ use horizon_client::{
impl Game<'_> {
/// `Minecraft.tick` -> `MusicManager.tick`, once per client tick.
pub(super) fn tick_music(&mut self) {
// Splitscreen: one music, P1's.
if self.options.split_guest {
return;
}
let (music, volume) = self.situational_music();
// Options.musicFrequency's listener: MusicManager.setMinutesBetweenSongs.
let frequency = MusicFrequency::parse(&self.settings.file.name("musicFrequency"));
+6 -4
View File
@@ -287,13 +287,15 @@ impl Game<'_> {
lift = f64::from(9.0f32 * 1.15 * 0.025);
}
lines.push((shown, lift));
// The tag rides with the body, between last tick's position and this one's.
let at = a.render_position(alpha);
let l = self.world.light(
a.position.x.floor() as i32,
(a.position.y + height).floor() as i32,
a.position.z.floor() as i32,
at.x.floor() as i32,
(at.y + height).floor() as i32,
at.z.floor() as i32,
);
tags.push(NameTag {
at: glam::DVec3::new(a.position.x, a.position.y + height + 0.5, a.position.z),
at: glam::DVec3::new(at.x, at.y + height + 0.5, at.z),
lines,
light: l,
discrete: false,
@@ -0,0 +1,391 @@
//! Mod block entities drawn on the client: `native_block_entity_submit`
//! (vanilla's `BlockEntityRenderer.submit`) gets a [`BlockEntityDraw`], whose
//! pose stack (`PoseStack`) places what the helpers here add to the entity
//! batch, as vanilla's block entity renderers draw.
use super::{Dim, Game};
use glam::{DVec3, Mat4, Quat, Vec3};
use horizon_assets::{
bake::{bake_flat_face, default_uv},
model::{Direction, Material},
};
use horizon_client::{
block_entity_render::{diffuse, diffuse_lights, vertex_light},
containers::BlockEntity,
gfx::Cull,
hud::Rect,
mesher::Vertex,
};
use horizon_world::registry::BlockStateId;
/// `BlockEntityRenderer.getViewDistance`'s default, in blocks.
const VIEW_DISTANCE: f64 = 64.0;
/// A mod block entity's bounds until its mod says otherwise: the block with
/// the block of margin Horizon culls vanilla's block entities with.
const DEFAULT_BOUNDS: [f32; 6] = [-1.0, -1.0, -1.0, 2.0, 2.0, 2.0];
/// Whether `bounds` (min then max, in blocks from `origin`, the block's corner
/// relative to the camera) meet the frustum; an infinite bound always does
/// (NeoForge's `INFINITE_EXTENT_AABB`), as does a frame without a frustum.
fn in_frustum(frustum: Option<&impl Cull>, origin: Vec3, bounds: [f32; 6]) -> bool {
if bounds.iter().any(|b| !b.is_finite()) {
return true;
}
let min = origin + Vec3::new(bounds[0], bounds[1], bounds[2]);
let max = origin + Vec3::new(bounds[3], bounds[4], bounds[5]);
frustum.is_none_or(|f| f.intersects(min, max))
}
/// One mod block entity's frame, as `submit` gets it: the render state's
/// partial tick, game time and light, and the pose stack the helpers draw
/// under (`native_draw_quad`, `native_draw_cube`, `native_draw_block`).
// Native mods read and drive it; nothing in Horizon does.
#[allow(dead_code)]
pub struct BlockEntityDraw {
/// The frame's partial tick, 0 to 1 (`BlockEntityRenderState`).
pub partial: f32,
/// The level's game time, for animations.
pub game_time: i64,
/// The light the helpers draw with: the block's own
/// (`LevelRenderer.getLightCoords`), 16 times sky light in bits 0..8 and
/// 16 times block light in bits 8..16; set it to light a part otherwise.
pub light: u32,
poses: Vec<Mat4>,
lights: (Vec3, Vec3),
vertices: Vec<Vertex>,
}
#[allow(dead_code)]
impl BlockEntityDraw {
/// `PoseStack.pushPose`: `pop` comes back to the pose as it is now.
pub fn push(&mut self) {
let pose = self.pose();
self.poses.push(pose);
}
/// `PoseStack.popPose`; the block's own pose is never popped.
pub fn pop(&mut self) {
if self.poses.len() > 1 {
self.poses.pop();
}
}
/// Moves what is drawn next by `by`, in blocks.
pub fn translate(&mut self, by: [f32; 3]) {
self.apply(Mat4::from_translation(Vec3::from_array(by)));
}
/// Turns what is drawn next by `degrees` about `axis` (counterclockwise
/// seen from the axis' positive end, as `Axis.rotationDegrees`) through
/// `pivot`, in blocks from the pose's origin: `[0.5; 3]` is the block's centre.
pub fn rotate_about(&mut self, axis: [f32; 3], degrees: f32, pivot: [f32; 3]) {
let axis = Vec3::from_array(axis).normalize_or(Vec3::Y);
let pivot = Vec3::from_array(pivot);
self.apply(
Mat4::from_translation(pivot)
* Mat4::from_quat(Quat::from_axis_angle(axis, degrees.to_radians()))
* Mat4::from_translation(-pivot),
);
}
/// Scales what is drawn next by `by` about the pose's origin.
pub fn scale(&mut self, by: [f32; 3]) {
self.apply(Mat4::from_scale(Vec3::from_array(by)));
}
fn apply(&mut self, m: Mat4) {
if let Some(pose) = self.poses.last_mut() {
*pose *= m;
}
}
fn pose(&self) -> Mat4 {
self.poses.last().copied().unwrap_or(Mat4::IDENTITY)
}
/// A quad of `corners` (blocks, under the pose) with unit UVs, shaded as
/// vanilla's block entity models are (`Lighting`'s two lights).
fn quad(&mut self, corners: [[f32; 3]; 4], uv: [[f32; 2]; 4], sprite: Rect, tint: u32) {
let pose = self.pose();
let at = corners.map(|c| pose.transform_point3(Vec3::from_array(c)));
let normal = (at[1] - at[0]).cross(at[2] - at[0]).normalize_or_zero();
let normal = if normal == Vec3::ZERO {
(at[2] - at[1]).cross(at[3] - at[1]).normalize_or(Vec3::Y)
} else {
normal
};
let color = shaded(tint, diffuse(normal, self.lights));
for (p, uv) in at.into_iter().zip(uv) {
self.vertices.push(Vertex {
pos: p.to_array(),
uv: [
sprite.u0 + (sprite.u1 - sprite.u0) * uv[0],
sprite.v0 + (sprite.v1 - sprite.v0) * uv[1],
],
layer: 0,
color,
light: self.light,
normal: [0; 4],
glint_uv: [0.0, 0.0],
});
}
}
}
/// One face of a `native_draw_cube` box: its sprite (`mymod:entity/cog`,
/// `mymod:block/casing`) and its UVs in texels of 16 per side, as a model
/// face's `uv` (none takes the box's own, as a model's default).
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct CubeFace<'a> {
pub sprite: &'a str,
pub uv: Option<[f32; 4]>,
}
/// 0xRRGGBB `tint` times `brightness`, as an opaque vertex colour.
fn shaded(tint: u32, brightness: f32) -> u32 {
let c = |shift: u32| (((tint >> shift) & 255) as f32 * brightness) as u32;
0xFF00_0000 | c(0) << 16 | c(8) << 8 | c(16)
}
impl Game<'_> {
/// `native_block_entity_submit` for each of `blocks` (in drawn sections,
/// drawn by no vanilla renderer) whose block entity is a mod's, closer than
/// `BlockEntityRenderer.getViewDistance` to the camera (`shouldRender`)
/// and with its bounds (`native_block_entity_render_bounding_box`) in the
/// frustum.
pub(super) fn mod_block_entity_geometry<'a>(
&self,
blocks: impl Iterator<Item = &'a ([i32; 3], u32)>,
camera: DVec3,
partial: f32,
out: &mut Vec<Vertex>,
) {
let mut draw = BlockEntityDraw {
partial,
game_time: self.world.game_time,
light: 0,
poses: Vec::new(),
lights: diffuse_lights(self.dim == Dim::Nether),
vertices: std::mem::take(out),
};
for &(pos, state) in blocks {
let corner = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
// Vec3.closerThan: strictly closer.
if (corner + 0.5).distance_squared(camera) >= VIEW_DISTANCE * VIEW_DISTANCE {
continue;
}
let Some(BlockEntity::Mod(entity)) =
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
else {
continue;
};
let id = BlockStateId(state);
let block = &*self.res.facts().get(id).name;
let origin = (corner - camera).as_vec3();
let mut bounds = DEFAULT_BOUNDS;
self.native_block_entity_render_bounding_box(pos, block, id, entity, &mut bounds);
if !in_frustum(self.view_frustum.as_ref(), origin, bounds) {
continue;
}
let start = draw.vertices.len();
self.mark_block_entity(start, state);
draw.poses.clear();
draw.poses.push(Mat4::from_translation(origin));
draw.light = vertex_light(self.world.light(pos[0], pos[1], pos[2]));
self.native_block_entity_submit(pos, block, id, entity, &mut draw);
self.cull_entity_vertices(&mut draw.vertices, start);
}
*out = draw.vertices;
}
}
/// Helpers for drawing mod block entities, kept as stable as the hook points.
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// Draws a quad with `corners` in blocks under `draw`'s pose, in a model
/// face's order (top left, bottom left, bottom right, top right as seen
/// from its front), textured with the `uv` rectangle (u0, v0, u1, v1 in
/// texels of 16 per side) of `sprite` (`mymod:entity/screen`,
/// `mymod:block/casing`, `block/stone`) times the 0xRRGGBB `tint`
/// (0xFFFFFF for none). False for an unknown sprite. API 71.
pub(super) fn native_draw_quad(
&self,
draw: &mut BlockEntityDraw,
sprite: &str,
corners: [[f32; 3]; 4],
uv: [f32; 4],
tint: u32,
) -> bool {
let Some(rect) = self.gui.atlas.sprites.get(sprite).copied() else {
return false;
};
let [u0, v0, u1, v1] = uv.map(|t| t / 16.0);
draw.quad(
corners,
[[u0, v0], [u0, v1], [u1, v1], [u1, v0]],
rect,
tint,
);
true
}
/// Draws a box from `from` to `to` (texels of 16 per block, as a block
/// model's element) under `draw`'s pose, with the faces given, by
/// `Direction` (down, up, north, south, west, east), baked as a model
/// element's are (`FaceBakery`), times the 0xRRGGBB `tint`. A turned box
/// is drawn under a turned pose (`BlockEntityDraw::rotate_about`). False
/// when a face's sprite is unknown; that face is left out. API 71.
pub(super) fn native_draw_cube(
&self,
draw: &mut BlockEntityDraw,
from: [f32; 3],
to: [f32; 3],
faces: [Option<CubeFace<'_>>; 6],
tint: u32,
) -> bool {
let mut all = true;
for (direction, face) in Direction::ALL.into_iter().zip(faces) {
let Some(face) = face else { continue };
let Some(rect) = self.gui.atlas.sprites.get(face.sprite).copied() else {
all = false;
continue;
};
let uv = face.uv.unwrap_or_else(|| default_uv(from, to, direction));
let material = Material {
sprite: String::new(),
force_translucent: false,
};
if let Ok(quad) = bake_flat_face(from, to, uv, direction, material, -1) {
draw.quad(quad.positions, quad.uv, rect, tint);
}
}
all
}
/// Draws block `block`'s model (`mymod:cog`, any block with a model) in
/// the state with `properties` (`[("axis", "x")]`, the rest default)
/// under `draw`'s pose, as vanilla's `submitBlockModel` draws a block in
/// an entity's place: a block's baked model, with its default tints. A
/// moving part is often a block of the mod's own, never placed. False for
/// an unknown block or property. API 71.
pub(super) fn native_draw_block(
&self,
draw: &mut BlockEntityDraw,
block: &str,
properties: &[(&str, &str)],
) -> bool {
let registry = &self.pack.assets.registry;
let Some(mut state) = registry
.default_state(block)
.and_then(|id| registry.state(id))
.cloned()
else {
return false;
};
for (key, value) in properties {
match state.properties.get_mut(*key) {
Some(v) => *v = (*value).to_owned(),
None => return false,
}
}
let Some(id) = registry.id(&state) else {
return false;
};
let pose = draw.pose();
self.block_model_vertices(id, 42, pose, draw.light, true, &mut draw.vertices);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn draw() -> BlockEntityDraw {
BlockEntityDraw {
partial: 0.0,
game_time: 0,
light: 0xF0F0,
poses: vec![Mat4::from_translation(Vec3::new(10.0, 0.0, 0.0))],
lights: diffuse_lights(false),
vertices: Vec::new(),
}
}
fn sprite() -> Rect {
Rect {
u0: 0.5,
v0: 0.25,
u1: 0.75,
v1: 0.5,
w: 16,
h: 16,
}
}
#[test]
fn the_pose_stack_turns_about_its_pivot_and_pops_back() {
let mut d = draw();
d.push();
d.rotate_about([0.0, 1.0, 0.0], 90.0, [0.5, 0.5, 0.5]);
// (1, 0, 0.5) turns a quarter about the block's centre to (0.5, 0, 0).
let p = d.pose().transform_point3(Vec3::new(1.0, 0.0, 0.5));
assert!(p.abs_diff_eq(Vec3::new(10.5, 0.0, 0.0), 1e-5), "{p}");
d.pop();
d.pop();
// Popping past the block's own pose keeps it.
let p = d.pose().transform_point3(Vec3::ZERO);
assert_eq!(p, Vec3::new(10.0, 0.0, 0.0));
d.translate([0.0, 2.0, 0.0]);
d.scale([2.0, 2.0, 2.0]);
let p = d.pose().transform_point3(Vec3::ONE);
assert_eq!(p, Vec3::new(12.0, 4.0, 2.0));
}
#[test]
fn a_quad_lands_in_its_sprite_lit_and_shaded() {
let mut d = draw();
// An upward face: full brightness under vanilla's level lights is
// below 1 (two lights), so the colour is shaded but opaque.
d.quad(
[
[0.0, 1.0, 0.0],
[0.0, 1.0, 1.0],
[1.0, 1.0, 1.0],
[1.0, 1.0, 0.0],
],
[[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]],
sprite(),
0xFFFFFF,
);
assert_eq!(d.vertices.len(), 4);
let v = d.vertices[2];
assert_eq!(v.pos, [11.0, 1.0, 1.0]);
assert_eq!(v.uv, [0.75, 0.5]);
assert_eq!(v.light, 0xF0F0);
let up = diffuse(Vec3::Y, diffuse_lights(false));
assert_eq!(v.color, shaded(0xFFFFFF, up));
assert_eq!(v.color >> 24, 0xFF);
}
/// A frustum that sees nothing.
struct Blind;
impl Cull for Blind {
fn intersects(&self, _: Vec3, _: Vec3) -> bool {
false
}
fn contains(&self, _: Vec3, _: Vec3) -> bool {
false
}
}
#[test]
fn infinite_bounds_and_frames_without_a_frustum_are_never_culled() {
let origin = Vec3::new(1.0e6, 0.0, 0.0);
let infinite = [f32::NEG_INFINITY, 0.0, 0.0, f32::INFINITY, 1.0, 1.0];
assert!(!in_frustum(Some(&Blind), origin, DEFAULT_BOUNDS));
assert!(in_frustum(Some(&Blind), origin, infinite));
assert!(in_frustum(None::<&Blind>, origin, DEFAULT_BOUNDS));
}
#[test]
fn a_tint_keeps_its_channels_in_vertex_order() {
// The vertex colour is RGBA8 read little-endian: red in the low byte.
assert_eq!(shaded(0x10_20_30, 1.0), 0xFF_30_20_10);
}
}
@@ -0,0 +1,215 @@
//! A mod's own advancement criteria triggers (docs/native-mods.md,
//! "Advancement triggers"). An advancement of a content mod or a world's data
//! pack names a trigger of the mod's (`mymod:pipe_connected`); the tree keeps
//! it as it keeps vanilla's, and the mod fires it where its event happens, as
//! a NeoForge mod calls its own `SimpleCriterionTrigger.trigger`.
use super::{Game, advancements::item_view, commands::Target};
use horizon_client::{
advancements::{self as adv, EntityView},
items::ItemStack,
};
use serde_json::Value;
/// One open criterion of a mod's trigger, as the mod's test sees it.
pub(crate) struct NativeCriterion<'a> {
/// The criterion's `conditions`, `Null` when it has none. Its `player`
/// predicate is the game's to test, as vanilla's `SimpleInstance.player`.
pub(crate) conditions: &'a Value,
world: &'a dyn adv::World,
}
#[allow(dead_code)]
impl NativeCriterion<'_> {
/// Whether `stack` passes the item predicate under `key` in the
/// conditions, as the game's own triggers read an `ItemPredicate`:
/// `items` (an id, a list or a `#tag`), `count`, the `potion_contents`
/// and `damage` components, and the `enchantments` and `damage`
/// predicates; other components and predicates aren't checked. A missing
/// key passes, as an absent `Optional<ItemPredicate>` does.
pub(crate) fn item(&self, key: &str, stack: &ItemStack) -> bool {
adv::item(&self.conditions[key], &item_view(stack), self.world)
}
}
/// Whether a mod may fire `trigger`: a namespaced id outside `minecraft`,
/// whose triggers the game fires from its own events.
fn mod_trigger(trigger: &str) -> bool {
trigger
.split_once(':')
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty() && ns != "minecraft")
}
/// Helpers for server mods, kept as stable as the hook points.
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// Fires the mod's criteria trigger `trigger` (`mymod:pipe_connected`)
/// for the player with that UUID: vanilla's
/// `SimpleCriterionTrigger.trigger`. Each criterion of theirs with that
/// trigger, not yet granted, of an advancement not yet done, is tested
/// by `test` and then by its `player` predicate; those that pass are
/// granted, and an advancement they complete is awarded with its toast,
/// chat line and rewards, as the game's own triggers award.
///
/// The acting player is reached inside their hook; any player on the
/// server, in their own level, only where no player's state is swapped in
/// (`native_tick`, `native_menu_clicked`), as `native_run_command`. True
/// when the player was reached, whether or not anything was granted;
/// false off a server, when the player isn't on or can't be reached from
/// here, and for a `minecraft:` trigger or a name without a namespace.
/// API 69.
pub(super) fn native_trigger_criterion(
&mut self,
uuid: u128,
trigger: &str,
test: impl Fn(&NativeCriterion<'_>) -> bool,
) -> bool {
if !mod_trigger(trigger) || self.remote.is_some() {
return false;
}
if self.lan_acting_uuid == Some(uuid) {
self.trigger_mod_criteria(trigger, &test);
return true;
}
if self.lan_acting.is_some() {
return false;
}
let Some(conn) = self.lan.as_ref().and_then(|host| {
host.hub
.guests
.iter()
.find(|g| g.placed && g.uuid == uuid)
.map(|g| g.conn)
}) else {
return false;
};
// In their level: the predicates read where they are, and a reward
// that doesn't fit drops there.
self.for_player(Target::Guest(conn), |g| {
g.trigger_mod_criteria(trigger, &test)
})
.is_some()
}
/// `trigger` for a mod's trigger, for the player in place.
fn trigger_mod_criteria(&mut self, trigger: &str, test: &dyn Fn(&NativeCriterion<'_>) -> bool) {
let me = self.player_entity();
self.trigger(trigger, |conditions, world| {
passes(conditions, &me, world, test)
});
}
}
/// `SimpleCriterionTrigger.trigger`'s test of one criterion for the player
/// `me`: the mod's matcher, then the `player` predicate, in the player's own
/// context (`EntityPredicate.createContext(player, player)`).
fn passes(
conditions: &Value,
me: &EntityView,
world: &dyn adv::World,
test: &dyn Fn(&NativeCriterion<'_>) -> bool,
) -> bool {
let player = adv::Entities {
this: Some(me),
origin: me.position,
tool: None,
block_pos: None,
};
test(&NativeCriterion { conditions, world })
&& adv::conditions(&conditions["player"], &player, world)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::BTreeMap;
struct Plains;
impl adv::World for Plains {
fn dimension(&self) -> &str {
"minecraft:overworld"
}
fn biome(&self, _: [i32; 3]) -> String {
"minecraft:plains".into()
}
fn structures(&self, _: [i32; 3]) -> Vec<String> {
Vec::new()
}
fn block(&self, _: [i32; 3]) -> (String, BTreeMap<String, String>) {
("minecraft:air".into(), BTreeMap::new())
}
fn fluid(&self, _: [i32; 3]) -> Option<String> {
None
}
fn can_see_sky(&self, _: [i32; 3]) -> bool {
true
}
fn light(&self, _: [i32; 3]) -> i32 {
15
}
fn smokey(&self, _: [i32; 3]) -> bool {
false
}
fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool {
registry == "item" && tag == "c:ingots/copper" && id == "minecraft:copper_ingot"
}
}
/// A mod's `pipe_connected` criterion: the pipe's item by a convention
/// tag, and only for a player on fire (a loot condition list, as
/// vanilla's advancements write `player`).
fn criterion() -> Value {
json!({
"pipe": {"items": "#c:ingots/copper", "count": {"min": 2}},
"player": [{
"condition": "minecraft:entity_properties",
"entity": "this",
"predicate": {"flags": {"is_on_fire": true}}
}]
})
}
#[test]
fn a_mods_criterion_takes_its_own_test_and_the_player_predicate() {
let burning = EntityView {
kind: "minecraft:player".into(),
on_fire: true,
..EntityView::default()
};
let calm = EntityView {
on_fire: false,
..burning.clone()
};
let copper = ItemStack::new("minecraft:copper_ingot", 2);
let pipe = |stack: &ItemStack| {
let stack = stack.clone();
move |c: &NativeCriterion<'_>| c.item("pipe", &stack)
};
let c = criterion();
assert!(passes(&c, &burning, &Plains, &pipe(&copper)));
assert!(
!passes(&c, &calm, &Plains, &pipe(&copper)),
"the player predicate"
);
let one = ItemStack::new("minecraft:copper_ingot", 1);
assert!(
!passes(&c, &burning, &Plains, &pipe(&one)),
"the item's count"
);
let iron = ItemStack::new("minecraft:iron_ingot", 2);
assert!(!passes(&c, &burning, &Plains, &pipe(&iron)), "the tag");
// A criterion without the key or any conditions passes the item test.
let any = |c: &NativeCriterion<'_>| c.item("filter", &iron);
assert!(passes(&c, &burning, &Plains, &any));
assert!(passes(&Value::Null, &calm, &Plains, &any));
}
#[test]
fn mods_fire_only_their_own_namespaced_triggers() {
assert!(mod_trigger("mymod:pipe_connected"));
assert!(!mod_trigger("minecraft:inventory_changed"));
assert!(!mod_trigger("inventory_changed"));
assert!(!mod_trigger(":pipe_connected"));
}
}
@@ -5,7 +5,10 @@
//! `item_display` showing an item model from the mod's assets. They act where
//! the world is simulated, in the level in place, which each names by its
//! `dimension` (`minecraft:overworld`) as `native_set_block` does.
use super::{Game, commands::Target};
use super::{
Game,
commands::{Target, place_summoned},
};
use horizon_core::geometry::Vec3;
use horizon_world::nbt::{Compound, Tag};
@@ -86,6 +89,16 @@ fn position_ok(pos: [f64; 3]) -> bool {
&& (-20_000_000.0..20_000_000.0).contains(&pos[1])
}
/// The id of a summoned stack's root: its command target's, else `first`,
/// the first id handed out, when an entity no command target stands for
/// (`live`) holds it.
fn root_id(target: Option<Target>, first: u32, live: impl Fn(u32) -> bool) -> Option<u32> {
match target {
Some(t) => target_id(t),
None => live(first).then_some(first),
}
}
/// The entity id a summoned stack's root is known by.
fn target_id(t: Target) -> Option<u32> {
match t {
@@ -134,13 +147,14 @@ impl Game<'_> {
}
let mut tag = nbt;
tag.insert("id".into(), Tag::String(kind.into()));
tag.insert(
"Pos".into(),
Tag::List(pos.map(|v| Tag::Double(v.to_bits())).to_vec()),
);
// As `/summon` does: every entity of the stack, passengers included,
// stands at `pos`.
let mut tag = Tag::Compound(tag);
place_summoned(&mut tag, pos);
let from = self.mobs.len();
let first = self.next_entity + 1;
let deferred = (self.tnts.len(), self.falling.len(), self.rockets.len());
let target = self.spawn_entity_from_nbt(Tag::Compound(tag));
let target = self.spawn_entity_from_nbt(tag);
// These roots load with id 0 and have no command Target variant.
// Select the newly appended root, never an older waiting entity.
let root = match kind {
@@ -151,7 +165,17 @@ impl Game<'_> {
};
let root = root.map(|id| super::lan_entities::assign_entity_id(&mut self.next_entity, id));
self.native_mobs_joined(from, false);
root.or_else(|| target.and_then(target_id))
// Otherwise the root is the first entity spawned, also for kinds no
// command target stands for (experience orbs, end crystals, cushions). An id handed out to an
// entity a mod then kept out is not given back, so the root must be
// there.
root.or_else(|| {
root_id(target, first, |id| {
self.orbs.iter().any(|o| o.id == id)
|| self.crystals.iter().any(|c| c.id == id)
|| self.cushions.iter().any(|c| c.id == id)
})
})
}
/// Moves entity `id`, one Horizon keeps without simulating (a display, a
/// marker, an interaction), to `pos` facing `yaw` and `pitch` in degrees;
@@ -291,6 +315,42 @@ mod tests {
);
}
#[test]
fn a_passenger_without_a_position_stands_where_the_stack_is_summoned() {
let mut rider = Compound::new();
rider.insert("id".into(), Tag::String("minecraft:pig".into()));
let mut root = Compound::new();
root.insert("id".into(), Tag::String("minecraft:pig".into()));
root.insert("Passengers".into(), Tag::List(vec![Tag::Compound(rider)]));
let mut tag = Tag::Compound(root);
place_summoned(&mut tag, [1.5, 70.0, -2.5]);
let pos = Tag::List(
[1.5, 70.0, -2.5]
.map(|v: f64| Tag::Double(v.to_bits()))
.to_vec(),
);
let Tag::Compound(root) = &tag else { panic!() };
assert_eq!(root.get(&"Pos".into()), Some(&pos));
let Some(Tag::List(riders)) = root.get(&"Passengers".into()) else {
panic!("no passengers");
};
let Tag::Compound(rider) = &riders[0] else {
panic!()
};
assert_eq!(rider.get(&"Pos".into()), Some(&pos));
}
#[test]
fn an_orb_root_has_an_id_only_while_it_is_in_the_level() {
// Spawned: the orb holds the first id.
assert_eq!(root_id(None, 36, |id| id == 36), Some(36));
// A mod kept it out: id 36 was handed out, but no entity holds it.
assert_eq!(root_id(None, 36, |_| false), None);
// A command target answers for itself.
assert_eq!(root_id(Some(Target::Mob(40)), 36, |_| false), Some(40));
assert_eq!(root_id(Some(Target::Player), 36, |_| true), None);
}
#[test]
fn summon_positions_are_summons() {
assert!(position_ok([0.5, 64.0, 0.5]));
@@ -44,7 +44,7 @@ impl Game<'_> {
/// done, on a client's copy of the world, for another dimension, a chunk
/// that isn't loaded, or a radius that isn't in `0 < radius <= 32`. Call
/// it from a server hook; a blast inside `native_explosion_detonate`
/// starts that hook again. API @API:native_explode@.
/// starts that hook again. API 66.
pub(super) fn native_explode(
&mut self,
dimension: &str,
@@ -0,0 +1,130 @@
//! Falling blocks for native server mods (docs/native-mods.md, "Falling
//! blocks"): a mod throws a block as a `FallingBlockEntity` that flies, lands
//! and places itself (or drops as an item) as sand does. It acts where the
//! world is simulated, in the level in place, which it names by its
//! `dimension` (`minecraft:overworld`) as `native_set_block` does.
use super::{FallingBlock, Game};
use horizon_core::{geometry::Vec3, position::BlockPos};
use horizon_world::registry::BlockStateId;
/// The most falling blocks a level holds before `native_spawn_falling_block`
/// refuses more: a mod's debris shouldn't take the server's tick with it.
pub(super) const NATIVE_FALLING_MAX: usize = 1024;
/// The largest speed a thrown block starts with, in blocks a tick on each
/// axis (`Entity.setDeltaMovement` takes any; a packet's speed is cut to 3.9).
const NATIVE_FALLING_MAX_SPEED: f64 = 3.9;
/// What a mod's falling block does when it lands and when it hits.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct NativeFalling {
/// `FallingBlockEntity.dropItem`: whether it drops as an item where it
/// can't be placed.
pub drop_item: bool,
/// `FallingBlockEntity.hurtEntities`, at 2 a block fallen and at most 40,
/// an anvil's.
pub hurts: bool,
}
impl Default for NativeFalling {
fn default() -> Self {
Self {
drop_item: true,
hurts: false,
}
}
}
/// `velocity` with each axis cut to the largest start speed; none for a
/// value that isn't finite.
fn start_velocity(velocity: [f64; 3]) -> Option<Vec3> {
velocity.iter().all(|v| v.is_finite()).then(|| {
let [x, y, z] =
velocity.map(|v| v.clamp(-NATIVE_FALLING_MAX_SPEED, NATIVE_FALLING_MAX_SPEED));
Vec3::new(x, y, z)
})
}
#[allow(dead_code)]
impl Game<'_> {
/// Throws a falling block of `state` from `pos` (its bottom centre) at
/// `velocity` (blocks a tick) in the level in place, `dimension`: it
/// falls, lands and places itself as sand does, or drops as an item
/// where it can't. The block at `pos` is left as it is: take it away first
/// with `native_set_block` to make a block fall out of the world. False,
/// with nothing thrown, on a client's copy of the world, for another
/// dimension, an air or unknown state, a chunk that isn't loaded, a value
/// that isn't finite, or with 1024 falling blocks in the level already.
/// Each axis of `velocity` is cut to 3.9. API @API:native_falling_block@.
pub(super) fn native_spawn_falling_block(
&mut self,
dimension: &str,
state: BlockStateId,
pos: [f64; 3],
velocity: [f64; 3],
falling: NativeFalling,
) -> bool {
if (self.serving.is_none() && self.options.dedicated.is_none())
|| dimension != self.dim.id()
|| self.pack.assets.registry.state(state).is_none()
|| self.res.facts().is_air(state)
|| self.falling.len() >= NATIVE_FALLING_MAX
|| !pos.iter().all(|v| v.is_finite() && v.abs() <= 30_000_000.0)
{
return false;
}
let Some(velocity) = start_velocity(velocity) else {
return false;
};
let cell = BlockPos::new(
pos[0].floor() as i32,
pos[1].floor() as i32,
pos[2].floor() as i32,
);
if !self.world.chunks.contains_key(&(cell.x >> 4, cell.z >> 4)) {
return false;
}
let mut damage =
horizon_client::falling_block::Damage::falling(&self.res.facts().get(state).name);
if falling.hurts {
damage.hurt_entities = true;
damage.per_distance = 2.0;
damage.max = 40;
}
let at = Vec3::new(pos[0], pos[1], pos[2]);
self.falling.push(FallingBlock {
id: 0,
state,
start_pos: cell,
position: at,
previous: at,
velocity,
age: 0,
no_gravity: false,
drop_item: falling.drop_item,
fall_distance: 0.0,
stuck: Vec3::ZERO,
damage,
saved: None,
});
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_throw_is_cut_to_the_largest_speed_and_refuses_what_isnt_finite() {
assert_eq!(
start_velocity([0.5, 9.0, -9.0]),
Some(Vec3::new(
0.5,
NATIVE_FALLING_MAX_SPEED,
-NATIVE_FALLING_MAX_SPEED
))
);
assert_eq!(start_velocity([0.0, f64::NAN, 0.0]), None);
assert_eq!(start_velocity([f64::INFINITY, 0.0, 0.0]), None);
}
}
File diff suppressed because it is too large. Load diff
@@ -129,9 +129,14 @@ impl Game<'_> {
items: Vec<Option<ItemStack>>,
) -> Option<i32> {
let conn = self.native_conn(uuid)?;
let host = self.lan.as_mut()?;
host.hub
.open_native_menu(conn, rows, component_nbt(&title.component()), items)
let mut host = self.lan.take()?;
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(&mut host, conn);
let window =
host.hub
.open_native_menu(conn, rows, component_nbt(&title.component()), items);
self.lan = Some(host);
window
}
/// Replaces what the player's open native menu shows; sent with the
/// tick's container sync.
@@ -1,6 +1,7 @@
//! Horizon's own shader packs in the game (docs/shaders.md): the pick in `shaderpacks/` (a
//! folder with `horizon-shaders.toml`, or a `.hshader`; `game/shader_packs.rs`) runs once it
//! passes `horizon_shader_api::check`. The pack is looked at twice a second: a saved file is checked and the pack rebuilt, and a
//! passes `horizon_shader_api::check`. The pack is looked at twice a second: a saved file (or
//! saved option values) is checked and the pack rebuilt, and a
//! pack that fails is refused with its problems in the chat while the last good one keeps
//! drawing.
@@ -57,12 +58,10 @@ impl Game<'_> {
let result = Pack::read(path).map_err(|e| vec![e]).and_then(|pack| {
let report = check(&pack)
.map_err(|p| p.iter().map(ToString::to_string).collect::<Vec<String>>())?;
let constants = pack
.manifest
.options
.iter()
.map(|o| (o.id.clone(), o.kind.default_constant()))
.collect();
// The player's values (the pack's Shader Options page), else the defaults.
let options = &pack.manifest.options;
let values = horizon_client::shader_pack_list::load_values(path, options);
let constants = options.iter().map(|o| o.id.clone()).zip(values).collect();
let sprites = self.pack.render.sprite_layers.iter();
let tags = pack
.manifest
@@ -97,7 +96,8 @@ impl Game<'_> {
}
}
/// A hash of the pack's file names, sizes and modification times (`None` when unreadable).
/// A hash of the names, sizes and modification times of the pack's files and of its saved
/// option values (`None` when the pack is unreadable).
fn fingerprint(path: &Path) -> Option<u64> {
fn walk(path: &Path, hasher: &mut std::hash::DefaultHasher) -> Option<()> {
let meta = std::fs::metadata(path).ok()?;
@@ -120,5 +120,8 @@ fn fingerprint(path: &Path) -> Option<u64> {
}
let mut hasher = std::hash::DefaultHasher::new();
walk(path, &mut hasher)?;
// The player's option values, when there are any.
let values = horizon_client::shader_pack_list::values_path(path);
let _ = walk(&values, &mut hasher);
Some(hasher.finish())
}
@@ -522,6 +522,10 @@ impl<'a> Game<'a> {
/// then `tickSpawningChunk` in the shuffled spawning chunks of every
/// player: inhabited time, then `spawnForChunk`.
pub(super) fn spawn_natural(&mut self) {
// `tickChunks` does nothing in a Debug world.
if self.world_debug {
return;
}
let players = self.level_players();
if players.is_empty() {
return;
@@ -994,6 +994,7 @@ impl Game<'_> {
if !self.may_enter(plan.dim) {
return;
}
self.release_vehicle_container(id);
let uuid = self.uuid_for(id);
let b = &self.boats[i];
let tag = horizon_client::entity_nbt::boat_to_nbt(
@@ -1019,6 +1020,7 @@ impl Game<'_> {
if !self.may_enter(plan.dim) {
return;
}
self.release_vehicle_container(id);
let uuid = self.uuid_for(id);
let tag = self.carts[i].to_nbt(uuid, self.persist_raw(id));
self.carts.remove(i);
+66 -2
View File
@@ -86,6 +86,9 @@ impl Game<'_> {
}
}
pub(super) fn options_path() -> std::path::PathBuf {
if let Some(path) = super::split::options_path() {
return path;
}
std::env::current_dir()
.unwrap_or_default()
.join("options.txt")
@@ -376,6 +379,11 @@ impl Game<'_> {
Event::ShaderPack => self.settings.save(&Self::options_path()),
Event::OpenShaderOptions => self.open_shader_options(),
Event::ShaderOptions => self.save_shader_options(),
Event::NativePackOptions(i) => {
if let Some(pack) = self.settings.shader_packs.get(i) {
pack.save_values(&Self::shader_packs_dir());
}
}
Event::OpenShaderPackFolder => {
let dir = Self::shader_packs_dir();
let _ = std::fs::create_dir_all(&dir);
@@ -401,6 +409,10 @@ impl Game<'_> {
/// window to change, so applying settings never undoes a fullscreen
/// the compositor set up.
fn apply_fullscreen(&mut self) {
// A splitscreen guest draws in a window it shares: its option never changes the window.
if self.options.split_guest {
return;
}
if let Some(w) = self.window.clone() {
let monitor = w.current_monitor().or_else(|| w.primary_monitor());
if let Some(modes) = self
@@ -419,8 +431,12 @@ impl Game<'_> {
.fullscreen_resolution
.as_deref()
.and_then(horizon_client::window::VideoMode::read);
let leaving = !wanted && w.fullscreen().is_some();
self.fullscreen
.apply(&w, wanted, exclusive, preferred, in_video_screen);
if leaving {
windowed_again(&w);
}
}
}
/// Settings that Horizon applies beyond render distance, volume and GUI scale.
@@ -433,6 +449,7 @@ impl Game<'_> {
r.improved_transparency = s.improved_transparency;
r.set_present_mode(s.vsync, s.horizon.present_mode);
r.animated_textures = s.horizon.animated_textures;
r.screenshot_quality = s.horizon.screenshot_quality;
r.set_mipmap_levels(s.mipmap_levels.max(0) as u32);
r.set_texture_filtering(
s.file.int("textureFiltering"),
@@ -455,7 +472,10 @@ impl Game<'_> {
}
let mut volumes = s.volumes;
volumes[0] = 1.0;
horizon_client::settings::set_source_volumes(volumes);
// Splitscreen: the speakers are P1's, and so are their volumes.
if !self.options.split_guest {
horizon_client::settings::set_source_volumes(volumes);
}
super::narrator::sync(s);
let chat = s.chat_options();
let rescale = {
@@ -483,6 +503,9 @@ impl Game<'_> {
return;
}
let Some(w) = &self.window else { return };
if self.options.split_guest {
return;
}
if let Some(now) = self.fullscreen.observe(w.fullscreen().is_some())
&& self.settings.fullscreen != now
{
@@ -492,6 +515,22 @@ impl Game<'_> {
}
/// F11 (key.fullscreen): Window.toggleFullScreen, remembered in options.txt.
pub(super) fn toggle_fullscreen(&mut self) {
// Splitscreen: another player's F11 toggles the window it plays in, kept out of its
// options (a profile that saved it would turn the next window fullscreen on joining).
if self.options.split_guest {
// Window 1 is P1's: its fullscreen follows P1's option, not a guest's key.
if self.options.split_in_main_window {
return;
}
if let Some(w) = &self.window {
let on = w.fullscreen().is_none();
w.set_fullscreen(on.then_some(Fullscreen::Borderless(None)));
if !on {
windowed_again(w);
}
}
return;
}
self.settings.fullscreen = !self.settings.fullscreen;
self.settings.save(&Self::options_path());
self.apply_ui_settings();
@@ -530,7 +569,7 @@ impl Game<'_> {
/// below 260 frames per second, sleeping then spin-waiting until the
/// frame's slot ends (not in scripted frame-count runs).
pub(super) fn limit_frame(&mut self) {
if self.options.frames.is_some() {
if self.options.frames.is_some() || self.options.paced_by_split {
return;
}
let limit = self.framerate_limit();
@@ -545,3 +584,28 @@ impl Game<'_> {
}
}
}
/// Out of fullscreen, a window that splitscreen opened to fill a monitor (no border, the
/// monitor's size) is a window again: its border back and, still covering the monitor, the game's
/// first size (854 x 480) in the middle of it, to be moved and sized.
fn windowed_again(w: &winit::window::Window) {
// Only those: an ordinary window has its border, and its own size comes back by itself.
if w.is_decorated() {
return;
}
w.set_decorations(true);
let Some(m) = w.current_monitor() else {
return;
};
let (size, screen) = (w.inner_size(), m.size());
if size.width >= screen.width && size.height >= screen.height {
let scale = m.scale_factor();
let small = winit::dpi::LogicalSize::new(854.0, 480.0).to_physical::<u32>(scale);
let _ = w.request_inner_size(small);
let at = m.position();
w.set_outer_position(winit::dpi::PhysicalPosition::new(
at.x + (screen.width.saturating_sub(small.width) / 2) as i32,
at.y + (screen.height.saturating_sub(small.height) / 2) as i32,
));
}
}
+9 -1
View File
@@ -326,6 +326,9 @@ impl Game<'_> {
// The new atlas's skin and map slots are blank: players and maps
// upload into it again instead of naming sprites it does not have.
self.skin_slots.forget();
self.skin_downloads
.borrow_mut()
.set_resource_root(self.options.resources.clone());
self.maps.slots.forget();
self.map_patches.clear();
// The atlas's sprites moved: the particle engine re-reads every rect
@@ -350,7 +353,12 @@ impl Game<'_> {
self.pending_uploads.clear();
// New GPU textures: a renderer built from the reloaded resources; the
// sections are uploaded again as they are meshed.
if let Some(renderer) = renderer {
if let Some(mut renderer) = renderer {
// The old one's size: a splitscreen player's view is smaller than the window the new
// one was sized from (it drew a window-sized frame into the view, cut off).
if let Some(old) = &self.renderer {
renderer.resize(old.config.width, old.config.height);
}
self.renderer = Some(renderer);
self.apply_ui_settings();
}
+46 -1
View File
@@ -26,6 +26,8 @@ pub(super) const HELP: &[&str] = &[
/// The controllers and the state the game last acted on.
#[derive(Default)]
pub(super) struct Pads {
/// Splitscreen: this player's pad, read by `PadRouter` for every game, instead of the pads.
pub(super) feed: Option<PadState>,
/// The real controllers and the DualSense's output.
#[cfg(feature = "controllers")]
devices: hardware::Devices,
@@ -56,6 +58,19 @@ pub(super) struct Pads {
#[cfg(feature = "controllers")]
mod hardware;
pub(super) use hardware::{PadId, PadRouter};
/// What a controller did since the last frame.
#[cfg_attr(not(feature = "controllers"), allow(dead_code))]
pub(in crate::game) enum PadEvent {
Press(PadId, Button),
/// Disconnected (out of battery, unplugged, out of range).
Gone(PadId),
/// Connected again under the same id (Windows keeps it; macOS gives a new one). On Linux
/// gilrs gives a new device the slot of a disconnected one of the same model, so there this
/// can be another controller of that model.
Back(PadId),
}
/// A build without the `controllers` feature (the server build, which must not need libudev): no
/// hardware pads, only `horizon-ctl pad`'s virtual one.
@@ -69,6 +84,33 @@ mod hardware {
pub(super) fn read(&mut self) -> PadState {
PadState::default()
}
pub(in crate::game) fn release_hardware(&mut self) {}
}
pub(in crate::game) type PadId = usize;
#[derive(Default)]
pub(in crate::game) struct PadRouter {}
impl PadRouter {
pub(in crate::game) fn events(&mut self) -> Vec<super::PadEvent> {
Vec::new()
}
pub(in crate::game) fn lend_output(&mut self, _: &mut Pads, _: PadId) {}
pub(in crate::game) fn reclaim_output(&mut self, _: &mut Pads) {}
pub(in crate::game) fn connected(&self, _: PadId) -> bool {
false
}
pub(in crate::game) fn uuid(&self, _: PadId) -> [u8; 16] {
[0; 16]
}
pub(in crate::game) fn state(&self, _: PadId) -> PadState {
PadState::default()
}
pub(in crate::game) fn name(&self, _: PadId) -> String {
String::new()
}
pub(in crate::game) fn style(&self, _: PadId) -> horizon_client::gamepad::GlyphStyle {
horizon_client::gamepad::GlyphStyle::Auto
}
}
impl Game<'_> {
@@ -113,7 +155,10 @@ impl Game<'_> {
self.pad_menu_frame(event_loop);
return;
}
let hardware = self.pad.read();
let hardware = match self.pad.feed {
Some(state) => state,
None => self.pad.read(),
};
self.ui.options.pad_style = self.settings.controller.glyphs.resolve(self.pad.style);
let state = hardware.merge(&self.pad.injected);
self.pad.virtual_input = self.pad.injected != PadState::default();
+161 -33
View File
@@ -3,7 +3,7 @@
//! server build) has the stub in pad.rs instead, and only `horizon-ctl pad`'s virtual pad.
use super::{
super::{Game, Screen},
Pads,
PadEvent, Pads,
};
use horizon_client::gamepad::{self, Button, GlyphStyle, PadState, Rumble, TriggerFeel};
use serde_json::{Value, json};
@@ -93,6 +93,21 @@ impl Pads {
};
}
/// Splitscreen reads the pads for every player (`PadRouter`): this game's own gilrs closes, or
/// its events would queue up unread. It opens again if this game reads the pads itself.
/// While several play, nobody gets rumble, adaptive triggers or the lightbar: the router
/// has no output path yet.
pub(in crate::game) fn release_hardware(&mut self) {
if self.devices.started {
self.devices.gilrs = None;
self.devices.started = false;
self.devices.active = None;
self.devices.rumbles.clear();
self.devices.dualsense = false;
self.devices.output = None;
}
}
/// Drain gilrs' events and read the active pad (opening gilrs the first time).
pub(super) fn read(&mut self) -> PadState {
self.start();
@@ -137,40 +152,153 @@ impl Pads {
};
self.style = GlyphStyle::of_vendor(pad.vendor_id());
self.devices.dualsense = is_dualsense(&pad);
let mut state = PadState::default();
for button in Button::ALL {
if !matches!(button, Button::LeftTrigger | Button::RightTrigger) {
state.set(button, pad.is_pressed(gilrs_button(button)));
state_of(&pad)
}
}
/// A pad's buttons, sticks and triggers now.
fn state_of(pad: &gilrs::Gamepad) -> PadState {
let mut state = PadState::default();
for button in Button::ALL {
if !matches!(button, Button::LeftTrigger | Button::RightTrigger) {
state.set(button, pad.is_pressed(gilrs_button(button)));
}
}
let axis = |a| pad.value(a);
use gilrs::Axis as A;
let sdl_mapped = pad.mapping_source() == gilrs::MappingSource::SdlMappings;
let (left, right) = gamepad::stick_axes(
[axis(A::LeftStickX), axis(A::LeftStickY)],
[axis(A::RightStickX), axis(A::RightStickY)],
axis(A::LeftZ),
pad.vendor_id(),
pad.product_id(),
sdl_mapped,
);
state.left = left;
state.right = right;
// Triggers arrive as analog buttons on most mappings, as plain
// buttons on some.
let trigger = |b: gilrs::Button| {
let value = pad.button_data(b).map_or(0.0, |d| d.value());
if pad.is_pressed(b) {
value.max(1.0)
} else {
value
}
};
state.triggers = [
trigger(gilrs::Button::LeftTrigger2),
trigger(gilrs::Button::RightTrigger2),
];
state
}
/// A controller, as splitscreen tells them apart.
pub(in crate::game) type PadId = gilrs::GamepadId;
/// Splitscreen: one gilrs for every player's game in this process. The split decides which
/// player each pad drives; this reads them.
#[derive(Default)]
pub(in crate::game) struct PadRouter {
gilrs: Option<gilrs::Gilrs>,
started: bool,
}
impl PadRouter {
/// Solo play uses this reader's device IDs for feedback too: another pad cannot steal
/// the active output while its input is being offered to join. Reclaimed after P1's turn.
pub(in crate::game) fn lend_output(&mut self, pads: &mut Pads, id: PadId) {
if pads.devices.gilrs.is_some() {
pads.release_hardware();
}
if pads.devices.active != Some(id) {
pads.devices.rumbles.clear();
pads.devices.output = None;
pads.devices.shown = None;
}
pads.devices.dualsense = self
.gilrs
.as_ref()
.and_then(|g| g.connected_gamepad(id))
.is_some_and(|p| is_dualsense(&p));
pads.devices.active = Some(id);
pads.devices.started = true;
pads.devices.gilrs = self.gilrs.take();
}
pub(in crate::game) fn reclaim_output(&mut self, pads: &mut Pads) {
self.gilrs = pads.devices.gilrs.take();
}
/// The buttons pressed and the controllers gone or back since the last call, in order.
pub(in crate::game) fn events(&mut self) -> Vec<PadEvent> {
if !self.started {
self.started = true;
self.gilrs = gilrs::Gilrs::new().ok();
}
let Some(gilrs) = self.gilrs.as_mut() else {
return Vec::new();
};
let mut events = Vec::new();
while let Some(gilrs::Event { id, event, .. }) = gilrs.next_event() {
match event {
gilrs::EventType::ButtonPressed(pressed, _) => {
if let Some(button) = Button::ALL
.into_iter()
.find(|&b| gilrs_button(b) == pressed)
{
events.push(PadEvent::Press(id, button));
}
}
gilrs::EventType::Disconnected => {
log::info!("Controller disconnected: {}", gilrs.gamepad(id).name());
events.push(PadEvent::Gone(id));
}
gilrs::EventType::Connected => events.push(PadEvent::Back(id)),
_ => {}
}
}
let axis = |a| pad.value(a);
use gilrs::Axis as A;
let sdl_mapped = pad.mapping_source() == gilrs::MappingSource::SdlMappings;
let (left, right) = gamepad::stick_axes(
[axis(A::LeftStickX), axis(A::LeftStickY)],
[axis(A::RightStickX), axis(A::RightStickY)],
axis(A::LeftZ),
pad.vendor_id(),
pad.product_id(),
sdl_mapped,
);
state.left = left;
state.right = right;
// Triggers arrive as analog buttons on most mappings, as plain
// buttons on some.
let trigger = |b: gilrs::Button| {
let value = pad.button_data(b).map_or(0.0, |d| d.value());
if pad.is_pressed(b) {
value.max(1.0)
} else {
value
}
};
state.triggers = [
trigger(gilrs::Button::LeftTrigger2),
trigger(gilrs::Button::RightTrigger2),
];
state
events
}
pub(in crate::game) fn connected(&self, id: PadId) -> bool {
self.gilrs
.as_ref()
.is_some_and(|g| g.connected_gamepad(id).is_some())
}
/// The kind of controller (its maker, model and bus; two of one model share it), to know it
/// back when it reconnects under a new id; zero when the system doesn't say.
pub(in crate::game) fn uuid(&self, id: PadId) -> [u8; 16] {
self.gilrs
.as_ref()
.map_or([0; 16], |g| g.gamepad(id).uuid())
}
/// The pad's state now (released when it is gone).
pub(in crate::game) fn state(&self, id: PadId) -> PadState {
self.gilrs
.as_ref()
.and_then(|g| g.connected_gamepad(id))
.map_or_else(PadState::default, |pad| state_of(&pad))
}
/// Its glyphs (Xbox, PlayStation, Nintendo), as `read` finds them for the active pad.
pub(in crate::game) fn style(&self, id: PadId) -> GlyphStyle {
self.gilrs
.as_ref()
.and_then(|g| g.connected_gamepad(id))
.map_or(GlyphStyle::Auto, |pad| {
GlyphStyle::of_vendor(pad.vendor_id())
})
}
pub(in crate::game) fn name(&self, id: PadId) -> String {
self.gilrs
.as_ref()
.and_then(|g| g.connected_gamepad(id))
.map_or_else(|| "A controller".into(), |pad| pad.name().to_owned())
}
}
@@ -332,6 +332,14 @@ impl Game<'_> {
self.pad.menu.synthetic = was;
}
/// The event being handled is one the pad or the keyboard's focus made
/// up (`pad_synthetic_event`), not the window's. A controller's click or
/// a focused widget's is not the mouse, so the native mods' mouse hooks
/// skip it.
pub(super) fn pad_synthetic_input(&self) -> bool {
self.pad.menu.synthetic
}
fn pad_click_at(&mut self, event_loop: &dyn EventLoopCtl, at: (f32, f32), right: bool) {
self.pad_pointer_to(event_loop, at);
self.pad_pointer_button(event_loop, right, true);
Loaded 100 of 284 files, more files were not shown because too many files have changed in this diff. Show more