Compare commits

...
Author SHA1 Message Date
Ludovic Marie d139f9bee6 Merge pull request #1514 from ludovic111/feat/st-default
🤖 Agent: merged after verify and CI parity passed, built merged with current main. Single player runs on its server thread by default, as vanilla does; `--in-frame` brings back the old mode. Evidence: the smoke session passes 48/48 in both modes. ThinkPad (HD 620, Mesa) RD 16: 651 vs 185 FPS. M4 Pro, v0.2.25 release build, forest, offscreen, alternating runs: 1% lows 224–231 → 399–476 at RD 16 and 120–124 → 210–213 at RD 32; average FPS within 4% at RD 32.
2026-10-24 07:27:50 +02:00
Ludovic Marie 03d93d79b2 Merge pull request #1513 from ludovic111/fix/st-cursor-close 2026-10-24 07:26:18 +02:00
Ludovic Marie f8e4e31b12 Merge pull request #1512 from ludovic111/fix/st-hits 2026-10-24 07:24:47 +02:00
Ludovic Marie 67e07f3faa Merge pull request #1511 from ludovic111/fix/st-player-body 2026-10-24 07:23:17 +02:00
Ludovic Marie 2bbbc70620 Merge pull request #1510 from ludovic111/fix/st-maps 2026-10-24 07:19:37 +02:00
Ludovic Marie 9c134f54ac Merge pull request #1509 from ludovic111/fix/st-fishing 2026-10-24 07:17:50 +02:00
Ludovic Marie a00dc868ec Merge pull request #1471 from ludovic111/perf/glyph-cache-fxhash 2026-10-24 07:14:41 +02:00
Ludovic Marie 08306d1586 Merge pull request #1470 from ludovic111/fix/entity-shadow-height 2026-10-24 07:12:59 +02:00
Ludovic Marie 3b70dc9c16 Merge pull request #1469 from ludovic111/fix/creative-search-hotbar-keys 2026-10-24 07:11:26 +02:00
Ludovic Marie 99b058442c Merge pull request #1538 from ludovic111/fix/builtin-packs
🤖 Agent: merged after verify and CI parity passed, built merged with current main (goldendays-1). Prepare copies vanilla's built-in Programmer Art and High Contrast packs from the player's own launcher asset store (SHA-1 checked; nothing ships with Horizon), so packs made to sit on Programmer Art, like Golden Days, show their classic textures.
2026-10-24 06:54:08 +02:00
Ludovic MarieandClaude Opus 5.5 8cce2e2f8a Prepare: import Programmer Art and High Contrast from the asset index
Vanilla lists its built-in resource packs from minecraft:resourcepacks of
the vanilla pack (BuiltInPackSource.populatePackList), and those two zips
live only in the asset index, not in the client JAR. Preparation never
copied them, so Horizon's pack list had no Programmer Art. Packs made to sit
on it, like Golden Days ("This pack is meant to be put over Programmer
Art"), then showed the modern textures for the ~720 textures they leave to
it: furnaces, stone bricks, crafting tables, pumpkins, TNT, logs, tools,
buckets, paper.

Full preparation now imports both after extracting the JAR, like the
panorama and Unifont, and `horizon-prepare packs` adds them to prepared
resources.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 06:34:00 +02:00
Ludovic Marie 582856aac2 Merge pull request #1326 from ludovic111/feat/java-shadow-programs
🤖 Agent: merged after verify and CI parity passed (shaders stack: Java packs draw their own terrain, water and shadow map, as in Iris). BSL shows real shadows on Vulkan and Metal; frame times and screenshots are on #1326. Without a pack, nothing changes.
2026-10-24 06:11:42 +02:00
Ludovic Marie 0a95a78e03 Merge pull request #1290 from ludovic111/feat/java-terrain-cull
🤖 Agent: merged after verify and CI parity passed (shaders stack: Java packs draw their own terrain, water and shadow map, as in Iris). BSL shows real shadows on Vulkan and Metal; frame times and screenshots are on #1326. Without a pack, nothing changes.
2026-10-24 06:09:33 +02:00
Ludovic Marie 5ec40c8004 Merge remote-tracking branch 'origin/main' into feat/java-terrain-cull 2026-10-24 05:15:42 +02:00
Ludovic Marie 82bba24f43 Merge remote-tracking branch 'origin/fix/st-cursor-close' into feat/st-default 2026-10-24 05:14:33 +02:00
Ludovic Marie 7b0316e6a4 Merge remote-tracking branch 'origin/fix/st-hits' into fix/st-cursor-close
# Conflicts:
#	tools/lan_e2e.py
2026-10-24 05:13:15 +02:00
Ludovic Marie a29e10ef9b Merge remote-tracking branch 'origin/fix/st-player-body' into fix/st-hits
# Conflicts:
#	tools/lan_e2e.py
2026-10-24 05:11:21 +02:00
Ludovic Marie 92050712fa Merge remote-tracking branch 'origin/fix/st-maps' into fix/st-player-body
# Conflicts:
#	tools/lan_e2e.py
2026-10-24 05:09:46 +02:00
Ludovic Marie 122e3bf287 Merge remote-tracking branch 'origin/fix/st-fishing' into fix/st-maps
# Conflicts:
#	tools/lan_e2e.py
2026-10-24 05:08:17 +02:00
Ludovic Marie 65031a7739 Merge remote-tracking branch 'origin/main' into fix/st-fishing
# Conflicts:
#	tools/lan_e2e.py
2026-10-24 05:06:43 +02:00
Ludovic Marie 59f018eabe Merge pull request #1535 from ludovic111/fix/dg1-mining
🤖 Agent: merged after verify and CI parity passed, built merged with current main (discord-gate-1: player reports checked against vanilla 26.3; a lan_e2e check fails on main and passes here in both modes; evidence in the PR body).
2026-10-24 04:49:25 +02:00
Ludovic Marie ca0637da80 Merge pull request #1508 from ludovic111/fix/st-wind-charge
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-2: server-thread gaps checked against vanilla, each with a lan_e2e check that fails before and passes after, host and guest, both modes; numbers in the PR body).
2026-10-24 04:45:07 +02:00
Ludovic Marie 47459b0b19 Merge pull request #1455 from ludovic111/chore/mob-brain-shared
🤖 Agent: merged after verify and CI parity passed, built merged with current main (review-2-deps-dupes; numbers and harness proof are in the PR body).
2026-10-24 04:22:35 +02:00
Ludovic Marie 9b16d37e80 Merge pull request #1452 from ludovic111/chore/dedupe-side-mask-kind
🤖 Agent: merged after verify and CI parity passed, built merged with current main (review-2-deps-dupes; numbers and harness proof are in the PR body).
2026-10-24 04:20:53 +02:00
Ludovic Marie 03be14bb59 Merge remote-tracking branch 'origin/fix/st-cursor-close' into feat/st-default 2026-10-24 04:12:23 +02:00
Ludovic Marie 45e5b64520 Merge remote-tracking branch 'origin/fix/st-hits' into fix/st-cursor-close 2026-10-24 04:11:25 +02:00
Ludovic Marie d65be4183f Merge remote-tracking branch 'origin/fix/st-player-body' into fix/st-hits 2026-10-24 04:10:32 +02:00
Ludovic Marie e307f03684 Merge remote-tracking branch 'origin/fix/st-maps' into fix/st-player-body 2026-10-24 04:09:39 +02:00
Ludovic Marie 26a91e3a08 Merge remote-tracking branch 'origin/fix/st-fishing' into fix/st-maps 2026-10-24 04:08:43 +02:00
Ludovic Marie 8c81cc63f7 Merge remote-tracking branch 'origin/fix/st-wind-charge' into fix/st-fishing 2026-10-24 04:07:54 +02:00
Ludovic Marie 221545e92b Merge remote-tracking branch 'origin/main' into fix/st-wind-charge 2026-10-24 04:07:01 +02:00
Ludovic Marie 88b27070bd Merge pull request #1476 from ludovic111/fix/st-held-use
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-1: server-thread gaps checked against vanilla, each with a lan_e2e check that fails on main and passes here, host and guest, both modes; tables in the PR body).
2026-10-24 03:43:11 +02:00
Ludovic Marie 499f8bddfc Merge pull request #1475 from ludovic111/fix/st-elytra
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-1: server-thread gaps checked against vanilla, each with a lan_e2e check that fails on main and passes here, host and guest, both modes; tables in the PR body).
2026-10-24 03:41:32 +02:00
Ludovic Marie 5904922865 Merge pull request #1466 from ludovic111/fix/st-books
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-1: server-thread gaps checked against vanilla, each with a lan_e2e check that fails on main and passes here, host and guest, both modes; tables in the PR body).
2026-10-24 03:39:56 +02:00
Ludovic Marie 49e706e8be Merge pull request #1465 from ludovic111/fix/st-signs
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-1: server-thread gaps checked against vanilla, each with a lan_e2e check that fails on main and passes here, host and guest, both modes; tables in the PR body).
2026-10-24 03:38:08 +02:00
Ludovic Marie 5e63b0512d Merge pull request #1464 from ludovic111/fix/st-keep-inventory
🤖 Agent: merged after verify and CI parity passed, built merged with current main (st-default-1: server-thread gaps checked against vanilla, each with a lan_e2e check that fails on main and passes here, host and guest, both modes; tables in the PR body).
2026-10-24 03:36:22 +02:00
Ludovic Marie 22b2711cf9 Merge pull request #1277 from ludovic111/feat/java-gbuffers-terrain
🤖 Agent: merged after verify and CI parity passed (shaders stack: Java packs draw their own terrain, water and shadow map, as in Iris). BSL shows real shadows on Vulkan and Metal; frame times and screenshots are on #1326. Without a pack, nothing changes.
2026-10-24 03:18:46 +02:00
Ludovic MarieandClaude Opus 5.5 ea424ebd52 LAN and dedicated servers wait for the player's client to finish breaking a block
ServerPlayerGameMode breaks a digging player's block only when the
player's client says it is done (STOP_DESTROY_BLOCK, or the delayed
destroy), or at once when it is instant. The host ran the guest's
tick_mining with its own count and broke the block when that reached 1.
A guest whose client ran even a tick behind lost the block before it
predicted the break: no destroy particles, no break sound, and the
dropped item could appear before the block vanished.

lan_e2e's new digging check holds attack across six oak logs and hears
six breaks, then stalls the guest for a second halfway through stone:
the stone is still there on both sides when it resumes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 02:38:07 +02:00
Ludovic MarieandClaude Opus 5.5 a4a101ca9c Single player runs on its server thread by default again; --in-frame is the opt-out
#1428 made the in-frame world the default while a server's players lagged
behind it (signs, books, totems, keep_inventory and the other [server-thread]
issues). With the fixes of this stack a scripted single player session (walk,
mine, place, inventory, combat, eating, boats, minecarts, chests, furnaces,
beds, chat, signs, books, totems, keep_inventory, fishing, maps, saving and
reopening) gives the same results on the server thread as in the frame.

`--in-frame`, `--no-server-thread` or HORIZON_SERVER_THREAD=0 runs the world
in the frame; `--server-thread` still works. LAN guests, dedicated servers and
the command harness are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 23:30:14 +02:00
Ludovic MarieandClaude Opus 5.5 c6ed3112e7 Closing a menu puts a server's player's cursor stack back into their inventory
AbstractContainerMenu.removed hands the stack a closing player holds on the
cursor to placeItemBackInInventory (dropped only what doesn't fit, or for a
player who left), and broadcastChanges then empties the client's cursor. The
LAN host threw it out at the player's eyes instead, for the inventory and
every container, crafting, station, mount and merchant menu, and the client
kept showing a copy on its cursor (#1283).

The three ContainerClose arms now place it back through lan_place_back and
send the inventory, carried stack included.

tools/lan_e2e.py gains a cursor check: each player picks up a stack in their
inventory and closes it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 23:30:08 +02:00
Ludovic MarieandClaude Opus 5.5 f96101cd47 Strikes and mob effects reach a server's players: lightning, evoker fangs, poison, axolotls
Lightning (Entity.thunderHit) and evoker fangs (EvokerFangs.dealDamageTo) hit
every entity in their box, but thunder_hit and fangs_bite only looked at the
window's own player, so a server's players (guests, and single player on the
server thread) were never struck (#1480, #1481). Both now run their player
part for each guest too, through Game::each_guest_hit.

The effects a mob gives the player it acts on (cave spider and bee poison, husk
hunger, wither skeleton wither, the nautilus's breath) and an axolotl's help
after a kill stayed with the server's own player: acts_on_player now routes
MobEvent::EffectPlayer and MobEvent::AxolotlSupportPlayer to the guest the mob
sees, like its blows (#1484, #1485).

tools/lan_e2e.py gains a strikes check: a bolt hurts each player standing in
water, and a cave spider poisons them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 23:30:08 +02:00
Ludovic MarieandClaude Opus 5.5 fa4ea80d32 A server's player's client sets its body as in frame: look pitch, use slowdown, boots on powder snow
On a server (guests, and single player on the server thread) the window ticks
its player through tick_remote_level, which copied the yaw, the Respiration
level, the wings and FAST_LAVA but not the rest of what the in-frame tick sets
from the window's own state. Both now call Game::sync_player_body:

- the look pitch: Player.travel steers sprint-swimming up and down by it (#1303)
- the slowdown of an item in use, LocalPlayer.modifyInput (#1278)
- leather boots, PowderSnowBlock.canEntityWalkOnPowderSnow (#1296)
- freeze-immune wear, LivingEntity.canFreeze
- the Auto Jump option, LocalPlayer.sendPosition (#1279)

tools/lan_e2e.py gains a body check: each player walks while drawing a bow,
stands on powder snow with leather boots and without, and sprint-swims looking
down.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 23:30:08 +02:00
Ludovic Marie 0bbe50c6e8 Merge remote-tracking branch 'origin/main' into feat/java-terrain-cull 2026-10-23 23:10:12 +02:00
Ludovic Marie c924894624 Merge remote-tracking branch 'origin/main' into feat/java-gbuffers-terrain 2026-10-23 23:08:59 +02:00
Ludovic MarieandClaude Opus 5.5 287d7a7eb5 Maps on a server's players: each holder's map ticks on the server and its data reaches them
A map a server's player (a LAN guest, or single player's window on its
server thread) made stayed blank and saved nothing (#1382): only the
game's own player ever updated a map, and no map data went to the guests.
Each guest's turn now runs MapItem's ticks as theirs (the marker of every
carried map, the reveal of the held ones), and the server sends each guest
ClientboundMapItemDataPacket for the maps it carries, and every 10 ticks
for the maps framed in the level (ServerPlayer.synchronizeSpecialItemUpdates,
ServerEntity.sendChanges).

As in vanilla, a map keeps its holders (MapItemSavedData.HoldingPlayer):
each has its own column sweep and its own record of what changed since its
last packet, so the packet carries only the changed pixels' box, and the
decorations at most every 5th tick while they changed. Player markers are
kept per holder, so each player's marker shows on a shared map, and go
when that player stops carrying it. A server's client now applies the
packet's decorations.

lan_e2e maps: host and guest each make and hold a map, which draws the
terrain around them and their marker, in both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 22:58:28 +02:00
Ludovic MarieandClaude Opus 5.5 c86996c234 Fishing on a server's players: a hook per player, ticked on the server and drawn by its clients
A server's players (LAN guests, and single player's window on its server
thread) cast no lasting hook and caught nothing (#1348): the guest's cast
went into the game's own hook, which the server's tick then dropped for
want of a local player. Each guest now keeps its own hook (Player.fishing),
swapped in with the rest of its state for its turns, ticked as theirs every
server tick, and dropped when they die. Reeling in retrieves it on the
server: a catch, the rod's wear, the experience.

The server tracks every hook to its players as a fishing_bobber entity
(FishingHook.getAddEntityPacket: the owner's id; DATA_HOOKED_ENTITY and
DATA_BITING). A client takes the one its own player cast as its hook, so
the rod shows cast and the line runs to its hand, and draws the others'
bobbers on lines to their owners' hands.

A hook resting on a block stays on the ground (Entity.move keeps onGround
when nothing moves vertically); it used to flip every tick, so reeling it
in cost the rod 2 only half the time.

lan_e2e fishing: host and guest, both modes: the hook stays out and both
sides draw it, reeling in from the ground costs 2, and in open water with
Lure III a fish bites and is caught for 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 22:58:18 +02:00
Ludovic MarieandClaude Opus 5.5 4daa91cf52 Wind charges push a server's players: the burst's packet carries each one's push
A wind charge's burst (ServerExplosion with TRIGGER_BLOCK) pushed only the
game's own player, so a LAN guest, and single player's window on its server
thread, never rose (#1386). The burst now pushes every player in reach, each
swapped in as the player, and sends ClientboundExplodePacket to the players
within 64 blocks with the push it gave them (hitPlayers), which their client
adds (Entity.pushFromExplosion); onExplosionHit's fall-damage grace goes on
the server's body. A burst from a guest's own turn (their charge landing,
their mace's wind burst) waits for the turn to end, so that it reaches the
host and the other guests too.

The packet's centre particle is the gust emitter and it throws no block
particles, as WindCharge.explode asks; the client now draws the packet's
own particle. Explosion knockback resistance lowers the player's push as in
vanilla, and a guest's body knows whether it flies (no push while flying in
creative).

lan_e2e wind: host and guest each throw a charge at their feet, rise about
six blocks and land unhurt, in both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 22:15:06 +02:00
Ludovic Marie 5595db9077 Merge remote-tracking branch 'origin/main' into chore/dedupe-side-mask-kind 2026-10-23 22:10:53 +02:00
Ludovic Marie fe70ff90b4 Merge remote-tracking branch 'origin/main' into fix/st-wind-charge 2026-10-23 21:43:45 +02:00
Ludovic Marie 4a442f7250 Merge remote-tracking branch 'origin/main' into perf/glyph-cache-fxhash 2026-10-23 20:53:30 +02:00
Ludovic Marie f47f6ee472 Merge remote-tracking branch 'origin/main' into fix/entity-shadow-height 2026-10-23 20:53:28 +02:00
Ludovic Marie 85c6103d45 Merge remote-tracking branch 'origin/main' into fix/creative-search-hotbar-keys 2026-10-23 20:53:27 +02:00
Ludovic Marie 60611ed5a3 Merge remote-tracking branch 'origin/main' into mm1-1455
# Conflicts:
#	crates/horizon-client/src/mobs/villager_brain.rs
2026-10-23 20:16:35 +02:00
Ludovic MarieandClaude Opus 5.5 4fa5966338 Text: the glyph cache hashes chars with Fx instead of SipHash
Every drawn character looks its glyph up in the font set's cache, and with
std's SipHash the hashing was the top cost of drawing sign text (#1461:
100 text signs in view). FxHash for those char keys cuts the frame's
prepare time with 100 text signs from 0.32 ms to 0.22 ms on the Mac mini
(Metal, 1280x720): 1920 to 2250 fps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:58:48 +02:00
Ludovic MarieandClaude Opus 5.5 eb79cfdb01 Entity shadows: no more z-fighting with chicken feet and other model feet
Shadow quads were lifted 0.001 above the block top and drawn in the entity
batch with depth writes. LivingEntityRenderer's translate(0, -1.501, 0)
puts a model's feet 0.001 above the ground too, so a chicken's feet and the
shadow under it were coplanar and flickered (#1440).

ShadowFeatureRenderer puts the quads on the block top itself
(relativeY + shapeBelow.minY), and RenderPipelines.ENTITY_SHADOW tests depth
without writing it. Shadows are now their own entity material (overlay 14)
drawn by such a pipeline after the entities. A small depth bias, constant
plus 1.5% of the slope, keeps them over the terrain under them, whose
vertices snap to the subpixel grid differently (without it whole shadows
vanished at some camera positions), while staying under feet 0.001 higher.
The shader pack shadow map no longer draws entity shadows as casters.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:58:48 +02:00
Ludovic MarieandClaude Opus 5.5 717f54bf28 Creative search tab: number keys over a slot move its item to the hotbar
CreativeModeInventoryScreen.keyPressed on the search tab tries the hotbar
keys on the hovered slot before the search box: a digit or keypad key bound
to key.hotbar.N swaps when the cursor is over a slot holding an item, or any
slot that is not a display slot, and the cursor stack is empty
(checkHotbarKeyPressed). Over an empty display slot, outside every slot or
with a stack held, the key still types. Horizon sent every key to the search
box (#1460).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:58:47 +02:00
Ludovic MarieandClaude Opus 5.5 ad76f911bd Shields, tridents, crossbows and spyglasses end their use on a server: the client starts it too
Item.use runs on the client as well (MultiPlayerGameMode.useItem), and
only a use the client started sends RELEASE_USE_ITEM when the button
lets go. A server's client started one for bows and food only, so a
shield kept blocking after release, a charged trident never left the
hand, a loaded crossbow stayed "in use" on the server and never fired,
and a spyglass never scoped. The client now starts what the window's
own Item.use does: a bow or crossbow with ammunition, a trident, a
spyglass, a shield off cooldown, and keeps it until the button lets go.

The server's RELEASE_USE_ITEM let go of a main-hand bow only. It now
runs ServerPlayer.releaseUsingItem with the used item's hand, through
the release the window's own tick shares (bow, trident, spyglass).

A loaded crossbow reached its client without its ammunition: the
network stack gets minecraft:charged_projectiles (registry id 51,
ItemStackTemplate list), and a server's client decodes it.

lan_e2e gets a helduse check, for the host and the guest, both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:50:56 +02:00
Ludovic MarieandClaude Opus 5.5 5ed8181adc Elytra glides on a server's players: the client sends START_FALL_FLYING and the server checks it
A server's client never took the chest slot's Elytra into its own
movement (player.glider) and never told the server it started gliding,
so on the server thread and as a LAN guest the player just fell. The
client now sets the glider as the window's own tick does and sends
ServerboundPlayerCommandPacket START_FALL_FLYING when a glide starts.

The hub used to accept that command blindly and never clear it. The game
now answers it as handlePlayerCommand does (Player.tryToStartFallFlying,
else stopFallFlying), and each tick runs LivingEntity.updateFallFlying
for a gliding guest: the glide ends once canGlide fails (landed, riding,
flying, levitation, a glider about to break), every 20th tick wears the
Elytra, every 10th is an elytra_glide game event, and a glide that isn't
diving keeps the fall distance at 1 (checkFallDistanceAccumulation).

lan_e2e gets an elytra check, for the host and the guest, both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 19:12:44 +02:00
Ludovic MarieandClaude Opus 5.5 5617d702cc Books open on a server's players, and what they write and sign reaches the server
A server's player (a LAN guest, or single player's window on its server
thread) could neither write in a book and quill nor read a written book:
the server opened the screens in the guest's turn and dropped them (#1366).
As in vanilla, the client opens a book and quill's editor itself when it
uses one (LocalPlayer.openItemGui) and sends ServerboundEditBookPacket
when it saves or signs; the server writes the pages or makes the written
book, signed with the player's name, in that hotbar slot or the off hand.
A written book opens on the client when the server sends
ClientboundOpenBookPacket for its hand (ServerPlayer.openItemGui).

horizon-protocol: OpenBook and EditBook (pages a list of at most 100).
horizon-ctl: book sign presses Sign, then Sign and Close; book done on a
book and quill saves it, as its Done button does.

lan_e2e books: host and guest each write, sign, read and drop a book,
both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 18:10:13 +02:00
Ludovic MarieandClaude Opus 5.5 52d8ed6721 Signs open their editor on a server's players, and what they write reaches the server
A sign a server's player placed (a LAN guest, or single player's window on
its server thread) opened no editor: its turn's editor was dropped with
the rest of the guest's screens (#1380). The server now does as
ServerPlayer.openTextEdit: the block, then ClientboundOpenSignEditorPacket,
and the player may write on that sign. The client opens its editor on the
sign it has and sends ServerboundSignUpdatePacket when it closes; the
server takes the lines (formatting codes stripped) unless the sign is
waxed, the player was not the one allowed or went out of reach, and sends
the sign to everyone. The window's own sign now reaches its guests too
(sendBlockUpdated, not only a save).

horizon-protocol: OpenSignEditor and SignUpdate, with SignTextSlot as a
VarInt and the lines as a fixed list of four (26.3's stream codecs).
horizon-ctl: type and key reach an open sign editor; block shows a sign's
lines.

lan_e2e signs: host and guest each place, write and see, both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 18:10:13 +02:00
Ludovic MarieandClaude Opus 5.5 de358cd721 A server's players drop their inventory on death unless keep_inventory is on
The guest death path (LAN guests, dedicated players and single player's
window on its server thread) gave the experience reward but never ran
Player.dropEquipment, so a player died with keep_inventory off and kept
everything (#1284). Both paths now share one drop_inventory:
destroyVanishingCursedItems, then Inventory.dropAll from the eyes less 0.3.

ServerPlayer.die skips the loot for spectators only; the window's own
death skipped it in creative too, which vanilla doesn't.

lan_e2e keepinv: host and guest, keep_inventory off and on, both modes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 18:10:13 +02:00
Ludovic MarieandClaude Opus 5.5 9acc68f09f Merge feat/java-terrain-cull into feat/java-shadow-programs
The shadow samplers bind with main's custom textures (texture.* first,
then the shadow map, then the rest); the shadow layers are drawn from the
shadow list on the flat path too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 18:00:56 +02:00
Ludovic MarieandClaude Opus 5.5 a8c21c27df Merge feat/java-gbuffers-terrain into feat/java-terrain-cull
With main's Metal culling: the pack's depth test in OpenGL's layout runs
forced early on Vulkan (fs_box_early_depth) and in the shader on Metal
(fs_box_gl, the pack's depth attached read only and bound), and the flat
path culls too (phase 1 calls, flatten_phase2 after test_gl, then
draw_phase2 with each page's group 3).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 17:55:04 +02:00
Ludovic MarieandClaude Opus 5.5 0f3a92ac2b Merge feat/java-packs-photon-chocapic into feat/java-gbuffers-terrain
Brings main's shader api 2 (#1414) up the stack. One path for blending:
the terrain programs' blend.* now go through Pass.blend/buffer_blends like
composite and final (java_pack::pass_blends). depthtex1/depthtex2 are one
source, DepthSolid: Horizon's depth before translucent geometry, then the
pack's solid terrain's. Horizon's world goes to the pack's first terrain
target (colortex1 for MakeUp).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 17:46:12 +02:00
Ludovic MarieandClaude Opus 5.5 a79077b30e Mob brains share Timed, compute_path, reached and the living-entity scan
Behavior's Timed was copied into the villager, animal, monster and warden
brains: one copy now, in brain.rs, drawing its duration in Java's int
arithmetic (two copies used plain +, the same for every duration they
have). MoveToTargetSink's tryComputePath and reachedTarget, copied into
the villager, monster and warden brains on the same WalkTarget and Slot
types, are the villager brain's, given each brain's
CANT_REACH_WALK_TARGET_SINCE. NearestLivingEntitySensor's scan, copied
into the villager, animal and monster brains, is brain.rs's living_near.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:33:59 +02:00
Ludovic MarieandClaude Opus 5.5 ac811d4708 One side_mask for the two datafixes, one test MobType for two mobs.rs tests
datafix: LeavesFix's getSideMask is ChunkPalettedStorageFix's, line for
line; chunk_leaves uses chunk_flatten's. mobs.rs: push_tests takes
sensor_scan_tests' 46-line cow MobType instead of its own copy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:30:46 +02:00
Ludovic MarieandClaude Opus 5.5 5858cb113e Java shader packs: the shadow map leaves out sections whose shadow can't reach the view
A section's shadow is its box swept away from the light; when the box
is outside one of the camera frustum's planes and the sweep never
crosses it, nothing it casts can be seen (Iris's advanced shadow
culling, conservatively). The picture is the same; BSL's 256-block
shadow distance draws far fewer sections.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:21:16 +02:00
Ludovic MarieandClaude Opus 5.5 fe6ae937ef Java shader packs: the pack's shadow programs draw the shadow map
A Java pack's shadow program (shadow_cutout, shadow or shadow_solid for
the solid layer, shadow_water or shadow for the translucent one) draws
Horizon's terrain from the light after begin, as Iris's ShadowRenderer:

- shadowModelView and shadowProjection are Iris's ShadowMatrices, with
  the pack's shadowMapResolution, shadowDistance, near and far planes,
  shadowIntervalSize and sunPathRotation; shadow programs get them as
  their model view and projection.
- The renderer picks every resident section inside the light's box (no
  cave culling, no face culling) and draws it instanced, solid then
  translucent, with origins after the camera's.
- shadowtex0 holds everything, shadowtex1 the depth before translucent
  terrain, bound as depth textures for comparisons and as float copies
  for plain reads; shadowcolor0..7 take the pack's formats and clears
  (white by default).

A shadow program that fails to compile leaves the pack without its
shadow map, with a warning; its terrain programs still run.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 23:41:18 +02:00
Ludovic MarieandClaude Opus 5.5 c344a26e80 Java shader packs: occlusion culling while the pack draws the terrain
The pack's terrain program draws the solid sections the last test saw,
the boxes of the others are tested against the pack's depth in a pass
of their own (OpenGL's layout: y flipped, depth 0 near, so a second
depth-only culling pipeline), and the program then draws the sections
in sight from the culling's phase 2 draws. The flat path, which has no
occlusion culling, is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 23:05:26 +02:00
Ludovic MarieandClaude Opus 5.5 6a58fc20da Java shader packs: the pack's gbuffers_terrain and gbuffers_water draw the terrain
When a Java pack has a terrain program (gbuffers_terrain, or the program
Iris falls back to), Horizon's world pass skips its own terrain and the
pack's program draws every visible section into its render targets,
tested against the depth of the rest of the world; gbuffers_water draws
the translucent layer before the first composite with the pack's blend
modes. depthtex0/depthtex1 get the depth before and after water.

- translate: a geometry mode (group 2, attributes as globals a glue
  function fills before main, alpha test) and a pass that ends switch
  cases which return, which naga's WGSL writer otherwise rejects
  (Photon).
- java_terrain: the glue decoding Horizon's terrain vertices from the
  arena pages into Iris's attributes, the block.properties id table by
  sprite name, and a 2D atlas built from the sprite arrays (animated
  sprites copied in).
- RGBA16/RG16/R16 targets are 16-bit unorm where the GPU renders them:
  Chocapic13' packs its G-buffer into them.

MakeUp Ultra Fast, Chocapic13' and BSL draw their own terrain and water
on the M4 Pro; Photon still draws black (compute and .dat textures).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 21:50:37 +02:00
61 changed files with 6293 additions and 1098 deletions

No files matched your search

+4
View File
@@ -87,3 +87,7 @@ rng-trace = ["horizon-core/rng-trace"]
[lints]
workspace = true
[dev-dependencies]
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
+39
View File
@@ -425,6 +425,29 @@ fn split_id(id: &str) -> (String, String) {
None => ("minecraft".to_owned(), id.to_owned()),
}
}
/// The slot under the cursor, for the search tab's keys.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Hover {
Nothing,
/// A display slot (`isCreativeSlot`), showing an item or past the end.
Display {
filled: bool,
},
/// Any other slot: the hotbar row, or the inventory tab's slots.
Slot,
}
/// `CreativeModeInventoryScreen.keyPressed` on the search tab: whether a
/// key is a hotbar swap on the hovered slot instead of text for the search
/// box. `numeric` is `getNumericKeyValue().isPresent()` (the digit and keypad
/// keys); `hotbar_key` is a `keyHotbarSlots` key. Over an empty display slot
/// the key types (`doQuickSwap` is false), and `checkHotbarKeyPressed` needs
/// a hovered slot and an empty cursor.
pub fn search_key_swaps(numeric: bool, hotbar_key: bool, hover: Hover, carrying: bool) -> bool {
let quick_swap = hover != Hover::Display { filled: false };
quick_swap && numeric && hotbar_key && !carrying && hover != Hover::Nothing
}
/// What a click on an item tab's display slot does besides changing the
/// carried stack.
#[derive(Clone, PartialEq, Debug)]
@@ -529,6 +552,22 @@ mod tests {
(carried, effect)
}
#[test]
fn search_tab_number_keys_swap_over_a_slot_before_typing() {
let filled = Hover::Display { filled: true };
let empty = Hover::Display { filled: false };
// Over an item, the hotbar row or another slot: the hotbar swap.
assert!(search_key_swaps(true, true, filled, false));
assert!(search_key_swaps(true, true, Hover::Slot, false));
// Over an empty display slot, nothing or with a carried stack: text.
assert!(!search_key_swaps(true, true, empty, false));
assert!(!search_key_swaps(true, true, Hover::Nothing, false));
assert!(!search_key_swaps(true, true, filled, true));
// Letters always type; so do the digits no hotbar key is bound to.
assert!(!search_key_swaps(false, false, filled, false));
assert!(!search_key_swaps(true, false, filled, false));
}
#[test]
fn display_slot_clicks_follow_slot_clicked() {
use crate::menu::Input::*;
+3 -2
View File
@@ -102,7 +102,8 @@ enum Provider {
/// One font (`FontSet`): its providers and the glyphs resolved so far.
struct FontSet {
providers: Vec<Provider>,
glyphs: HashMap<char, Glyph>,
/// Every char drawn looks up here, so the hasher is Fx, not SipHash.
glyphs: crate::fast_hash::FxHashMap<char, Glyph>,
/// `glyphsByWidth` for obfuscated text (built on first use).
by_width: Option<HashMap<u32, Vec<char>>>,
}
@@ -647,7 +648,7 @@ impl Font {
.collect();
FontSet {
providers,
glyphs: HashMap::new(),
glyphs: Default::default(),
by_width: None,
}
}
+181 -95
View File
@@ -285,11 +285,9 @@ struct Options {
benchmark: Option<String>,
/// `--dev-mod PATH` (repeatable): mods always on and reloaded when rebuilt.
dev_mods: Vec<PathBuf>,
/// `--server-thread` or `HORIZON_SERVER_THREAD=1`: single player runs on an
/// integrated server thread (game/integrated.rs), as vanilla's does. Off by
/// default again until a server's players reach parity with the in-frame
/// world (signs, books, totems, keep_inventory...); `--no-server-thread`
/// keeps it off.
/// Single player runs on an integrated server thread (game/integrated.rs),
/// as vanilla's does: the default. `--in-frame` (or `--no-server-thread`,
/// or `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead.
server_thread: bool,
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
integrated: bool,
@@ -347,7 +345,7 @@ impl Options {
dev_mods: Vec::new(),
// The command harness (HORIZON_COMMAND_SCRIPT) drives the world
// itself, without the event loop: it stays in the frame.
server_thread: std::env::var("HORIZON_SERVER_THREAD").is_ok_and(|v| v == "1")
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
integrated: false,
};
@@ -357,7 +355,7 @@ impl Options {
match arg.as_str() {
"--help" | "-h" => {
println!(
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --server-thread Run single player's world on its server thread (not at parity yet)\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --width N, --height N Window size (default 1280 x 720)\n --creative Creative mode (default survival)\n --test-terrain Simple test terrain instead of vanilla generation\n --frames N Exit after N frames (testing)\n --world-frames N Exit after N frames drawn in the world (testing)\n --smoke-report PATH Write a JSON report on exit\n --benchmark SCENARIO Run a benchmark (standard, village, forest, ocean, mountain), write its report to benchmarks/ and quit\n --control Accept horizon-ctl commands on a local socket\n --control-socket PATH Control socket path (default $XDG_RUNTIME_DIR/horizon-control.sock)\n --server HOST[:PORT] Join a multiplayer server (offline mode) at start\n --name NAME Player name for multiplayer (default: generated once, kept in horizon-player.txt)\n --session-file PATH Signed-in account from the launcher (read once, then deleted)\n --dev-mod PATH A Horizon mod (.hmod or folder) to develop: always on, reloaded when rebuilt\n --in-frame Run single player's world in the frame, not on its server thread\n\nControls: mouse look, WASD move, Space jump, Left Shift sneak, Left Ctrl sprint,\nleft click break, right click place/use, 1-9/scroll hotbar, E inventory,\nQ drop, F3 debug, Esc pause.",
horizon_core::VANILLA_VERSION
);
return Ok(None);
@@ -372,7 +370,7 @@ impl Options {
"--offscreen" => o.offscreen = true,
"--control" => o.control = Some(horizon_client::control::default_path()),
"--server-thread" => o.server_thread = true,
"--no-server-thread" => o.server_thread = false,
"--in-frame" | "--no-server-thread" => o.server_thread = false,
_ => {
let value = args
.next()
@@ -1446,6 +1444,8 @@ struct Game<'a> {
mount_menu: mount_menu::MountMenu,
/// The player's fishing hook.
hook: Option<fishing::Hook>,
/// The other players' hooks a server tracks to this client.
remote_hooks: Vec<fishing::RemoteHook>,
tridents: Vec<trident::ThrownTrident>,
rockets: Vec<fireworks::Rocket>,
/// Ominous trial spawners' item spawners.
@@ -1817,6 +1817,65 @@ impl Game<'_> {
self.stack_in_mut(self.held_slot())
}
/// The slot of the hand that acts: the selected one, or `OFFHAND_SLOT`.
/// `LivingEntity.canGlideUsing`: a glider in the chest slot that will
/// not break with its next point of wear.
fn glider_worn(&self) -> bool {
self.armor[1].as_ref().is_some_and(|s| {
self.res
.items()
.get(&s.item)
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
})
}
/// What the window decides for its player's body every tick, in frame and
/// on a server alike: the look (Player.travel steers sprint-swimming by
/// the pitch), Auto Jump (LocalPlayer.sendPosition), the slowdown of an
/// item in use (LocalPlayer.modifyInput), the helmet's Respiration
/// (decreaseAirSupply), the wings, leather boots on powder snow
/// (PowderSnowBlock.canEntityWalkOnPowderSnow), freeze-immune wear
/// (LivingEntity.canFreeze) and the dimension's FAST_LAVA.
pub(crate) fn sync_player_body(&mut self) {
let glider = self.glider_worn();
let item_use_speed = self
.using
.and_then(|(slot, _)| {
if slot == shield::OFFHAND_SLOT {
self.offhand.as_ref()
} else {
self.inventory.get(slot)?.as_ref()
}
})
.and_then(|s| self.res.items().get(&s.item))
.map(|i| i.use_speed);
let respiration = self.armor[0]
.as_ref()
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
let leather_boots = self.armor[3]
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:leather_boots");
let immune = self
.res
.items()
.item_tags
.get("minecraft:freeze_immune_wearables");
let freeze_immune = self
.armor
.iter()
.flatten()
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)));
let Some(player) = self.player.as_mut() else {
return;
};
player.yaw = self.yaw;
player.pitch = self.pitch;
player.auto_jump_enabled = self.settings.auto_jump;
player.item_use_speed = item_use_speed;
player.respiration = respiration;
player.glider = glider;
player.leather_boots = leather_boots;
player.freeze_immune = freeze_immune;
player.fluids.fast_lava = self.dim == Dim::Nether;
}
fn held_slot(&self) -> usize {
match self.hand {
Hand::Main => self.selected,
@@ -1994,6 +2053,28 @@ impl Game<'_> {
particle.scale(0.6);
self.particles.push(particle);
}
/// `Player.dropEquipment` for the player who died: unless keep_inventory
/// is on, `destroyVanishingCursedItems`, then `Inventory.dropAll` from
/// `at` (the eyes less 0.3): slots 0..36, armor feet to head (36..39),
/// the off hand (40), each thrown in a random direction.
fn drop_inventory(&mut self, at: [f64; 3]) {
if self.ui.rules.bool("keep_inventory") {
return;
}
let mut stacks: Vec<ItemStack> =
self.inventory.iter_mut().filter_map(Option::take).collect();
stacks.extend(self.armor.iter_mut().rev().filter_map(Option::take));
stacks.extend(self.offhand.take());
stacks.retain(|s| s.enchantment_level("minecraft:vanishing_curse") == 0);
self.apply_equipment();
for stack in stacks {
let r = &mut self.world.random;
let strength = f64::from(r.next_float()) * 0.5;
let angle = f64::from(r.next_float()) * std::f64::consts::TAU;
let v = [-angle.sin() * strength, 0.2, angle.cos() * strength];
self.spawn_item(stack, at, v, 40);
}
}
fn spawn_item(
&mut self,
stack: ItemStack,
@@ -3649,16 +3730,8 @@ impl Game<'_> {
// Switching hotbar slots only ends a main-hand use.
let hand_changed = slot != self.selected && slot != shield::OFFHAND_SLOT;
if !self.right || hand_changed || self.screen != Screen::Playing {
if &*stack.item == "minecraft:bow" && !hand_changed {
self.release_bow(ticks);
}
if &*stack.item == "minecraft:spyglass" {
self.stop_spyglass();
}
if &*stack.item == "minecraft:trident" && !hand_changed {
self.release_trident(ticks);
}
self.using = None;
self.release_used_item(&stack, ticks, hand_changed);
return;
}
let Some(info) = self.res.items().get(&stack.item).cloned() else {
@@ -3714,6 +3787,17 @@ impl Game<'_> {
self.using = Some((slot, ticks));
}
}
/// `LivingEntity.releaseUsingItem` (`Item.releaseUsing`) for `stack`,
/// used for `ticks`, its hand acting: a bow or a trident lets go (not
/// when its hand changed), a spyglass stops scoping.
pub(super) fn release_used_item(&mut self, stack: &ItemStack, ticks: u32, hand_changed: bool) {
match &*stack.item {
"minecraft:bow" if !hand_changed => self.release_bow(ticks),
"minecraft:trident" if !hand_changed => self.release_trident(ticks),
"minecraft:spyglass" => self.stop_spyglass(),
_ => {}
}
}
fn tick_mining(&mut self) -> Result<()> {
if self.destroy_delay > 0 {
self.destroy_delay -= 1;
@@ -3803,7 +3887,12 @@ impl Game<'_> {
}
self.crack_particle(crack.0, crack.1, state);
let m = self.mining.as_mut().ok_or("mining")?;
if m.progress >= 1.0 {
// ServerPlayerGameMode breaks a player's block at once only when it is instant (START's
// insta-mine); otherwise it waits for the client's STOP_DESTROY_BLOCK
// (`guest_stop_digging`), never breaking it on its own count. Breaking it here would
// take the block from under the player's crack before the player's client predicts the
// break, so it gets neither particles nor sound.
if m.progress >= 1.0 && (self.lan_acting.is_none() || m.ticks == 1) {
let pos = m.pos;
self.mining = None;
self.break_block(pos)?;
@@ -3937,13 +4026,7 @@ impl Game<'_> {
.as_ref()
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
player.pitch = self.pitch;
// LivingEntity.canGlideUsing: a glider in its slot that will not break next.
player.glider = self.armor[1].as_ref().is_some_and(|s| {
self.res
.items()
.get(&s.item)
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
});
player.glider = self.glider_worn();
// A saved death already dropped inventory/XP and counted the death.
if player.health.dead_or_dying() {
self.screen = Screen::Dead;
@@ -4021,6 +4104,7 @@ impl Game<'_> {
let riding = self.riding.is_some();
let ghast_collisions = self.happy_ghast_player_collisions();
let spin_targets = self.spin_targets();
self.sync_player_body();
let Some(player) = self.player.as_mut() else {
// A dedicated server has no local player: the world still runs.
if self.options.dedicated.is_some() {
@@ -4052,52 +4136,10 @@ impl Game<'_> {
if !riding {
self.previous_position = player.position;
}
player.yaw = self.yaw;
let spectator = self.mode == horizon_client::command::args::GameMode::Spectator;
player.may_fly = self.creative || spectator;
// LocalPlayer.sendPosition: autoJumpEnabled follows the option.
player.auto_jump_enabled = self.settings.auto_jump;
player.invulnerable = self.creative || spectator;
player.spectator = spectator;
player.respiration = self.armor[0]
.as_ref()
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
player.pitch = self.pitch;
// LivingEntity.canGlideUsing: a glider in its slot that will not break next.
player.glider = self.armor[1].as_ref().is_some_and(|s| {
self.res
.items()
.get(&s.item)
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
});
// LocalPlayer.modifyInput: walking while using an item.
player.item_use_speed = self
.using
.and_then(|(slot, _)| {
if slot == shield::OFFHAND_SLOT {
self.offhand.as_ref()
} else {
self.inventory.get(slot)?.as_ref()
}
})
.and_then(|s| self.res.items().get(&s.item))
.map(|i| i.use_speed);
// PowderSnowBlock.canEntityWalkOnPowderSnow and LivingEntity.canFreeze.
player.leather_boots = self.armor[3]
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:leather_boots");
let immune = self
.res
.items()
.item_tags
.get("minecraft:freeze_immune_wearables");
player.freeze_immune = self
.armor
.iter()
.flatten()
.any(|s| immune.is_some_and(|t| t.contains(&*s.item)));
// The dimension's FAST_LAVA: the Nether's lava pushes harder.
player.fluids.fast_lava = self.dim == Dim::Nether;
player.spin_targets = spin_targets;
let grounded = player.ground.on_ground;
let before = (
@@ -4306,20 +4348,8 @@ impl Game<'_> {
.experience
.death_reward(self.ui.rules.bool("keep_inventory") || self.spectator());
self.spawn_orbs(feet, reward);
if !self.creative && !self.ui.rules.bool("keep_inventory") {
// Inventory.dropAll: slots 0..36, armor feet to head (36..39), off hand (40).
let mut stacks: Vec<ItemStack> =
self.inventory.iter_mut().filter_map(Option::take).collect();
stacks.extend(self.armor.iter_mut().rev().filter_map(Option::take));
stacks.extend(self.offhand.take());
self.apply_equipment();
for stack in stacks {
let r = &mut self.world.random;
let strength = f64::from(r.next_float()) * 0.5;
let angle = f64::from(r.next_float()) * std::f64::consts::TAU;
let v = [-angle.sin() * strength, 0.2, angle.cos() * strength];
self.spawn_item(stack, at, v, 40);
}
if !self.spectator() {
self.drop_inventory(at);
}
self.audio
.play("minecraft:entity.player.death", Some(at), 1.0, 1.0);
@@ -5992,6 +6022,37 @@ impl Game<'_> {
}
true
}
/// `CreativeModeInventoryScreen.keyPressed` on the search tab: a hotbar
/// key over a slot swaps before the search box sees it.
fn search_tab_hotbar_key(&mut self, code: KeyCode) -> bool {
use horizon_client::creative::{Hover, search_key_swaps};
let hotbar_key = HOTBAR_DIGITS.contains(&code);
let numeric = hotbar_key || NUMERIC_KEYS.contains(&code);
if !numeric {
return false;
}
let Some(size) = self
.renderer
.as_ref()
.map(|r| [r.config.width, r.config.height])
else {
return false;
};
let batch = HudBatch::new(size);
let hover = match self.slot_at(&batch, self.cursor.0, self.cursor.1) {
None => Hover::Nothing,
Some(slot) if slot >= Creative::DISPLAY => {
let first = self.creative_screen.first_row(&self.pack.names);
let shown = self.creative_screen.items(&self.pack.names).len();
Hover::Display {
filled: first * 9 + slot - Creative::DISPLAY < shown,
}
}
Some(_) => Hover::Slot,
};
search_key_swaps(numeric, hotbar_key, hover, self.carried.is_some())
&& self.inventory_hotkey(code)
}
/// Number keys, F and Q over a slot of the open menu: SWAP with a hotbar
/// slot or the off hand, THROW one or the whole stack.
fn inventory_hotkey(&mut self, key: KeyCode) -> bool {
@@ -6007,19 +6068,7 @@ impl Game<'_> {
let Some(slot) = self.slot_at(&batch, self.cursor.0, self.cursor.1) else {
return false;
};
let digit = [
KeyCode::Digit1,
KeyCode::Digit2,
KeyCode::Digit3,
KeyCode::Digit4,
KeyCode::Digit5,
KeyCode::Digit6,
KeyCode::Digit7,
KeyCode::Digit8,
KeyCode::Digit9,
]
.iter()
.position(|k| *k == key);
let digit = HOTBAR_DIGITS.iter().position(|k| *k == key);
let done = match (digit, key) {
(Some(n), _) => self.menu_click(Some(slot), n as i32, Input::Swap),
(None, KeyCode::KeyF) => self.menu_click(Some(slot), 40, Input::Swap),
@@ -10330,6 +10379,11 @@ impl Game<'_> {
if (self.screen == Screen::Inventory
|| (self.screen == Screen::Creative && !self.creative_screen.tab().search))
&& self.inventory_hotkey(key) => {}
// The search tab's number keys (scripted presses; the window's
// keys reach search_tab_hotbar_key before the search box).
_ if self.screen == Screen::Creative
&& self.creative_screen.tab().search
&& self.search_tab_hotbar_key(key) => {}
KeyCode::KeyT if self.screen == Screen::Inventory => self.recipe_focus_search(),
KeyCode::KeyT if self.screen == Screen::Playing => self.open_chat(""),
// key.advancements.
@@ -11201,7 +11255,11 @@ impl ApplicationHandler for Game<'_> {
&& event.state == ElementState::Pressed
&& !matches!(event.physical_key, PhysicalKey::Code(KeyCode::Escape)) =>
{
if let Some(shortcut) = self.edit_shortcut(&event) {
if let PhysicalKey::Code(key) = event.physical_key
&& self.search_tab_hotbar_key(key)
{
// The hovered slot took the key (`ignoreTextInput`).
} else if let Some(shortcut) = self.edit_shortcut(&event) {
self.creative_search_shortcut(shortcut);
} else {
let backspace = event.physical_key == PhysicalKey::Code(KeyCode::Backspace);
@@ -11862,6 +11920,7 @@ impl<'a> Game<'a> {
recipe_ui: Default::default(),
mount_menu: Default::default(),
hook: None,
remote_hooks: Vec::new(),
tridents: Vec::new(),
rockets: Vec::new(),
ominous_spawners: Vec::new(),
@@ -12139,6 +12198,33 @@ impl Game<'_> {
}
}
/// `key.hotbar.1` to `key.hotbar.9` in a menu.
const HOTBAR_DIGITS: [KeyCode; 9] = [
KeyCode::Digit1,
KeyCode::Digit2,
KeyCode::Digit3,
KeyCode::Digit4,
KeyCode::Digit5,
KeyCode::Digit6,
KeyCode::Digit7,
KeyCode::Digit8,
KeyCode::Digit9,
];
/// The other keys `InputConstants.Key.getNumericKeyValue` gives a digit.
const NUMERIC_KEYS: [KeyCode; 11] = [
KeyCode::Digit0,
KeyCode::Numpad0,
KeyCode::Numpad1,
KeyCode::Numpad2,
KeyCode::Numpad3,
KeyCode::Numpad4,
KeyCode::Numpad5,
KeyCode::Numpad6,
KeyCode::Numpad7,
KeyCode::Numpad8,
KeyCode::Numpad9,
];
const STAGES: u32 = 10;
const STAGE_LINES: [&str; 10] = [
"Loading menu",
+7
View File
@@ -335,6 +335,12 @@ impl Game<'_> {
Some(Ticker::Shulker { .. }) => v["lid"] = json!(self.block_anim(pos, 1.0).shulker.0),
_ => {}
}
// The lines of a sign, as this client has them.
if let Some(horizon_client::containers::BlockEntity::Sign(sign)) =
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
{
v["sign"] = json!({ "front": sign.front.lines, "back": sign.back.lines });
}
v
}
pub(super) fn stack_value(&self, s: &ItemStack) -> Value {
@@ -457,6 +463,7 @@ impl Game<'_> {
"velocity": [p.velocity.x, p.velocity.y, p.velocity.z],
"on_ground": p.ground.on_ground,
"flying": p.flying,
"fall_flying": p.fall_flying,
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
"health": p.health.health(),
+124 -29
View File
@@ -3,9 +3,10 @@
//! pixels, 1024 characters, 100 pages), signing it into a written book,
//! reading written books, and lecterns (placing a book, turning its pages
//! with a redstone pulse, the comparator output and taking the book).
use super::Game;
use super::{Game, lan::LanHost};
use horizon_client::{
containers::{BlockEntity, Lectern},
first_person::Hand,
hud::{HudBatch, rgba},
item_data::WrittenBook,
items::ItemStack,
@@ -14,6 +15,7 @@ use horizon_client::{
ui,
};
use horizon_core::position::BlockPos;
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
use horizon_world::level::flags as level_flags;
use std::time::Instant;
use winit::{
@@ -48,9 +50,32 @@ impl Game<'_> {
fn book_lines(&self, page: &str) -> Vec<Text> {
self.gui.atlas.font.split(&Text::literal(page), 114.0)
}
/// `WritableBookItem.use` / `WrittenBookItem.use`.
/// `WritableBookItem.use` / `WrittenBookItem.use` (`Player.openItemGui`).
/// A server's player writes in its own client's editor
/// (`LocalPlayer.openItemGui`), and reads on its own client once told to
/// (`ServerPlayer.openItemGui`'s ClientboundOpenBookPacket).
pub(super) fn use_book(&mut self, stack: &ItemStack) -> bool {
let screen = match &*stack.item {
let written = match &*stack.item {
"minecraft:writable_book" => false,
"minecraft:written_book" => true,
_ => return false,
};
if self.lan_acting.is_some() {
if written {
let hand = i32::from(self.hand == Hand::Off);
self.guest_packets
.push(C::OpenBook(play::OpenBook { hand }));
}
} else if let Some(screen) = self.book_screen(stack) {
self.open_book(screen);
}
self.swing_arm();
true
}
/// The screen a book in hand opens: `BookEditScreen` for a book and
/// quill, `BookViewScreen` for a written book.
fn book_screen(&self, stack: &ItemStack) -> Option<BookScreen> {
Some(match &*stack.item {
"minecraft:writable_book" => {
let mut pages = stack
.data()
@@ -77,12 +102,31 @@ impl Game<'_> {
page: 0,
lectern: None,
},
_ => return false,
};
self.open_book(screen);
self.swing_arm();
_ => return None,
})
}
/// `MultiPlayerGameMode.useItem`'s own use of a book and quill on a
/// server's client: its editor opens here at once.
pub(super) fn remote_use_book(&mut self, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:writable_book" {
return false;
}
if let Some(screen) = self.book_screen(stack) {
self.open_book(screen);
}
true
}
/// `ClientPacketListener.handleOpenBook`: the written book in that hand.
pub(super) fn remote_open_book(&mut self, hand: i32) {
let hand = if hand == 1 { Hand::Off } else { Hand::Main };
let screen = self.with_hand(hand, |g| {
let stack = g.held().filter(|s| &*s.item == "minecraft:written_book")?;
g.book_screen(&stack.clone())
});
if let Some(screen) = screen {
self.open_book(screen);
}
}
fn open_book(&mut self, screen: BookScreen) {
self.book = Some(screen);
self.keys.clear();
@@ -104,7 +148,9 @@ impl Game<'_> {
self.book = None;
self.capture(true);
}
/// `BookEditScreen.saveChanges`: trailing empty pages dropped.
/// `BookEditScreen.saveChanges` and `BookSignScreen`'s Sign and Close:
/// trailing empty pages dropped, the book in hand updated here and, on a
/// server's client, sent (ServerboundEditBookPacket).
fn save_book(&mut self, sign: bool) {
let Some(BookScreen::Edit {
slot, pages, title, ..
@@ -116,29 +162,59 @@ impl Game<'_> {
while pages.len() > 1 && pages.last().is_some_and(String::is_empty) {
pages.pop();
}
if let Some(stack) = self.stack_in_mut(slot).as_mut()
&& &*stack.item == "minecraft:writable_book"
{
if sign {
let mut written = ItemStack::new("minecraft:written_book", 1);
written.data_mut().written = Some(WrittenBook {
title: title.trim().to_owned(),
author: super::chat::PLAYER_NAME.to_owned(),
generation: 0,
pages,
});
*stack = written;
} else {
stack.data_mut().writable_pages = if pages.iter().all(String::is_empty) {
Vec::new()
} else {
pages
};
stack.tidy();
}
let title = sign.then(|| title.trim().to_owned());
if self.remote.is_some() {
self.send(S::EditBook(play::EditBook {
slot: slot as i32,
pages: pages.clone(),
title: title.clone(),
}));
}
self.write_book(slot, pages, title);
self.capture(true);
}
/// The book and quill at `slot` takes the pages, or with a title becomes
/// the written book its writer signed.
fn write_book(&mut self, slot: usize, pages: Vec<String>, title: Option<String>) {
let author = self
.lan_acting_name
.clone()
.unwrap_or_else(|| super::chat::PLAYER_NAME.to_owned());
let Some(stack) = self
.stack_in_mut(slot)
.as_mut()
.filter(|s| &*s.item == "minecraft:writable_book")
else {
return;
};
if let Some(title) = title {
let mut written = ItemStack::new("minecraft:written_book", 1);
written.data_mut().written = Some(WrittenBook {
title,
author,
generation: 0,
pages,
});
*stack = written;
} else {
stack.data_mut().writable_pages = if pages.iter().all(String::is_empty) {
Vec::new()
} else {
pages
};
stack.tidy();
}
}
/// `ServerGamePacketListenerImpl.handleEditBook`: a guest's book in a
/// hotbar slot or its off hand (`updateBookContents`, `signBook`).
pub(super) fn lan_edit_book(&mut self, host: &mut LanHost, conn: u32, p: play::EditBook) {
if !((0..9).contains(&p.slot) || p.slot == super::shield::OFFHAND_SLOT as i32) {
return;
}
self.as_guest(host, conn, |g| {
g.write_book(p.slot as usize, p.pages, p.title)
});
}
pub(super) fn book_key(&mut self, event: &KeyEvent) {
if event.state != ElementState::Pressed {
return;
@@ -519,7 +595,26 @@ impl Game<'_> {
Some(key) => self.lectern_button(key, 3),
None => return Err("not a lectern".into()),
},
Some("done") => self.close_book(),
// The book and quill's buttons: Sign, then Sign and Close; Done
// (which saves), then Cancel.
Some("sign") => match self.book.as_mut() {
Some(BookScreen::Edit {
signing: true,
title,
..
}) if !title.trim().is_empty() => self.save_book(true),
Some(BookScreen::Edit { signing, .. }) => *signing = true,
_ => return Err("not a book and quill".into()),
},
Some("done") => match self.book.as_mut() {
Some(BookScreen::Edit { signing: true, .. }) => {
if let Some(BookScreen::Edit { signing, .. }) = self.book.as_mut() {
*signing = false;
}
}
Some(BookScreen::Edit { .. }) => self.save_book(false),
_ => self.close_book(),
},
Some(other) => return Err(format!("unknown book button {other}").into()),
}
Ok(match &self.book {
+13 -1
View File
@@ -232,6 +232,13 @@ impl Game<'_> {
"key" => {
let key = key_code(arg(1)?).ok_or_else(|| format!("unknown key {}", words[1]))?;
self.require_world()?;
// The sign editor takes raw presses (Enter, arrows, Backspace, Escape).
if self.sign_edit.is_some() {
if arg(2)? != "up" {
self.sign_input(Some(key), None);
}
return ok();
}
// A held edit modifier (`key super down`) and A/C/V/X: the
// open book's Select All, Copy, Paste or Cut.
if self.book.is_some() && arg(2)? != "up" {
@@ -524,6 +531,10 @@ impl Game<'_> {
self.book_type(text);
return ok();
}
if self.sign_edit.is_some() {
self.sign_input(None, Some(text));
return ok();
}
match self.screen {
Screen::Chat => self.chat_type(text),
Screen::CommandBlock => self.command_block_type(text),
@@ -549,7 +560,7 @@ impl Game<'_> {
}
_ => {
return Err(
"type needs the chat screen (key t tap), the language search, the creative search, the anvil or World Options".into(),
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
);
}
}
@@ -977,6 +988,7 @@ impl Game<'_> {
"flashes": self.lightning_seen[1], "flash": self.flash});
// Primed TNT, falling blocks, projectiles, tridents and end crystals.
status["projectiles"] = json!(self.projectiles_status());
status["maps"] = json!(self.maps_status());
// The particles made so far, by type.
status["particles"] = json!(self.particles.made());
// The world border this side knows of.
+3 -1
View File
@@ -47,6 +47,8 @@ pub(super) struct Creatures {
pub pearl_cooldown: u32,
/// The wind charge's use cooldown (ticks).
pub wind_cooldown: u32,
/// Wind bursts from a guest's turn, waiting for it to end (game/wind.rs).
pub wind_bursts: Vec<super::wind::Burst>,
/// Ticks left of the elder guardian's apparition (MobAppearanceParticle).
pub elder_apparition: i32,
/// The wandering trader spawner's clock.
@@ -1778,7 +1780,7 @@ impl Game<'_> {
_ => self.projectile_hit(&p, hit)?,
}
}
Ok(())
self.flush_wind_bursts()
}
/// `projectile_hit` for a projectile the guest `conn` shot, run as them;
/// as the host's when they have left.
+12 -6
View File
@@ -69,12 +69,18 @@ impl Game<'_> {
let lo = Vec3::new(f.pos[0] - half, f.pos[1], f.pos[2] - half);
let hi = Vec3::new(f.pos[0] + half, f.pos[1] + 0.8, f.pos[2] + half);
let b = horizon_core::geometry::Aabb::new(lo, hi);
if let Some(p) = self.player_view()
&& p.alive
&& p.bounds.intersects(b)
{
self.mob_hits_player_kind(6.0, f.pos, "minecraft:indirect_magic");
}
// Every player in the box, the server's players too.
let pos = f.pos;
let bite = move |g: &mut Self| {
if let Some(p) = g.player_view()
&& p.alive
&& p.bounds.intersects(b)
{
g.mob_hits_player_kind(6.0, pos, "minecraft:indirect_magic");
}
};
bite(self);
self.each_guest_hit(bite);
for m in &mut self.mobs {
let ally = matches!(
m.species(),
+241 -41
View File
@@ -2,6 +2,7 @@
//! bobbing, lure and bite timers, open water, hooking mobs, retrieving the
//! `gameplay/fishing` loot) and `FishingHookRenderer` (bobber and line).
use super::{Game, view_vector};
use horizon_client::lan::{entity::States, hub::Hub};
use horizon_client::{
blocks::Fluid,
collisions::SceneCollisions,
@@ -15,7 +16,10 @@ use horizon_core::{
geometry::{Aabb, Vec3, trig},
random::RandomSource,
};
use std::sync::Arc;
use horizon_protocol::entity_data::{Value, serializer};
use std::{collections::HashMap, sync::Arc};
use super::lan_actions::GuestPlay;
#[derive(Clone, Copy, Debug, PartialEq)]
enum State {
@@ -24,6 +28,8 @@ enum State {
Hooked(u32),
}
pub(super) struct Hook {
/// Its entity id, which a server's players track it under.
id: u32,
position: Vec3,
previous: Vec3,
velocity: Vec3,
@@ -114,7 +120,9 @@ impl Game<'_> {
);
// A spawned hook's start position is its feet: centre the box on the eye.
let start = Vec3::new(start.x, start.y - SIZE / 2.0, start.z);
self.next_entity += 1;
self.hook = Some(Hook {
id: self.next_entity,
position: start,
previous: start,
velocity: v,
@@ -562,12 +570,17 @@ impl Game<'_> {
lerp(h.previous.z, h.position.z),
))
}
/// FishingHookRenderer: the bobber, a camera-facing half-block quad
/// centred on the hook.
/// FishingHookRenderer: the bobbers (this player's and the others' a
/// server tracks), camera-facing half-block quads centred on the hooks.
pub(super) fn fishing_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let Some(p) = self.hook_position(alpha) else {
let hooks: Vec<glam::DVec3> = self
.hook_position(alpha)
.into_iter()
.chain(self.remote_hook_ends(alpha).into_iter().map(|(h, _)| h))
.collect();
if hooks.is_empty() {
return;
};
}
let Some(r) = self
.gui
.atlas
@@ -577,56 +590,57 @@ impl Game<'_> {
else {
return;
};
let light = self
.world
.light(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
let forward = glam::DVec3::from_array(view_vector(self.yaw, self.pitch));
let right = forward.cross(glam::DVec3::Y).normalize_or_zero();
let up = right.cross(forward).normalize_or_zero();
let s = 0.25;
let corners = [
p - right * s - up * s,
p + right * s - up * s,
p + right * s + up * s,
p - right * s + up * s,
];
let uv = [[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]];
for (v, uv) in corners.iter().zip(uv) {
out.push(Vertex {
normal: [0; 4],
pos: (*v - camera).as_vec3().to_array(),
uv,
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
});
for p in hooks {
let light =
self.world
.light(p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
let corners = [
p - right * s - up * s,
p + right * s - up * s,
p + right * s + up * s,
p - right * s + up * s,
];
for (v, uv) in corners.iter().zip(uv) {
out.push(Vertex {
normal: [0; 4],
pos: (*v - camera).as_vec3().to_array(),
uv,
layer: 0,
color: 0xFFFF_FFFF,
light: packed,
});
}
}
}
/// FishingHookRenderer: the line, 16 black `lines` segments sagging from
/// a quarter block above the hook to the rod in the player's hand,
/// FishingHookRenderer: each line, 16 black `lines` segments sagging
/// from a quarter block above the hook to the rod in its owner's hand,
/// `width` pixels wide (`Window.getAppropriateLineWidth`).
pub(super) fn fishing_line(&self, alpha: f64, size: [u32; 2]) -> Vec<Line> {
let (Some(hook), Some(hand)) = (
self.hook_position(alpha),
self.rod_hand_position(alpha, size),
) else {
return Vec::new();
};
let offset = (hand - (hook + glam::DVec3::new(0.0, 0.25, 0.0))).as_vec3();
let point = |a: f32| {
let y = offset.y * (a * a + a) * 0.5 + 0.25;
hook + glam::Vec3::new(offset.x * a, y, offset.z * a).as_dvec3()
};
let own = self
.hook_position(alpha)
.zip(self.rod_hand_position(alpha, size));
let width = horizon_client::gfx::outline_width(size[0]);
(0..16)
.map(|i| Line {
let mut lines = Vec::new();
for (hook, hand) in own.into_iter().chain(self.remote_hook_ends(alpha)) {
let offset = (hand - (hook + glam::DVec3::new(0.0, 0.25, 0.0))).as_vec3();
let point = |a: f32| {
let y = offset.y * (a * a + a) * 0.5 + 0.25;
hook + glam::Vec3::new(offset.x * a, y, offset.z * a).as_dvec3()
};
lines.extend((0..16).map(|i| Line {
start: point(i as f32 / 16.0).to_array(),
end: point((i + 1) as f32 / 16.0).to_array(),
color: 0xFF00_0000,
width,
})
.collect()
}));
}
lines
}
/// `FishingHookRenderer.getPlayerHandPos`: on the near plane in first
/// person, beside the body otherwise.
@@ -694,6 +708,20 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// The hooks drawn here, with their owners' ids (0 for this player's own).
pub(super) fn hooks_status(&self) -> Vec<serde_json::Value> {
let at = |p: Vec3| [p.x, p.y, p.z];
let own = self.hook.iter().map(|h| (h.id, 0, h.position));
let others = self
.remote_hooks
.iter()
.map(|h| (h.id, h.owner, h.position));
own.chain(others)
.map(|(id, owner, p)| {
serde_json::json!({"id": id, "kind": "minecraft:fishing_bobber", "owner": owner, "position": at(p)})
})
.collect()
}
/// The hook for `horizon-ctl status`.
pub(super) fn hook_status(&self) -> Option<serde_json::Value> {
let h = self.hook.as_ref()?;
@@ -706,3 +734,175 @@ impl Game<'_> {
}))
}
}
/// A hook a server tracks to this client (`FishingHook.recreateFromPacket`)
/// that another player cast: drawn where the server puts it, its line
/// running to that player's hand.
pub(super) struct RemoteHook {
id: u32,
owner: u32,
position: Vec3,
previous: Vec3,
}
impl Hook {
/// This client's own hook as its server tracks it: drawn, never ticked here.
fn remote(id: u32, at: Vec3) -> Self {
Self {
id,
position: at,
previous: at,
velocity: Vec3::ZERO,
state: State::Flying,
life: 0,
on_ground: false,
nibble: 0,
time_until_lured: 0,
time_until_hooked: 0,
fish_angle: 0.0,
out_of_water: 0,
open_water: true,
luck: 0,
lure: 0,
}
}
/// `FishingHook`'s entity for the players tracking it: the owner's id in
/// its add packet, DATA_HOOKED_ENTITY (the hooked id plus one) and
/// DATA_BITING when they differ from their defaults.
fn track(&self, owner: i32, out: &mut States) {
let p = self.position;
let e = out.add(
self.id as i32,
super::lan::entity_uuid(self.id),
"minecraft:fishing_bobber",
);
e.position = [p.x, p.y, p.z];
e.velocity = [self.velocity.x, self.velocity.y, self.velocity.z];
e.on_ground = self.on_ground;
e.data = owner;
if let State::Hooked(id) = self.state {
e.meta.push((8, serializer::INT, Value::Int(id as i32 + 1)));
}
if self.nibble > 0 {
e.meta.push((9, serializer::BOOLEAN, Value::Bool(true)));
}
}
}
impl Game<'_> {
/// The hooks guests track: the host's and each guest's in the level.
pub(super) fn lan_hook_states(
&self,
host_entity: Option<i32>,
play: &HashMap<u32, GuestPlay>,
hub: &Hub,
out: &mut States,
) {
if let (Some(h), Some(owner)) = (&self.hook, host_entity) {
h.track(owner, out);
}
for g in hub.guests.iter().filter(|g| hub.here(g)) {
if let Some(h) = play.get(&g.conn).and_then(|p| p.hook.as_ref()) {
h.track(g.entity_id, out);
}
}
}
/// `handleAddEntity` for a fishing bobber: this player's own becomes its
/// hook (`Player.fishing`), another player's is drawn on its line to them.
pub(super) fn spawn_remote_hook(&mut self, id: u32, at: Vec3, owner: i32) {
let own = self.remote.as_ref().map(|r| r.entity_id);
if own == Some(owner) {
self.hook = Some(Hook::remote(id, at));
} else {
self.remote_hooks.push(RemoteHook {
id,
owner: owner as u32,
position: at,
previous: at,
});
}
}
/// One tick of interpolation for the hooks a server tracks.
pub(super) fn step_remote_hooks(&mut self, mut step: impl FnMut(u32, Vec3) -> Vec3) {
if self.remote.is_none() {
return;
}
if let Some(h) = self.hook.as_mut() {
h.previous = h.position;
h.position = step(h.id, h.position);
}
for h in &mut self.remote_hooks {
h.previous = h.position;
h.position = step(h.id, h.position);
}
}
/// `FishingHook.onSyncedDataUpdated`: this player's hook is biting
/// (DATA_BITING), which `status` shows as a nibble.
pub(super) fn remote_hook_data(&mut self, id: u32, values: &[(u8, i32, Value)]) {
let Some(h) = self
.hook
.as_mut()
.filter(|h| h.id == id && self.remote.is_some())
else {
return;
};
for (index, _, value) in values {
if let (9, Value::Bool(biting)) = (index, value) {
h.nibble = i32::from(*biting);
}
}
}
pub(super) fn remove_remote_hook(&mut self, id: u32) {
if self.remote.is_some() && self.hook.as_ref().is_some_and(|h| h.id == id) {
self.hook = None;
}
self.remote_hooks.retain(|h| h.id != id);
}
pub(super) fn clear_remote_hooks(&mut self) {
if self.remote.is_some() {
self.hook = None;
}
self.remote_hooks.clear();
}
/// Other players' hooks: the bobber and its line to the owner's hand
/// (`FishingHookRenderer.getPlayerHandPos` in third person), as
/// (hook, hand) this frame.
fn remote_hook_ends(&self, alpha: f64) -> Vec<(glam::DVec3, glam::DVec3)> {
let lerp = |a: Vec3, b: Vec3| {
glam::DVec3::new(
a.x + (b.x - a.x) * alpha,
a.y + (b.y - a.y) * alpha,
a.z + (b.z - a.z) * alpha,
)
};
self.remote_hooks
.iter()
.filter_map(|h| {
let owner = self.mobs.iter().find(|m| m.id == h.owner)?;
let feet = lerp(owner.previous, owner.position);
let body = horizon_client::camera::lerp(
alpha as f32,
owner.previous_body_yaw,
owner.body_yaw,
);
let angle = f64::from(body * (std::f64::consts::PI / 180.0) as f32);
let (sin, cos) = (f64::from(trig::sin(angle)), f64::from(trig::cos(angle)));
// The right arm; a crouching owner's eyes are lower.
let eye = owner.eye_y() - owner.position.y;
let hand = feet
+ glam::DVec3::new(
-cos * 0.35 - sin * 0.8,
eye - 0.45,
-sin * 0.35 + cos * 0.8,
);
Some((lerp(h.previous, h.position), hand))
})
.collect()
}
}
+4 -6
View File
@@ -6,12 +6,10 @@
//! the world's options) with [`Integrated::call`], as vanilla's client calls
//! into its `IntegratedServer`.
//!
//! Opt-in again (`--server-thread` or `HORIZON_SERVER_THREAD=1`) until a
//! server's players reach parity with the in-frame world: on the server
//! thread, signs open no editor, books don't open, a totem doesn't save its
//! holder and death keeps the inventory (#1380, #1366, #1292, #1284 and the
//! other `[server-thread]` issues). LAN guests and dedicated servers use the
//! same code either way.
//! On by default. `--in-frame` (or `--no-server-thread`, or
//! `HORIZON_SERVER_THREAD=0`) runs the world in the frame instead, the
//! escape hatch. LAN guests and dedicated servers use the same code either
//! way.
use super::{
Game, Options, PackResources, Resources, UiResources,
lan::{LanSettings, Owner},
+9
View File
@@ -411,7 +411,12 @@ impl Game<'_> {
"game_mode": g.game_mode,
// ItemCooldowns: the ticks the shield an axe disabled stays down.
"shield_cooldown": h.play.get(&g.conn).map_or(0, |p| p.shield_cooldown),
// The item the server has this guest using: its slot and ticks.
"using": h.play.get(&g.conn).and_then(|p| p.using())
.map(|(slot, ticks)| json!({ "slot": slot, "ticks": ticks })),
"sneaking": g.sneaking,
// The server's Elytra glide (shared flag 7).
"fall_flying": g.fall_flying,
"sleeping": g.bed,
"health": g.health,
// The server's body: its air supply and golden hearts.
@@ -870,6 +875,7 @@ impl Game<'_> {
.collect();
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
self.lan_hook_states(host_entity, &host.play, &host.hub, &mut states);
} else {
states.clear();
}
@@ -1006,6 +1012,8 @@ impl Game<'_> {
drop(holding);
lan::flush_all(host.hub.guests.iter().map(|g| &g.sender));
self.lan = Some(host);
// A mace's wind burst from a guest's attack.
let _ = self.flush_wind_bursts();
}
fn lan_guest_joined(
@@ -1061,6 +1069,7 @@ impl Game<'_> {
}
// A dedicated server has no host player.
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
self.lan_hook_states(None, &host.play, &host.hub, &mut theirs);
theirs.as_slice()
} else {
entities
+142 -19
View File
@@ -30,6 +30,8 @@ use winit::keyboard::KeyCode;
/// Per-guest state of the gameplay code that lives outside `Guest`.
#[derive(Default)]
pub(super) struct GuestPlay {
/// The sign this guest may write on (`SignBlockEntity.playerWhoMayEdit`).
pub(super) sign_editing: Option<[i32; 3]>,
mining: Option<Mining>,
destroy_delay: u32,
using: Option<(usize, u32)>,
@@ -80,6 +82,8 @@ pub(super) struct GuestPlay {
/// (`LivingEntity.collectEquipmentChanges`).
modifiers: Vec<(String, String)>,
equipped: Option<Vec<Option<ItemStack>>>,
/// The guest's cast fishing hook (`Player.fishing`).
pub(super) hook: Option<super::fishing::Hook>,
}
/// A guest's command waiting for the hub (`LanHost::commands`).
@@ -259,6 +263,7 @@ impl Game<'_> {
motion.pitch = g.pitch;
motion.sprinting = g.sprinting;
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
motion.flying = g.flying;
motion.invulnerable = motion.may_fly;
let _ = motion.set_health(g.health, false);
motion.health.food.food = g.food;
@@ -342,6 +347,7 @@ impl Game<'_> {
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
std::mem::swap(&mut self.stats, &mut play.stats);
std::mem::swap(&mut self.hook, &mut play.hook);
let adv_loaded = std::mem::replace(&mut self.adv.loaded, true);
let last_inventory = std::mem::replace(&mut self.adv.last_inventory, play.last_inventory);
let death_cause = std::mem::replace(&mut self.ui.death_cause, play.death_cause.take());
@@ -513,7 +519,8 @@ impl Game<'_> {
std::mem::swap(&mut self.mount, &mut play.mount);
self.container = container;
self.screen = screen;
self.sign_edit = host_sign;
// A sign the guest's placement opened (SignBlock.openTextEdit).
let guest_sign = std::mem::replace(&mut self.sign_edit, host_sign);
// A lectern the guest's use opened (LecternBlock.openScreen).
let guest_lectern = match std::mem::replace(&mut self.book, host_book) {
Some(super::books::BookScreen::View {
@@ -548,6 +555,7 @@ impl Game<'_> {
std::mem::swap(&mut self.adv.unlocked, &mut g.recipes_unlocked);
std::mem::swap(&mut self.level.recipe_book, &mut g.recipe_book);
std::mem::swap(&mut self.stats, &mut play.stats);
std::mem::swap(&mut self.hook, &mut play.hook);
self.adv.loaded = adv_loaded;
play.last_inventory = std::mem::replace(&mut self.adv.last_inventory, last_inventory);
let stat_events = std::mem::take(&mut play.stats.events);
@@ -652,6 +660,9 @@ impl Game<'_> {
if let Some(station) = opened_station {
self.lan_station_opened(host, conn, station);
}
if let Some(edit) = guest_sign {
self.lan_open_sign_editor(host, conn, edit);
}
if let Some(key) = guest_lectern {
self.lan_open_lectern(host, conn, key);
}
@@ -865,14 +876,20 @@ impl Game<'_> {
self.as_guest(host, conn, |g| g.drop_held(a.action == 4));
}
}
// RELEASE_USE_ITEM: ServerPlayer.releaseUsingItem, with the
// hand of the item in use acting.
6 => {
self.as_guest(host, conn, |g| {
if let Some((slot, ticks)) = g.using.take()
&& g.stack_in(slot)
.is_some_and(|s| &*s.item == "minecraft:bow")
{
g.release_bow(ticks);
}
let Some(hand) = g.used_hand() else {
return;
};
g.with_hand(hand, |g| {
if let Some((slot, ticks)) = g.using.take()
&& let Some(stack) = g.stack_in(slot).cloned()
{
g.release_used_item(&stack, ticks, false);
}
});
});
}
// SWAP_ITEM_WITH_OFFHAND: the hands swap and the item in use stops
@@ -1029,30 +1046,27 @@ impl Game<'_> {
g.open_ridden_mount_menu();
});
}
S::PlayerCommand(p) if p.action == 6 => self.lan_start_gliding(host, conn),
S::ContainerButtonClick(p) => {
self.lan_station_button(host, conn, p.container, p.button);
}
S::RenameItem(p) => self.lan_station_rename(host, conn, &p.name),
S::SignUpdate(p) => self.lan_sign_update(host, conn, p),
S::EditBook(p) => self.lan_edit_book(host, conn, p),
S::ContainerSlotStateChanged(p) => {
self.lan_crafter_slot_state(host, conn, p.container, p.slot, p.enabled);
}
S::SetBeacon(p) => self.lan_station_beacon(host, conn, p.primary, p.secondary),
S::SelectTrade(p) => self.lan_select_trade(host, conn, p.item),
S::ContainerClose(p) if self.lan_merchant_closed(host, conn, Some(p.container)) => {
let g = &mut host.hub.guests[gi];
if let Some(c) = g.carried.take() {
self.drop_guest_items(host, gi, vec![c]);
}
self.guest_carried_back(host, gi);
}
S::ContainerClose(p)
if self.lan_crafting_closed(host, conn, Some(p.container))
|| self.lan_station_closed(host, conn, Some(p.container))
|| self.lan_mount_closed(host, conn, Some(p.container)) =>
{
let g = &mut host.hub.guests[gi];
if let Some(c) = g.carried.take() {
self.drop_guest_items(host, gi, vec![c]);
}
self.guest_carried_back(host, gi);
}
S::ContainerClick(p) => {
let rules = ItemRules(self.res.items());
@@ -1068,10 +1082,7 @@ impl Game<'_> {
self.guest_container_closed(host, conn);
}
host.hub.container_closed(conn, p.container);
let g = &mut host.hub.guests[gi];
if let Some(c) = g.carried.take() {
self.drop_guest_items(host, gi, vec![c]);
}
self.guest_carried_back(host, gi);
}
S::ChatCommand(c) => self.guest_chat_command(host, conn, &c.command),
S::ChatCommandSigned(p) => self.guest_signed_command(host, conn, p),
@@ -1218,6 +1229,17 @@ impl Game<'_> {
Some(out)
}
/// `AbstractContainerMenu.removed`: what a closing player held on the
/// cursor goes back into their inventory (`placeItemBackInInventory`,
/// dropped only if it doesn't fit), and their client's cursor empties
/// with the inventory it is sent.
fn guest_carried_back(&mut self, host: &mut LanHost, gi: usize) {
if let Some(c) = host.hub.guests[gi].carried.take() {
self.lan_place_back(host, gi, vec![c]);
host.hub.send_inventory(gi);
}
}
pub(super) fn drop_guest_items(&mut self, host: &LanHost, gi: usize, stacks: Vec<ItemStack>) {
let g = &host.hub.guests[gi];
let eye = [
@@ -1704,16 +1726,23 @@ impl Game<'_> {
// PlayerAdvancements.flushDirty.
let sleeping = host.hub.guests[gi].sleeping;
self.as_guest(host, conn, |g| {
g.tick_fishing();
g.tick_maps();
g.count_tick_stats(sleeping);
g.tick_advancements();
g.update_player_attributes();
});
self.lan_map_updates(host, conn);
host.hub.flush_progress(conn);
self.guest_dig_feedback(host, conn);
self.guest_pickups(host, gi);
self.guest_orbs(host, gi);
if host.hub.guests[gi].health <= 0.0 && !host.hub.guests[gi].dead {
self.guest_forget_digging(host, conn);
// FishingHook.shouldStopFishing: its owner is dead.
if let Some(play) = host.play.get_mut(&conn) {
play.hook = None;
}
}
let g = &mut host.hub.guests[gi];
if g.health <= 0.0 && !g.dead {
@@ -1727,6 +1756,9 @@ impl Game<'_> {
}
.death_reward(self.ui.rules.bool("keep_inventory") || g.game_mode == SPECTATOR);
let (name, uuid) = (g.name.clone(), g.uuid);
// ServerPlayer.die: dropAllDeathLoot unless a spectator, from the eyes less 0.3.
let drops = (g.game_mode != SPECTATOR)
.then(|| [at[0], at[1] + g.eye_height() - 0.3, at[2]]);
let cause = host.play.get_mut(&conn).and_then(|p| p.death_cause.take());
let message = match cause {
// ServerPlayer.die: the death message, to everyone unless
@@ -1760,6 +1792,9 @@ impl Game<'_> {
);
// LivingEntity.die: the death animation, the dying player included.
host.hub.entity_event(id, 3, at);
if let Some(eyes) = drops {
self.as_guest(host, conn, |game| game.drop_inventory(eyes));
}
self.spawn_orbs(at, reward);
}
}
@@ -1846,6 +1881,13 @@ impl Game<'_> {
/// sounds are heard at the guest.
fn guest_server_tick(&mut self, host: &mut LanHost, conn: u32) {
self.guest_vehicle_tick(host, conn);
// The last move's fall stands in for the server's delta movement.
let fell = host
.hub
.guest_mut(conn)
.and_then(|g| g.moves.last().map(|m| m.0[1]))
.unwrap_or(0.0);
self.guest_glide_tick(host, conn, fell);
let Some(g) = host.hub.guest_mut(conn) else {
return;
};
@@ -1963,6 +2005,87 @@ impl Game<'_> {
}
}
/// ServerGamePacketListenerImpl.handlePlayerCommand START_FALL_FLYING:
/// `Player.tryToStartFallFlying`, or else `stopFallFlying` (a second
/// start while gliding ends the glide, as in vanilla).
fn lan_start_gliding(&mut self, host: &mut LanHost, conn: u32) {
let Some(g) = host.hub.guest_mut(conn) else {
return;
};
let start = !g.fall_flying && self.guest_can_glide(host, conn) == Some(true);
let in_liquid = self
.as_guest(host, conn, |game| {
game.player
.as_ref()
.is_some_and(|p| p.fluids.was_touching_water || p.is_in_lava())
})
.unwrap_or(true);
if let Some(g) = host.hub.guest_mut(conn) {
g.fall_flying = start && !in_liquid;
}
}
/// `Player.canGlide`: not flying, airborne, not riding, no levitation, and
/// a glider that its next point of wear won't break.
fn guest_can_glide(&mut self, host: &mut LanHost, conn: u32) -> Option<bool> {
let g = host.hub.guest_mut(conn)?;
if g.flying || g.on_ground || g.vehicle.is_some() {
return Some(false);
}
self.as_guest(host, conn, |game| {
game.glider_worn() && game.potions.effects.get("levitation").is_none()
})
}
/// `LivingEntity.updateFallFlying` on the server, once a tick for a
/// gliding guest: the glide ends once `canGlide` fails; otherwise every
/// 20th tick wears the glider by one, and every 10th is an
/// `elytra_glide` game event. Entity.checkFallDistanceAccumulation runs
/// first: a glide that isn't diving keeps the fall distance at 1.
fn guest_glide_tick(&mut self, host: &mut LanHost, conn: u32, fell: f64) {
let Some(g) = host.hub.guest_mut(conn) else {
return;
};
if !g.fall_flying {
if let Some(m) = g.motion.as_mut() {
m.fall_fly_ticks = 0;
}
return;
}
let creative = g.creative();
if let Some(m) = g.motion.as_mut()
&& fell > -0.5
&& m.fall_distance > 1.0
{
m.fall_distance = 1.0;
}
if self.guest_can_glide(host, conn) != Some(true) {
if let Some(g) = host.hub.guest_mut(conn) {
g.fall_flying = false;
}
return;
}
self.as_guest(host, conn, |game| {
let Some(p) = game.player.as_mut() else {
return;
};
p.fall_fly_ticks += 1;
let next = p.fall_fly_ticks + 1;
let at = [p.position.x, p.position.y, p.position.z];
if next % 10 != 0 {
return;
}
if (next / 10) % 2 == 0
&& !creative
&& let Some(elytra) = game.armor[1].as_mut()
{
elytra.damage += 1;
}
let ctx = game.player_context(None);
game.post_game_event("minecraft:elytra_glide", at, ctx);
});
}
/// `Player.isInWall`: a suffocating block in the 0.48-wide box at the
/// eyes. Suffocating is a full collision box that is also a full render
/// cube (`BlockBehaviour.isSuffocating`'s default; glass and the like
@@ -78,6 +78,8 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
target: Who::Player,
..
}
| MobEvent::EffectPlayer { .. }
| MobEvent::AxolotlSupportPlayer
)
}
@@ -115,6 +117,25 @@ impl Game<'_> {
views
}
/// Runs `f` as each guest in play here, for what strikes every player in
/// an area (`Level.getEntities`), not only the window's own.
pub(super) fn each_guest_hit(&mut self, mut f: impl FnMut(&mut Self)) {
let Some(mut host) = self.lan.take() else {
return;
};
let conns: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
.map(|g| g.conn)
.collect();
for conn in conns {
self.guest_hit_by(&mut host, conn, &mut f);
}
self.lan = Some(host);
}
/// Mob events aimed at a guest, run as that guest (`guest_hit_by`).
pub(super) fn lan_guest_mob_events(&mut self, events: Vec<(u32, MobEvent)>) {
if events.is_empty() {
+111 -33
View File
@@ -3,7 +3,7 @@
//! `/particle` uses) and `ClientboundExplodePacket` (`ServerLevel.explode`).
use super::Game;
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_core::random::RandomSource;
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_protocol::{
entity_data::{Particle, ParticleData},
packets::play::{self, Clientbound as C},
@@ -154,7 +154,7 @@ impl Game<'_> {
}
/// `ClientPacketListener.handleExplosion`: the blast's particles (and its
/// sound, when the packet carries it).
/// sound, when the packet carries it), then the player's push.
pub(super) fn remote_explode(&mut self, p: &play::Explode) {
if p.play_sound {
let pitch = (1.0
@@ -168,37 +168,88 @@ impl Game<'_> {
pitch,
);
}
let (world, facts) = (&self.world, self.res.facts());
self.particles
.explosion(p.center, p.radius, p.block_count.max(0) as usize, &|b| {
facts.is_air(world.block(b[0], b[1], b[2]))
});
if let Some(options) = self.received_particle(&p.particle, p.center) {
self.particles.add(&options, p.center, [1.0, 0.0, 0.0]);
}
// A wind charge's burst throws none.
if !p.block_particles.is_empty() {
let (world, facts) = (&self.world, self.res.facts());
self.particles.explosion_blocks(
p.center,
p.radius,
p.block_count.max(0) as usize,
&|b| facts.is_air(world.block(b[0], b[1], b[2])),
);
}
// Entity.pushFromExplosion.
if let (Some(push), Some(player)) = (p.knockback, self.player.as_mut()) {
player.velocity = Vec3::new(
player.velocity.x + push[0],
player.velocity.y + push[1],
player.velocity.z + push[2],
);
}
}
/// `ServerLevel.explode`'s packet to each player within 64 blocks. The
/// sound and the push reach them on their own (the level's sounds, the
/// players' motion), so the packet carries neither.
/// motion a hurt player is sent), so the packet carries neither.
pub(super) fn lan_explode(&self, center: [f64; 3], radius: f32, block_count: usize) {
let Some(host) = self.lan.as_ref() else {
return;
};
let particle = |name: &str| {
Some(Particle {
id: host.hub.registry_id("minecraft:particle_type", name)?,
data: None,
})
};
let emitter = if radius < 2.0 {
"minecraft:explosion"
} else {
"minecraft:explosion_emitter"
};
let (Some(at_center), Some(poof), Some(smoke)) = (
particle(emitter),
particle("minecraft:poof"),
particle("minecraft:smoke"),
) else {
return;
let sound = "minecraft:entity.generic.explode";
if let Some(packet) =
self.explosion_packet(center, radius, block_count, emitter, sound, true)
{
self.send_explosion(packet, &[]);
}
}
/// A wind charge's burst (`AbstractWindCharge.explode`): gust emitters,
/// no block particles, and for each player it pushed the push
/// (`ServerExplosion.hitPlayers`), which only that client applies, as
/// nothing hurt them.
pub(super) fn lan_wind_burst(
&self,
center: [f64; 3],
radius: f32,
sound: &str,
block_count: usize,
pushed: &[(u32, [f64; 3])],
) {
let emitter = if radius < 2.0 {
"minecraft:gust_emitter_small"
} else {
"minecraft:gust_emitter_large"
};
if let Some(packet) =
self.explosion_packet(center, radius, block_count, emitter, sound, false)
{
self.send_explosion(packet, pushed);
}
}
/// `ClientboundExplodePacket` without a push, or None while not serving.
/// The sound is the level's, which the players hear already, so it isn't
/// played from the packet.
fn explosion_packet(
&self,
center: [f64; 3],
radius: f32,
block_count: usize,
emitter: &str,
sound: &str,
block_particles: bool,
) -> Option<play::Explode> {
let hub = &self.lan.as_ref()?.hub;
let particle = |name: &str| {
Some(Particle {
id: hub.registry_id("minecraft:particle_type", name)?,
data: None,
})
};
// Level.DEFAULT_EXPLOSION_BLOCK_PARTICLES.
let info = |particle, scaling| play::ExplosionParticle {
@@ -207,22 +258,49 @@ impl Game<'_> {
speed: 1.0,
weight: 1,
};
let packet = C::Explode(play::Explode {
let block_particles = if block_particles {
vec![
info(particle("minecraft:poof")?, 0.5),
info(particle("minecraft:smoke")?, 1.0),
]
} else {
Vec::new()
};
Some(play::Explode {
center,
radius,
block_count: block_count as i32,
knockback: None,
particle: at_center,
sound: match host
.hub
.registry_id("minecraft:sound_event", "minecraft:entity.generic.explode")
{
particle: particle(emitter)?,
sound: match hub.registry_id("minecraft:sound_event", sound) {
Some(id) => (id, None),
None => (-1, Some(("minecraft:entity.generic.explode".into(), None))),
None => (-1, Some((sound.into(), None))),
},
block_particles: vec![info(poof, 0.5), info(smoke, 1.0)],
block_particles,
play_sound: false,
});
host.hub.broadcast_near(center, 64.0, &packet, None);
})
}
/// `packet` to each player within 64 blocks of its centre, with the push
/// `pushed` holds for them.
fn send_explosion(&self, mut packet: play::Explode, pushed: &[(u32, [f64; 3])]) {
let Some(host) = self.lan.as_ref() else {
return;
};
let center = packet.center;
let plain = C::Explode(packet.clone()).encode();
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
let d: f64 = (0..3).map(|k| (g.position[k] - center[k]).powi(2)).sum();
if d >= 4096.0 {
continue;
}
match pushed.iter().find(|(conn, _)| *conn == g.conn) {
Some((_, push)) => {
packet.knockback = Some(*push);
g.send(&C::Explode(packet.clone()));
}
None => g.sender.send(plain.clone()),
}
}
}
}
+16 -11
View File
@@ -588,17 +588,7 @@ impl Game<'_> {
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);
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
// The block interaction range (4.5, creative 5) and the buffer.
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
let near = (0..3)
.map(|k| {
let lo = f64::from(gs.pos[k]);
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
})
.sum::<f64>()
< reach * reach;
if !(block && near) {
if !(block && within_reach(g, gs.pos)) {
let window = gs.window;
self.lan_station_closed(host, conn, Some(window));
host.hub.send_to(
@@ -670,3 +660,18 @@ pub(super) fn station_inputs(gs: &GuestStation) -> Vec<ItemStack> {
.map(|(_, _, inputs)| inputs.filter_map(|s| gs.menu.slot(s).cloned()).collect())
.unwrap_or_default()
}
/// `Player.isWithinBlockInteractionRange(pos, 4)`: the guest's eyes within
/// its block interaction range (4.5, creative 5) and a 4-block buffer of the
/// block's box.
pub(super) fn within_reach(g: &horizon_client::lan::guest::Guest, pos: [i32; 3]) -> bool {
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let reach = if g.creative() { 5.0 } else { 4.5 } + 4.0;
(0..3)
.map(|k| {
let lo = f64::from(pos[k]);
(lo - eye[k]).max(eye[k] - lo - 1.0).max(0.0).powi(2)
})
.sum::<f64>()
< reach * reach
}
+22 -16
View File
@@ -280,11 +280,12 @@ impl Game<'_> {
}
/// `thunderHit` for entities within 3 blocks (up to 9 above).
fn thunder_hit(&mut self, b: &mut Bolt) {
let inside = |p: [f64; 3]| {
(p[0] - b.at[0]).abs() <= 3.0
&& (p[2] - b.at[2]).abs() <= 3.0
&& p[1] >= b.at[1] - 3.0
&& p[1] <= b.at[1] + 9.0
let at = b.at;
let inside = move |p: [f64; 3]| {
(p[0] - at[0]).abs() <= 3.0
&& (p[2] - at[2]).abs() <= 3.0
&& p[1] >= at[1] - 3.0
&& p[1] <= at[1] + 9.0
};
let damage = Damage {
kind: DamageKind::Generic,
@@ -349,19 +350,24 @@ impl Game<'_> {
if !events.is_empty() {
let _ = self.handle_mob_events(events);
}
if let Some(p) = self.player.as_ref().map(|p| p.position)
&& inside([p.x, p.y, p.z])
{
if let Some(p) = self.player.as_mut() {
p.fire_ticks += 1;
if p.fire_ticks == 0
&& let Err(error) = p.ignite_for_ticks(160)
{
self.error = Some(error);
// Every player in the box, the server's players too.
let strike = move |g: &mut Self| {
if let Some(p) = g.player.as_ref().map(|p| p.position)
&& inside([p.x, p.y, p.z])
{
if let Some(p) = g.player.as_mut() {
p.fire_ticks += 1;
if p.fire_ticks == 0
&& let Err(error) = p.ignite_for_ticks(160)
{
g.error = Some(error);
}
}
g.hurt_player_by("minecraft:lightning_bolt", 5.0);
}
self.hurt_player_by("minecraft:lightning_bolt", 5.0);
}
};
strike(self);
self.each_guest_hit(strike);
// Dropped items burn up (ItemEntity health 5), save fire-resistant ones.
self.dropped.retain(|e| {
!inside([e.position.x, e.position.y, e.position.z])
+321 -50
View File
@@ -4,7 +4,7 @@
//! shading, the dithered edge), the player marker, `data/minecraft/maps/<id>.dat` and
//! `maps/last_id.dat`, the 128x128 texture in an atlas slot, the two-handed
//! first-person view and maps in item frames.
use super::Game;
use super::{Game, lan::LanHost};
use horizon_client::{
blocks::{Fluid, MAP_COLORS},
hud::{MAP_SLOTS, Rect},
@@ -14,13 +14,48 @@ use horizon_client::{
use horizon_world::nbt::Tag;
use std::collections::HashMap;
/// A decoration: sprite, map position (-128..127, half pixels) and rotation (0..15).
/// A decoration: sprite, map position (-128..127, half pixels) and rotation
/// (0..15), and the player it marks (an item's own decorations mark none).
#[derive(Clone, Debug, PartialEq)]
pub(super) struct Decoration {
pub sprite: &'static str,
pub x: i8,
pub y: i8,
pub rot: u8,
pub holder: Option<u32>,
}
/// The host among a map's holders (the guests are their connections).
const HOST: u32 = u32::MAX;
/// A player carrying a map, or seeing it framed
/// (`MapItemSavedData.HoldingPlayer`): the column sweep's step, and what
/// changed since the last `ClientboundMapItemDataPacket` it was sent.
struct Holder {
who: u32,
/// The last tick it carried the map, and saw it framed.
carried: i64,
framed: i64,
step: i32,
/// The changed pixels' box (min x, min y, max x, max y).
dirty: Option<[i32; 4]>,
decorations: bool,
tick: u32,
}
impl Holder {
fn new(who: u32) -> Self {
Self {
who,
carried: 0,
framed: 0,
step: 0,
dirty: Some([0, 0, 127, 127]),
decorations: true,
tick: 0,
}
}
/// Still carrying the map, or seeing it framed (frames tick every 10).
fn holds(&self, now: i64) -> bool {
self.carried + 1 >= now || self.framed + 10 >= now
}
}
pub(super) struct MapData {
pub center: [i32; 2],
@@ -31,8 +66,7 @@ pub(super) struct MapData {
pub unlimited: bool,
pub colors: Vec<u8>,
pub decorations: Vec<Decoration>,
/// HoldingPlayer.step for the column sweep.
step: i32,
holders: Vec<Holder>,
/// Changed since the last save / texture upload.
dirty: bool,
texture_dirty: bool,
@@ -51,7 +85,7 @@ impl MapData {
unlimited: false,
colors: vec![0; 128 * 128],
decorations: Vec::new(),
step: 0,
holders: Vec::new(),
dirty: true,
texture_dirty: true,
}
@@ -64,8 +98,128 @@ impl MapData {
self.colors[i] = color;
self.dirty = true;
self.texture_dirty = true;
// setColorsDirty: every holder's next packet carries it.
for h in &mut self.holders {
h.dirty = Some(match h.dirty {
Some([x0, y0, x1, y1]) => [x0.min(x), y0.min(y), x1.max(x), y1.max(y)],
None => [x, y, x, y],
});
}
true
}
/// `tickCarriedBy`'s holder for `who`, added when new.
fn holder(&mut self, who: u32) -> &mut Holder {
let i = match self.holders.iter().position(|h| h.who == who) {
Some(i) => i,
None => {
self.holders.push(Holder::new(who));
self.holders.len() - 1
}
};
&mut self.holders[i]
}
/// The holders that no longer carry the map nor see it framed go, and
/// their markers with them (`tickCarriedBy`'s removeDecoration).
fn drop_holders(&mut self, now: i64) {
self.holders.retain(|h| h.holds(now));
let holders = &self.holders;
let before = self.decorations.len();
self.decorations
.retain(|d| d.holder.is_none_or(|w| holders.iter().any(|h| h.who == w)));
if self.decorations.len() != before {
self.decorations_changed();
}
}
/// setDecorationsDirty.
fn decorations_changed(&mut self) {
for h in &mut self.holders {
h.decorations = true;
}
}
/// `addDecoration` / `removeDecoration` for `holder`'s marker: in place,
/// so that the order (the drawing order) stays.
fn set_marker(&mut self, holder: u32, marker: Option<Decoration>) {
let i = self
.decorations
.iter()
.position(|d| d.holder == Some(holder));
let changed = match (i, marker) {
(Some(i), Some(d)) if self.decorations[i] == d => false,
(Some(i), Some(d)) => {
self.decorations[i] = d;
true
}
(Some(i), None) => {
self.decorations.remove(i);
true
}
(None, Some(d)) => {
self.decorations.push(d);
true
}
(None, None) => false,
};
if changed {
self.decorations_changed();
}
}
/// `HoldingPlayer.nextUpdatePacket` for `who`: the changed pixels, and
/// the decorations (at most every 5th call while they changed).
fn update_packet(
&mut self,
id: i32,
who: u32,
kind: impl Fn(&str) -> Option<i32>,
) -> Option<horizon_protocol::packets::play::MapItemData> {
use horizon_protocol::packets::play::{MapDecoration, MapItemData, MapPatch};
let h = self.holders.iter_mut().find(|h| h.who == who)?;
let patch = h.dirty.take().map(|[x0, y0, x1, y1]| {
let (width, height) = (x1 + 1 - x0, y1 + 1 - y0);
let mut colors = Vec::with_capacity((width * height) as usize);
for y in 0..height {
for x in 0..width {
colors.push(self.colors[(x0 + x + (y0 + y) * 128) as usize]);
}
}
MapPatch {
start_x: x0,
start_y: y0,
width,
height,
colors,
}
});
let decorations = if h.decorations && {
let due = h.tick % 5 == 0;
h.tick += 1;
due
} {
h.decorations = false;
Some(
self.decorations
.iter()
.filter_map(|d| {
Some(MapDecoration {
kind: kind(decoration_type(d.sprite)?)?,
x: d.x,
y: d.y,
rot: d.rot as i8,
name: None,
})
})
.collect(),
)
} else {
None
};
(patch.is_some() || decorations.is_some()).then_some(MapItemData {
map_id: id,
scale: self.scale as i8,
locked: self.locked,
decorations,
patch,
})
}
fn to_nbt(&self) -> Tag {
let flag = |b: bool| Tag::Byte(i8::from(b));
Tag::compound([
@@ -112,7 +266,7 @@ impl MapData {
unlimited: int("unlimitedTracking").unwrap_or(0) != 0,
colors,
decorations: Vec::new(),
step: 0,
holders: Vec::new(),
dirty: false,
texture_dirty: true,
})
@@ -143,36 +297,48 @@ fn packed_rgba(packed: u8) -> [u8; 4] {
pub(super) fn map_id(stack: &ItemStack) -> Option<i32> {
stack.data().and_then(|d| d.map_id)
}
/// `MapDecorationTypes`: the sprite of a decoration type that a
/// `minecraft:map_decorations` entry can name.
/// `MapDecorationTypes` Horizon draws: (type, sprite).
const DECORATION_TYPES: [(&str, &str); 24] = [
("player", "map/decorations/player"),
("frame", "map/decorations/frame"),
("red_marker", "map/decorations/red_marker"),
("blue_marker", "map/decorations/blue_marker"),
("target_x", "map/decorations/target_x"),
("target_point", "map/decorations/target_point"),
("player_off_map", "map/decorations/player_off_map"),
("player_off_limits", "map/decorations/player_off_limits"),
("mansion", "map/decorations/woodland_mansion"),
("monument", "map/decorations/ocean_monument"),
("red_x", "map/decorations/red_x"),
("village_desert", "map/decorations/desert_village"),
("village_plains", "map/decorations/plains_village"),
("village_savanna", "map/decorations/savanna_village"),
("village_snowy", "map/decorations/snowy_village"),
("village_taiga", "map/decorations/taiga_village"),
("jungle_temple", "map/decorations/jungle_temple"),
("swamp_hut", "map/decorations/swamp_hut"),
("trial_chambers", "map/decorations/trial_chambers"),
("abandoned_camp", "map/decorations/abandoned_camp"),
("ancient_city", "map/decorations/ancient_city"),
("desert_pyramid", "map/decorations/desert_pyramid"),
("mineshaft", "map/decorations/mineshaft"),
("ocean_ruin_warm", "map/decorations/warm_ocean_ruins"),
];
/// The sprite of a decoration type that a `minecraft:map_decorations`
/// entry or a server's packet names.
fn decoration_sprite(id: &str) -> Option<&'static str> {
Some(match id.strip_prefix("minecraft:").unwrap_or(id) {
"player" => "map/decorations/player",
"frame" => "map/decorations/frame",
"red_marker" => "map/decorations/red_marker",
"blue_marker" => "map/decorations/blue_marker",
"target_x" => "map/decorations/target_x",
"target_point" => "map/decorations/target_point",
"player_off_map" => "map/decorations/player_off_map",
"player_off_limits" => "map/decorations/player_off_limits",
"mansion" => "map/decorations/woodland_mansion",
"monument" => "map/decorations/ocean_monument",
"red_x" => "map/decorations/red_x",
"village_desert" => "map/decorations/desert_village",
"village_plains" => "map/decorations/plains_village",
"village_savanna" => "map/decorations/savanna_village",
"village_snowy" => "map/decorations/snowy_village",
"village_taiga" => "map/decorations/taiga_village",
"jungle_temple" => "map/decorations/jungle_temple",
"swamp_hut" => "map/decorations/swamp_hut",
"trial_chambers" => "map/decorations/trial_chambers",
"abandoned_camp" => "map/decorations/abandoned_camp",
"ancient_city" => "map/decorations/ancient_city",
"desert_pyramid" => "map/decorations/desert_pyramid",
"mineshaft" => "map/decorations/mineshaft",
"ocean_ruin_warm" => "map/decorations/warm_ocean_ruins",
_ => return None,
})
let name = id.strip_prefix("minecraft:").unwrap_or(id);
DECORATION_TYPES
.iter()
.find(|(n, _)| *n == name)
.map(|(_, sprite)| *sprite)
}
/// The type a decoration's sprite stands for, as its registry names it.
fn decoration_type(sprite: &str) -> Option<&'static str> {
DECORATION_TYPES
.iter()
.find(|(_, s)| *s == sprite)
.map(|(name, _)| *name)
}
/// `minecraft:map_decorations` of a map item: (sprite, x, z, rotation), in
/// the component's order.
@@ -275,12 +441,29 @@ impl Game<'_> {
}
self.maps.data.get_mut(&id)
}
/// `ClientboundMapItemDataPacket.handle`: the server's colours for a map
/// this client does not own (lobby map art, a framed map on a wall).
/// `ClientboundMapItemDataPacket.handle`: the server's colours and
/// decorations for a map (one carried or framed on a server).
pub(super) fn remote_map_item_data(
&mut self,
p: &horizon_protocol::packets::play::MapItemData,
) {
let decorations = p.decorations.as_ref().map(|list| {
let types = self
.remote
.as_ref()
.and_then(|r| r.builtin.get("minecraft:map_decoration_type"));
list.iter()
.filter_map(|d| {
Some(Decoration {
sprite: decoration_sprite(types?.name(d.kind)?)?,
x: d.x,
y: d.y,
rot: (d.rot & 15) as u8,
holder: None,
})
})
.collect()
});
let dimension = self.dimension_id();
let map = self
.maps
@@ -288,6 +471,9 @@ impl Game<'_> {
.entry(p.map_id)
.or_insert_with(|| MapData::fresh(0.0, 0.0, p.scale.max(0) as u8, dimension));
map.locked = p.locked;
if let Some(decorations) = decorations {
map.decorations = decorations;
}
let Some(patch) = &p.patch else {
map.dirty = false;
return;
@@ -566,10 +752,14 @@ impl Game<'_> {
.collect();
let (yaw, dim) = (self.yaw, self.dimension_id());
let (nether, game_time) = (self.dim == super::Dim::Nether, self.world.game_time);
// The guest whose turn it is, else the host.
let me = self.lan_acting.unwrap_or(HOST);
for (id, targets) in carried {
let Some(m) = self.map(id) else { continue };
m.holder(me).carried = game_time;
m.drop_holders(game_time);
// tickCarriedBy: the player marker while tracking in this dimension.
m.decorations.clear();
let mut marker = None;
if m.tracking && m.dimension == dim {
let s = f64::from(1 << m.scale);
let dx = ((p.x - f64::from(m.center[0])) / s) as f32;
@@ -596,18 +786,19 @@ impl Game<'_> {
} else {
None
};
if let Some(sprite) = sprite {
m.decorations.push(Decoration {
sprite,
x: clamp(dx),
y: clamp(dz),
rot,
});
}
marker = sprite.map(|sprite| Decoration {
sprite,
x: clamp(dx),
y: clamp(dz),
rot,
holder: Some(me),
});
}
m.set_marker(me, marker);
// The item's MAP_DECORATIONS (an explorer map's target), added
// after the players as addDecoration would.
let s = (1 << m.scale) as f32;
let mut items: Vec<Decoration> = Vec::new();
for (sprite, x, z, rotation) in targets {
let dx = (x - f64::from(m.center[0])) as f32 / s;
let dz = (z - f64::from(m.center[1])) as f32 / s;
@@ -620,16 +811,95 @@ impl Game<'_> {
x: clamp_map_coordinate(dx),
y: clamp_map_coordinate(dz),
rot: decoration_rotation(nether, game_time, f64::from(rotation)),
holder: None,
};
if !m.decorations.contains(&d) {
m.decorations.push(d);
if !items.contains(&d) {
items.push(d);
}
}
if !m
.decorations
.iter()
.filter(|d| d.holder.is_none())
.eq(items.iter())
{
m.decorations.retain(|d| d.holder.is_some());
m.decorations.extend(items);
m.decorations_changed();
}
}
for id in held {
self.update_map(id);
}
}
/// The maps the player carries, for `status`: how many of their pixels
/// are drawn, and their decorations.
pub(super) fn maps_status(&self) -> Vec<serde_json::Value> {
self.inventory
.iter()
.chain(std::iter::once(&self.offhand))
.flatten()
.filter_map(map_id)
.filter_map(|id| {
let m = self.maps.data.get(&id)?;
Some(serde_json::json!({
"id": id,
"pixels": m.colors.iter().filter(|&&c| c != 0).count(),
"decorations": m.decorations.iter().map(|d| d.sprite).collect::<Vec<_>>(),
}))
})
.collect()
}
/// `ServerPlayer.synchronizeSpecialItemUpdates` for each map the guest
/// carries, and every 10 ticks (`ServerEntity.sendChanges`) for each map
/// framed in the level: a `ClientboundMapItemDataPacket` when something
/// changed since the guest's last one.
pub(super) fn lan_map_updates(&mut self, host: &LanHost, conn: u32) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let mut ids: Vec<i32> = g
.inventory
.iter()
.chain(std::iter::once(&g.offhand))
.flatten()
.filter_map(map_id)
.collect();
let now = self.world.game_time;
if now % 10 == 0 && host.hub.here(g) {
let framed: Vec<i32> = self
.item_frames
.iter()
.filter_map(|f| f.item.as_ref().and_then(map_id))
.collect();
for id in framed {
if let Some(m) = self.map(id) {
m.holder(conn).framed = now;
ids.push(id);
}
}
}
ids.sort_unstable();
ids.dedup();
let kind = |name: &str| {
host.hub.registry_id(
"minecraft:map_decoration_type",
&format!("minecraft:{name}"),
)
};
for id in ids {
if let Some(p) = self
.maps
.data
.get_mut(&id)
.and_then(|m| m.update_packet(id, conn, kind))
{
g.send(&horizon_protocol::packets::play::Clientbound::MapItemData(
p,
));
}
}
}
/// `MapItem.update` for the player holding map `id`.
fn update_map(&mut self, id: i32) {
let dim = self.dimension_id();
@@ -651,8 +921,9 @@ impl Game<'_> {
if nether {
radius /= 2;
}
m.step += 1;
let step = m.step;
let h = m.holder(self.lan_acting.unwrap_or(HOST));
h.step += 1;
let step = h.step;
let mut changes: Vec<(i32, i32, u8)> = Vec::new();
let mut consecutive = false;
for img_x in px - radius + 1..px + radius {
+88 -21
View File
@@ -641,6 +641,22 @@ impl Game<'_> {
("mob_factor", Tag::Float(v[5].to_bits())),
]),
Component::Profile(p) => profile_tag(p),
// ItemStackTemplate.CODEC: id, count and components.
Component::Stacks(stacks) => Tag::List(
stacks
.iter()
.map(|t| {
let mut entries = vec![
("id", Tag::String(self.item_name(t.item).as_ref().into())),
("count", Tag::Int(t.count)),
];
if let Some(c) = self.protocol_components(t) {
entries.push(("components", c));
}
Tag::compound(entries)
})
.collect(),
),
Component::CustomModelData {
floats,
flags,
@@ -2557,6 +2573,8 @@ impl Game<'_> {
self.drop_remote_chunks_out_of_range();
}
C::BlockEntityData(p) => self.remote_block_entity_data(p.pos, p.kind, &p.tag),
C::OpenSignEditor(p) => self.remote_open_sign_editor(p),
C::OpenBook(p) => self.remote_open_book(p.hand),
// handleSetSpawn: ClientLevel.setRespawnData, which spawn compasses point at.
C::SetDefaultSpawnPosition(p) => {
let [x, y, z] = p.pos.pos;
@@ -3121,14 +3139,11 @@ impl Game<'_> {
[player.position.x, player.position.y, player.position.z],
);
let was_flying = player.flying;
player.yaw = self.yaw;
// As the window's own tick has them: the helmet's Respiration
// (decreaseAirSupply) and the dimension's FAST_LAVA, the Nether's
// stronger lava current.
player.respiration = self.armor[0]
.as_ref()
.map_or(0, |s| s.enchantment_level("minecraft:respiration") as i32);
player.fluids.fast_lava = self.dim == Dim::Nether;
self.sync_player_body();
let Some(player) = self.player.as_mut() else {
return Ok(());
};
let was_gliding = player.fall_flying;
let dead = self.screen == Screen::Dead;
// LivingEntity.isImmobile: a sleeper lies still in the bed.
if let Some(head) = self.sleeping {
@@ -3153,6 +3168,9 @@ impl Game<'_> {
// LocalPlayer.onUpdateAbilities: flight turned on or off (a double
// jump, a spectator, landing) is sent to the server.
let flying = player.flying;
// LocalPlayer.aiStep: a glide this client started is sent to the
// server (ServerboundPlayerCommandPacket START_FALL_FLYING).
let started_gliding = player.fall_flying && !was_gliding;
let events = std::mem::take(&mut player.events);
self.previous_eye = self.eye;
self.eye += (player.dimensions.eye_height - self.eye) * 0.5;
@@ -3192,6 +3210,14 @@ impl Game<'_> {
flags: if flying { 2 } else { 0 },
}));
}
if started_gliding {
let id = self.remote.as_ref().map_or(0, |r| r.entity_id);
self.send(S::PlayerCommand(play::PlayerCommand {
id,
action: 6,
data: 0,
}));
}
self.remote_send_state(input);
Ok(())
}
@@ -4010,21 +4036,60 @@ impl Game<'_> {
return false;
};
// MultiPlayerGameMode.useItem runs the use here too: food starts
// being eaten (Consumable.startConsuming).
// being eaten (Consumable.startConsuming), a book and quill opens.
self.with_hand(hand, |g| g.start_eating(&info));
// BowItem.use: startUsingItem here too (with an arrow, or in creative), so that letting
// go sends RELEASE_USE_ITEM (Minecraft.handleKeybinds) and the server shoots.
if &*held.item == "minecraft:bow" {
self.with_hand(hand, |g| {
if g.using.is_none() && (g.creative || g.projectile_slot("minecraft:bow").is_some())
{
g.using = Some((g.held_slot(), 0));
}
});
if self.with_hand(hand, |g| g.remote_use_book(&held)) {
return true;
}
self.with_hand(hand, |g| g.remote_start_held_use(&held));
item_has_use(&info)
}
/// `Item.use` runs on the client too (MultiPlayerGameMode.useItem): an
/// item held in use until the button lets go starts being used here, so
/// that letting go sends RELEASE_USE_ITEM (Minecraft.handleKeybinds) and
/// the server ends the use: a bow or a trident is let go, a crossbow's
/// charge ends, a shield is lowered, a spyglass stops scoping. Their
/// sounds, loaded arrows and thrown tridents come from the server.
fn remote_start_held_use(&mut self, held: &ItemStack) {
if self.using.is_some() {
return;
}
let start = match &*held.item {
// BowItem.use: with an arrow, or in creative.
"minecraft:bow" => self.creative || self.projectile_slot("minecraft:bow").is_some(),
// CrossbowItem.use: charging when not charged and with ammunition;
// a charged one shoots on the server.
"minecraft:crossbow" => {
held.data().is_none_or(|d| d.charged.is_empty())
&& (self.creative || self.projectile_slot("minecraft:crossbow").is_some())
}
// TridentItem.use: not about to break, Riptide only in water or rain.
"minecraft:trident" => {
self.use_trident(held);
return;
}
// SpyglassItem.use (its sound is the server's).
"minecraft:spyglass" => true,
// ShieldItem.use: anything that blocks attacks, off cooldown.
_ => {
self.raise_shield(self.held_slot());
return;
}
};
if start {
self.using = Some((self.held_slot(), 0));
}
}
/// Whether `stack` stays in use until the button lets go (bows,
/// crossbows, tridents, spyglasses, shields): its use only counts ticks here.
fn held_until_released(&self, stack: &ItemStack) -> bool {
matches!(
&*stack.item,
"minecraft:bow" | "minecraft:crossbow" | "minecraft:trident" | "minecraft:spyglass"
) || self.blocks_attacks(stack).is_some()
}
/// The rest of Minecraft.startUseItem once the main hand's target passed:
/// the main hand's item, then the off hand's turn (its block, its item).
/// The server does each packet's part with that packet's hand only.
@@ -4043,7 +4108,8 @@ impl Game<'_> {
}
self.remote_use_item(Hand::Off);
}
/// `LivingEntity.updatingUsingItem` on a server client: a drawn bow, and a
/// `LivingEntity.updatingUsingItem` on a server client: an item held in
/// use (`held_until_released`), and a
/// consumable's eating animation and its crumbs. The server eats it and
/// sends the stack, the hunger and the sounds.
fn remote_tick_using(&mut self) {
@@ -4051,8 +4117,9 @@ impl Game<'_> {
return;
};
let stack = self.stack_in(slot).cloned();
// A drawn bow: held until the button lets go (or another slot is picked).
if stack.as_ref().is_some_and(|s| &*s.item == "minecraft:bow") {
// A drawn bow, a charging crossbow, a raised shield...: held until
// the button lets go (or another slot is picked).
if stack.as_ref().is_some_and(|s| self.held_until_released(s)) {
self.using = (slot == self.selected || slot == super::shield::OFFHAND_SLOT)
.then_some((slot, ticks + 1));
return;
@@ -88,6 +88,8 @@ impl Game<'_> {
self.end
.add_remote_eye(id, position, [velocity.x, velocity.y, velocity.z])
}
// FishingHook.recreateFromPacket: the owner's id is the data.
"minecraft:fishing_bobber" => self.spawn_remote_hook(id, at, data),
"minecraft:end_crystal" => self.crystals.push(super::dragon::Crystal {
id,
position: at,
@@ -114,6 +116,7 @@ impl Game<'_> {
/// `handleSetEntityData` for these types: what changes how they look.
pub(super) fn remote_projectile_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
let id = id as u32;
self.remote_hook_data(id, values);
if self.potions.has_thrown(id) {
// ThrowableItemProjectile.DATA_ITEM_STACK: the potion it is.
for (index, _, value) in values {
@@ -205,6 +208,7 @@ impl Game<'_> {
c.position = step(c.id, c.position);
}
self.potions.step_remote_thrown(&mut step);
self.step_remote_hooks(&mut step);
self.end.step_remote_eyes(|id, at| {
let at = step(id, Vec3::new(at[0], at[1], at[2]));
[at.x, at.y, at.z]
@@ -220,6 +224,7 @@ impl Game<'_> {
self.crystals.retain(|c| c.id != id);
self.potions.remove_remote_thrown(id);
self.end.remove_remote_eye(id);
self.remove_remote_hook(id);
}
/// Every one a server sent (a new connection, level or dimension).
@@ -232,6 +237,7 @@ impl Game<'_> {
self.crystals.clear();
self.potions.clear_thrown();
self.end.clear_remote_eyes();
self.clear_remote_hooks();
}
/// Their kind and place, for `status`.
@@ -260,6 +266,7 @@ impl Game<'_> {
for (id, p) in self.end.eye_positions() {
out.push(json!({"id": id, "kind": "minecraft:eye_of_ender", "position": p}));
}
out.extend(self.hooks_status());
out
}
}
@@ -69,7 +69,7 @@ impl Game<'_> {
}
};
if let Some(r) = self.renderer.as_mut()
&& let Err(e) = r.set_shader_pack(pack.as_ref())
&& let Err(e) = r.set_shader_pack(pack.as_ref(), &self.pack.render.sprite_layers)
{
return self.shader_pack_error(&id, &e.to_string());
}
@@ -79,7 +79,10 @@ impl Game<'_> {
/// Give the loaded pack to the renderer (also after it is rebuilt).
pub(super) fn apply_shader_pack(&mut self) -> horizon_client::Result<()> {
match self.renderer.as_mut() {
Some(r) => r.set_shader_pack(self.shader_packs.pack.as_ref()),
Some(r) => r.set_shader_pack(
self.shader_packs.pack.as_ref(),
&self.pack.render.sprite_layers,
),
None => Ok(()),
}
}
+86 -4
View File
@@ -1,12 +1,13 @@
//! The sign editing screen (`SignEditScreen` / `HangingSignEditScreen`):
//! opened when a sign is placed, four lines limited to the sign's line
//! width, Up/Down/Enter move between lines, Escape or Done closes.
use super::Game;
use super::{Game, lan::LanHost};
use horizon_client::{
containers::BlockEntity,
hud::{HudBatch, rgba},
signs::wood_type,
};
use horizon_protocol::packets::play::{self, Clientbound as C, Serverbound as S};
use std::time::Instant;
use winit::{
event::{ElementState, KeyEvent},
@@ -33,14 +34,95 @@ impl Game<'_> {
self.right = false;
self.capture(false);
}
/// `AbstractSignEditScreen.removed`: a server's client sends what it
/// wrote (ServerboundSignUpdatePacket); the window's own world keeps it
/// and sends it to its guests (`sendBlockUpdated`).
pub(super) fn close_sign_editor(&mut self) {
if let Some(edit) = self.sign_edit.take() {
self.world
.modified
.insert((edit.pos.0 >> 4, edit.pos.2 >> 4));
if self.remote.is_some() {
if let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get(&edit.pos) {
let side = if edit.front { &sign.front } else { &sign.back };
self.send(S::SignUpdate(play::SignUpdate {
pos: [edit.pos.0, edit.pos.1, edit.pos.2],
lines: side.lines.clone(),
front: edit.front,
}));
}
} else {
self.mark_block_entity_updated(edit.pos);
}
}
self.capture(true);
}
/// `ServerPlayer.openTextEdit` for the guest whose action opened a sign:
/// the block, then the editor, on its client, and the guest may write on it.
pub(super) fn lan_open_sign_editor(&mut self, host: &mut LanHost, conn: u32, edit: SignEditor) {
let pos = [edit.pos.0, edit.pos.1, edit.pos.2];
let state = self.world.block(pos[0], pos[1], pos[2]).0 as i32;
host.hub
.send_to(conn, &C::BlockUpdate(play::BlockUpdate { pos, state }));
host.hub.send_to(
conn,
&C::OpenSignEditor(play::OpenSignEditor {
pos,
front: edit.front,
}),
);
host.play.entry(conn).or_default().sign_editing = Some(pos);
}
/// `ServerGamePacketListenerImpl.handleSignUpdate`: the lines, formatting
/// codes stripped, go on the sign the guest may write on unless it is
/// waxed (`SignBlockEntity.updateSignText`); a guest gone out of reach
/// may no longer write (`playerIsTooFarAwayToEdit`).
pub(super) fn lan_sign_update(&mut self, host: &mut LanHost, conn: u32, p: play::SignUpdate) {
let Some(g) = host.hub.guest(conn) else {
return;
};
let near = super::lan_stations::within_reach(g, p.pos);
let Some(play) = host.play.get_mut(&conn) else {
return;
};
if !near {
play.sign_editing = None;
}
if play.sign_editing != Some(p.pos) {
log::warn!("{} just tried to change non-editable sign", g.name);
return;
}
let key = (p.pos[0], p.pos[1], p.pos[2]);
let Some(BlockEntity::Sign(sign)) = self.world.block_entities.get_mut(&key) else {
return;
};
if sign.waxed {
return;
}
let side = if p.front {
&mut sign.front
} else {
&mut sign.back
};
for (line, new) in side.lines.iter_mut().zip(&p.lines) {
*line = horizon_client::clipboard::strip_formatting(new);
}
play.sign_editing = None;
self.mark_block_entity_updated(key);
}
/// `ClientPacketListener.handleOpenSignEditor`: the editor on a sign this
/// client has.
pub(super) fn remote_open_sign_editor(&mut self, p: play::OpenSignEditor) {
let key = (p.pos[0], p.pos[1], p.pos[2]);
if matches!(
self.world.block_entities.get(&key),
Some(BlockEntity::Sign(_))
) {
self.open_sign_editor(key, p.front);
} else {
log::warn!(
"ignoring openTextEdit on a block that is no sign at {:?}",
p.pos
);
}
}
pub(super) fn sign_edit_key(&mut self, event: &KeyEvent) {
if event.state != ElementState::Pressed {
return;
@@ -10,7 +10,7 @@ pub(super) const HELP: &[&str] = &[
"use X Y Z open the block's menu as if right-clicked",
"click-slot ID [right] [shift] click a menu slot (0-35 inventory, 300+ container, 600+ station, 650 result)",
"click-gui X Y [right] [shift] click at GUI pixel X Y relative to the menu's top-left corner",
"book [next|back|take|done] the open book or lectern: its page, or press one of its buttons",
"book [next|back|take|sign|done] the open book or lectern: its page, or press one of its buttons",
"type TEXT | type-back type into the anvil name box / delete one character",
"enchant ID LEVEL enchant the held item (books store the enchantment)",
"potion ITEM POTION give a potion item (potion, splash_potion, lingering_potion, tipped_arrow)",
+117 -36
View File
@@ -22,6 +22,14 @@ const PLAYER_KNOCKBACK: f32 = 1.22;
/// `BreezeWindCharge.RADIUS`.
const BREEZE_RADIUS: f32 = 3.0;
/// A burst `wind_burst` puts off until a guest's turn is over.
pub(super) struct Burst {
center: [f64; 3],
radius: f32,
multiplier: f32,
breeze: bool,
wind_charge: bool,
}
/// `Projectile.deflect` with `ProjectileDeflection.REVERSE`.
pub(super) fn reverse(p: &mut Projectile, jitter: f32) {
p.velocity = Vec3::new(
@@ -196,7 +204,9 @@ impl Game<'_> {
set.into_iter().collect()
}
/// `ServerExplosion.hurtEntities` for a calculator that damages nothing:
/// knockback scaled by distance, exposure and the multiplier.
/// knockback scaled by distance, exposure and the multiplier. Returns the
/// guests it pushed and by how much (`hitPlayers`), which their clients
/// apply from the burst's packet.
fn wind_knockback(
&mut self,
center: [f64; 3],
@@ -204,7 +214,7 @@ impl Game<'_> {
multiplier: f32,
wind_charge: bool,
blocklike: bool,
) {
) -> Vec<(u32, [f64; 3])> {
let diameter = f64::from(radius * 2.0);
let distance = |p: [f64; 3]| {
((p[0] - center[0]).powi(2) + (p[1] - center[1]).powi(2) + (p[2] - center[2]).powi(2))
@@ -223,42 +233,30 @@ impl Game<'_> {
[d[0] / l, d[1] / l, d[2] / l]
}
};
let multiplier = f64::from(multiplier);
// The player (not a spectator; a flying creative player takes none).
if let Some(p) = self.player.as_ref()
&& !self.spectator()
{
let feet = [p.position.x, p.position.y, p.position.z];
let dist = distance(feet) / diameter;
let flying = self.creative && p.flying;
if dist <= 1.0 && !flying {
let w = f64::from(p.dimensions.width) / 2.0;
let h = f64::from(p.dimensions.height);
let eye = [
feet[0],
feet[1] + f64::from(p.dimensions.eye_height),
feet[2],
];
let exposure = self.seen_percent(
center,
[feet[0] - w, feet[1], feet[2] - w],
[feet[0] + w, feet[1] + h, feet[2] + w],
);
let power = (1.0 - dist) * f64::from(exposure) * multiplier;
let dir = direction(eye);
if let Some(p) = self.player.as_mut() {
p.velocity = Vec3::new(
p.velocity.x + dir[0] * power,
p.velocity.y + dir[1] * power,
p.velocity.z + dir[2] * power,
);
// ServerPlayer.onExplosionHit: a wind charge's burst cancels the
// fall; any other burst ends the grace of the last impulse.
let at = p.position;
p.impulse.set_ignore_fall_damage(wind_charge, Some(at));
// The player, then every other player in the level (LAN guests),
// each swapped in as the player. In a guest's turn (a mace's wind
// burst) the player is that guest.
let own = self.wind_push_player(center, radius, multiplier, wind_charge);
let mut pushed: Vec<_> = self.lan_acting.zip(own).into_iter().collect();
if let Some(mut host) = self.lan.take() {
let guests: Vec<u32> = host
.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
.map(|g| g.conn)
.collect();
for conn in guests {
let push = self.as_guest(&mut host, conn, |g| {
g.wind_push_player(center, radius, multiplier, wind_charge)
});
if let Some(Some(push)) = push {
pushed.push((conn, push));
}
}
self.lan = Some(host);
}
let multiplier = f64::from(multiplier);
let mut mobs = std::mem::take(&mut self.mobs);
for m in mobs.iter_mut().filter(|m| m.alive()) {
let feet = [m.position.x, m.position.y, m.position.z];
@@ -351,6 +349,67 @@ impl Game<'_> {
push(t.position, &mut t.velocity, self);
}
self.tnts = tnts;
pushed
}
/// `ServerExplosion.hurtEntities` for the player (not a spectator, nor a
/// creative player flying): the push, less with explosion knockback
/// resistance, and `ServerPlayer.onExplosionHit`. Returns the push.
fn wind_push_player(
&mut self,
center: [f64; 3],
radius: f32,
multiplier: f32,
wind_charge: bool,
) -> Option<[f64; 3]> {
if self.spectator() {
return None;
}
let resistance = self
.player
.as_mut()
.and_then(|p| p.attribute("minecraft:explosion_knockback_resistance").ok())
.unwrap_or(0.0);
let creative = self.creative;
let p = self.player.as_ref()?;
let feet = [p.position.x, p.position.y, p.position.z];
let dist = ((feet[0] - center[0]).powi(2)
+ (feet[1] - center[1]).powi(2)
+ (feet[2] - center[2]).powi(2))
.sqrt()
/ f64::from(radius * 2.0);
if dist > 1.0 || creative && p.flying {
return None;
}
let w = f64::from(p.dimensions.width) / 2.0;
let h = f64::from(p.dimensions.height);
let eye = [
feet[0] - center[0],
feet[1] + f64::from(p.dimensions.eye_height) - center[1],
feet[2] - center[2],
];
let exposure = self.seen_percent(
center,
[feet[0] - w, feet[1], feet[2] - w],
[feet[0] + w, feet[1] + h, feet[2] + w],
);
let power = (1.0 - dist) * f64::from(exposure) * f64::from(multiplier) * (1.0 - resistance);
let l = (eye[0] * eye[0] + eye[1] * eye[1] + eye[2] * eye[2]).sqrt();
let push = if l < 1.0e-5 {
[0.0; 3]
} else {
eye.map(|d| d / l * power)
};
let p = self.player.as_mut()?;
p.velocity = Vec3::new(
p.velocity.x + push[0],
p.velocity.y + push[1],
p.velocity.z + push[2],
);
// ServerPlayer.onExplosionHit: a wind charge's burst cancels the
// fall; any other burst ends the grace of the last impulse.
let at = p.position;
p.impulse.set_ignore_fall_damage(wind_charge, Some(at));
Some(push)
}
/// The burst: knockback, the blocks that react, the gust and the sound.
pub(super) fn wind_burst(
@@ -361,13 +420,27 @@ impl Game<'_> {
breeze: bool,
wind_charge: bool,
) -> Result<()> {
// In a guest's turn (their charge landing, their mace's burst) the
// host holds the other players (`as_guest`): the burst waits for the
// turn to end so that it reaches every player, the guest too.
if self.lan_acting.is_some() && self.lan.is_none() {
self.creatures.wind_bursts.push(Burst {
center,
radius,
multiplier,
breeze,
wind_charge,
});
return Ok(());
}
// ServerExplosion.explode: the blast is heard first.
let ctx = horizon_client::sculk::Context::default();
self.post_game_event("minecraft:explode", center, ctx);
let mut targets = self.wind_positions(center, radius);
// ServerExplosion.shouldAffectBlocklikeEntities
let blocklike = !breeze && !wind_charge && self.ui.rules.bool("mob_griefing");
self.wind_knockback(center, radius, multiplier, wind_charge, blocklike);
let pushed = self.wind_knockback(center, radius, multiplier, wind_charge, blocklike);
let block_count = targets.len();
// Explosion.canTriggerBlocks: a breeze's charge needs mob griefing.
if !breeze || self.ui.rules.bool("mob_griefing") {
// Util.shuffle(list, level.random)
@@ -396,6 +469,14 @@ impl Game<'_> {
};
self.particles
.add(&ParticleOptions::simple(ty), center, [1.0, 0.0, 0.0]);
self.lan_wind_burst(center, radius, sound, block_count, &pushed);
Ok(())
}
/// The bursts guests' turns put off.
pub(super) fn flush_wind_bursts(&mut self) -> Result<()> {
for b in std::mem::take(&mut self.creatures.wind_bursts) {
self.wind_burst(b.center, b.radius, b.multiplier, b.breeze, b.wind_charge)?;
}
Ok(())
}
/// `BlockBehaviour.onExplosionHit` for `TRIGGER_BLOCK`: doors, gates,
+490 -25
View File
@@ -35,7 +35,7 @@ fn entity_shader() -> String {
mod entity_batch;
mod weather;
use entity_batch::EntityBatch;
pub use entity_batch::WATER_MASK_OVERLAY;
pub use entity_batch::{SHADOW_OVERLAY, WATER_MASK_OVERLAY};
/// Window.getAppropriateLineWidth, in framebuffer pixels.
pub fn outline_width(width: u32) -> f32 {
@@ -65,7 +65,8 @@ mod arena;
mod cull;
mod end_sky;
mod flat;
mod java_stages;
pub(crate) mod java_stages;
pub(crate) mod java_terrain;
pub mod lightning;
mod lod;
mod occlusion;
@@ -158,6 +159,18 @@ struct Globals {
dynamic_lights: [[f32; 4]; crate::dynamic_light::MAX],
}
pub type SectionKey = (i32, i32, i32);
/// The sections a Java pack's shadow map draws ([`Renderer`]'s `shadow`).
#[derive(Default)]
struct ShadowList {
visible: Vec<resident::Visible>,
draws: Vec<[u32; 5]>,
pages: Vec<(u32, Option<u32>)>,
/// How many of `draws` are solid.
solid: u32,
indirect: Option<wgpu::Buffer>,
capacity: u64,
}
struct SectionGpu {
/// Arena page and first vertex in it: solid quads, then translucent ones.
page: u32,
@@ -375,6 +388,7 @@ pub struct Renderer {
entity_pipeline: wgpu::RenderPipeline,
weather: weather::Weather,
crumbling_pipeline: wgpu::RenderPipeline,
shadow_blob_pipeline: wgpu::RenderPipeline,
/// `RenderPipelines.WATER_MASK`: depth writes only, no colour.
water_mask_pipeline: wgpu::RenderPipeline,
cloud_pipeline: wgpu::RenderPipeline,
@@ -478,6 +492,10 @@ pub struct Renderer {
/// built, and their pages.
phase2_draws: Vec<[u32; 5]>,
phase2_pages: Vec<(u32, Option<u32>)>,
/// A Java pack's shadow map (`gfx/java_terrain.rs`): the sections in
/// the light's view, their draws (solid then translucent, instances
/// after the camera's), and the indirect buffer they're uploaded to.
shadow: ShadowList,
/// Each section's solid draws, to group them by arena page.
solid_runs: page_runs::PageRuns,
phase2_runs: page_runs::PageRuns,
@@ -491,6 +509,15 @@ pub struct Renderer {
world_post: world_post::Post,
/// The selected shader pack's stages (Horizon setting; none by default).
pack: Option<Pack>,
/// A Java pack's terrain programs (`gfx/java_terrain.rs`): their page
/// groups' layout, the glue's table, each arena page's group.
java_page_layout: wgpu::BindGroupLayout,
/// The device renders to and filters 16-bit normalized formats.
java_norm16: bool,
java_table: Option<wgpu::Buffer>,
java_pages: Vec<Option<(wgpu::Buffer, wgpu::BindGroup)>>,
/// The sprite layers and mip levels `animate` wrote this frame.
animated: Vec<(u32, u32)>,
pub hud: HudRenderer,
/// Vanilla's menu background blur, made on first use.
menu_blur: Option<crate::menu_blur::MenuBlur>,
@@ -839,7 +866,10 @@ async fn open_device(
| cull::Cull::features(&adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS));
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
// Java shader packs' 16-bit colour targets.
| wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES));
let driver = format!("{} {}", info.driver, info.driver_info);
startup_step(format_args!(
"requesting the device (driver {}) with features [{features}] and the adapter's \
@@ -1369,8 +1399,10 @@ impl Renderer {
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
// COPY_SRC: animated sprites into a Java pack's atlas.
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::STORAGE_BINDING,
view_formats: &[],
},
@@ -1815,6 +1847,26 @@ impl Renderer {
CRUMBLING_BLEND,
false,
);
// RenderPipelines.ENTITY_SHADOW: translucent, depth test without
// writes. The quads lie on the block tops like vanilla's, but the
// terrain under them snaps to the subpixel grid at other vertices, so
// at a slant their depths differ by up to 1/256 of a pixel's depth
// slope and whole shadows failed the test. The bias covers that and
// the rounding, and stays below the 0.001 a model's feet sit higher
// (1/27 of a pixel's slope at the 16-block shadow range in 720p).
let shadow_blob_pipeline = entity_pipeline(
"Entity shadows",
None,
false,
wgpu::CompareFunction::GreaterEqual,
wgpu::DepthBiasState {
constant: 16,
slope_scale: 0.015,
clamp: 0.0,
},
alpha_blend,
false,
);
let cloud_pipeline = entity_pipeline(
"Clouds",
Some(wgpu::Face::Back),
@@ -2214,6 +2266,8 @@ impl Renderer {
std::mem::size_of::<TerrainVertex>() as u64,
1 << 20,
);
let java_page_layout = java_terrain::page_layout(&device);
let java_norm16 = java_terrain::norm16_targets(&adapter, &device);
// Sections as one index list per frame where multi-draw is emulated.
let flat = flat::wanted(&adapter, &device).then(|| {
let buffers = [Some(wgpu::VertexBufferLayout {
@@ -2332,6 +2386,7 @@ impl Renderer {
entity_pipeline,
weather,
crumbling_pipeline,
shadow_blob_pipeline,
water_mask_pipeline,
cloud_pipeline,
cloud_mesh: Default::default(),
@@ -2407,6 +2462,7 @@ impl Renderer {
draw_pages: Vec::new(),
phase2_draws: Vec::new(),
phase2_pages: Vec::new(),
shadow: ShadowList::default(),
solid_runs: page_runs::PageRuns::default(),
phase2_runs: page_runs::PageRuns::default(),
block_entities: HashMap::new(),
@@ -2414,6 +2470,11 @@ impl Renderer {
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
world_post,
pack: None,
java_page_layout,
java_norm16,
java_table: None,
java_pages: Vec::new(),
animated: Vec::new(),
hud,
menu_blur: None,
info,
@@ -2722,28 +2783,177 @@ impl Renderer {
base: u32,
first: u32,
count: u32,
) {
self.draw_sections_with(pass, indirect, base, first, count, |_, _| {});
}
/// [`Self::draw_sections`]; `page` binds what else a page's draws need.
fn draw_sections_with(
&self,
pass: &mut wgpu::RenderPass<'_>,
indirect: &wgpu::Buffer,
base: u32,
first: u32,
count: u32,
page_group: impl Fn(&mut wgpu::RenderPass<'_>, u32),
) {
let list = (&self.draws[..], &self.draw_pages[..]);
self.draw_list(pass, indirect, list, base, first, count, page_group);
}
/// [`Self::draw_sections_with`] over another list of draws and pages.
#[allow(clippy::too_many_arguments)]
fn draw_list(
&self,
pass: &mut wgpu::RenderPass<'_>,
indirect: &wgpu::Buffer,
(draws, pages): (&[[u32; 5]], &[(u32, Option<u32>)]),
base: u32,
first: u32,
count: u32,
page_group: impl Fn(&mut wgpu::RenderPass<'_>, u32),
) {
let (mut i, end) = (first as usize, (first + count) as usize);
while i < end {
let (page, index_page) = self.draw_pages[i];
let run = self.draw_pages[i..end]
let (page, index_page) = pages[i];
let run = pages[i..end]
.iter()
.take_while(|&&p| p == (page, index_page))
.count();
pass.set_vertex_buffer(0, self.arena.buffer(page).slice(..));
page_group(pass, page);
let indices = index_page.map_or(&self.indices, |p| self.sorted_indices.buffer(p));
pass.set_index_buffer(indices.slice(..), wgpu::IndexFormat::Uint32);
if self.multi_draw {
let offset = (i - base as usize) as u64 * 20;
pass.multi_draw_indexed_indirect(indirect, offset, run as u32);
} else {
for d in &self.draws[i..i + run] {
for d in &draws[i..i + run] {
pass.draw_indexed(d[2]..d[2] + d[0], d[3] as i32, d[4]..d[4] + 1);
}
}
i += run;
}
}
/// One `layer` of Horizon's terrain with a Java pack's geometry
/// pipelines (`java_terrain::DrawTerrain`): the frame's flat draws or its
/// section draws, each page with its group 3. `draws` are the frame's
/// phase 1 solid draws, all its solid draws and its translucent ones;
/// `culling` tells whether phase 2's come from occlusion culling.
fn draw_java_terrain(
&self,
pass: &mut wgpu::RenderPass<'_>,
layer: java_terrain::Layer,
pipelines: &java_terrain::TerrainPipelines,
flat_frame: bool,
draws: [u32; 3],
culling: bool,
) {
use java_terrain::Layer;
let page = |pass: &mut wgpu::RenderPass<'_>, page: u32| {
if let Some(Some((_, group))) = self.java_pages.get(page as usize) {
pass.set_bind_group(3, group, &[]);
}
};
if matches!(layer, Layer::ShadowSolid | Layer::ShadowTranslucent) {
let shadow = &self.shadow;
let all = shadow.draws.len() as u32;
let (first, count) = if layer == Layer::ShadowSolid {
(0, shadow.solid)
} else {
(shadow.solid, all - shadow.solid)
};
// Without multi-draw the draws are read from the list.
let indirect = shadow.indirect.as_ref().unwrap_or(&self.indirect);
if count == 0 {
return;
}
pass.set_bind_group(2, pipelines.group, &[]);
pass.set_pipeline(pipelines.instanced);
pass.set_vertex_buffer(1, self.instances.slice(..));
let list = (&shadow.draws[..], &shadow.pages[..]);
self.draw_list(pass, indirect, list, 0, first, count, page);
return;
}
pass.set_bind_group(2, pipelines.group, &[]);
match (self.flat.as_ref().filter(|_| flat_frame), pipelines.flat) {
// Without occlusion culling, phase 1 is every solid call.
(Some(flat), Some(pipeline)) => {
let vertices = |p| self.arena.buffer(p);
pass.set_pipeline(pipeline);
match layer {
Layer::Solid => {
flat.draw(pass, &flat.calls[..flat.phase1_calls], vertices, page)
}
Layer::Revealed if culling => flat.draw_phase2(pass, vertices, page),
Layer::Revealed => {}
Layer::Translucent => {
flat.draw(pass, &flat.calls[flat.solid_calls..], vertices, page)
}
// Drawn from the shadow list, above.
Layer::ShadowSolid | Layer::ShadowTranslucent => {}
}
}
(Some(_), None) => {}
(None, _) => {
let [phase1, solid, translucent_draws] = draws;
let cull = self.cull.as_ref().filter(|_| culling);
let (indirect, base, first, count) = match (layer, cull) {
(Layer::Solid, Some(_)) => (&self.indirect, 0, 0, phase1),
(Layer::Solid, None) => (&self.indirect, 0, 0, solid),
(Layer::Revealed, Some(c)) => (&c.phase2, phase1, phase1, solid - phase1),
(Layer::Revealed, None) => return,
(Layer::Translucent, _) => (&self.indirect, 0, solid, translucent_draws),
(Layer::ShadowSolid | Layer::ShadowTranslucent, _) => return,
};
if count == 0 {
return;
}
pass.set_pipeline(pipelines.instanced);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(1, self.instances.slice(..));
self.draw_sections_with(pass, indirect, base, first, count, page);
}
}
}
/// Each arena page's group for a Java pack's terrain programs: its
/// vertices and the glue's table.
fn prepare_java_pages(&mut self) {
let Some(table) = &self.java_table else {
return;
};
let count = self.arena.page_count() as usize;
self.java_pages.resize_with(count, || None);
let limit = self.device.limits().max_storage_buffer_binding_size;
for page in 0..count {
if !self.arena.has_page(page as u32) {
self.java_pages[page] = None;
continue;
}
let buffer = self.arena.buffer(page as u32);
if matches!(&self.java_pages[page], Some((b, _)) if b == buffer) {
continue;
}
let size = buffer.size().min(limit / 4 * 4);
let group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Java shader pack terrain page"),
layout: &self.java_page_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer,
offset: 0,
size: std::num::NonZeroU64::new(size),
}),
},
wgpu::BindGroupEntry {
binding: 1,
resource: table.as_entire_binding(),
},
],
});
self.java_pages[page] = Some((buffer.clone(), group));
}
}
fn release_section(&mut self, key: SectionKey) {
if let Some(s) = self.sections.remove(key) {
self.release_mesh(s);
@@ -2827,9 +3037,29 @@ impl Renderer {
}
}
/// Run `pack`'s stages over the world from now on (`None`: no pack, the
/// vanilla frame). On error the previous pack stays.
pub fn set_shader_pack(&mut self, pack: Option<&crate::shader_pack::ShaderPack>) -> Result<()> {
/// vanilla frame). On error the previous pack stays. `sprite_layers`
/// names the block sprites, for a Java pack's `block.properties`.
pub fn set_shader_pack(
&mut self,
pack: Option<&crate::shader_pack::ShaderPack>,
sprite_layers: &std::collections::BTreeMap<String, u32>,
) -> Result<()> {
let format = self.config.format.remove_srgb_suffix();
let layouts = java_stages::GeometryLayouts {
flat: self.flat.as_ref().map(|f| f.draw_layout()),
page: &self.java_page_layout,
};
let table = pack.and_then(|p| p.java.as_ref()).map(|java| {
let table = java_terrain::table(sprite_layers, &java.block_ids);
wgpu::util::DeviceExt::create_buffer_init(
&self.device,
&wgpu::util::BufferInitDescriptor {
label: Some("Java shader pack terrain table"),
contents: bytemuck::cast_slice(&table),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
},
)
});
self.pack = match pack {
Some(pack) => Some(match &pack.java {
Some(java) => Pack::Java(java_stages::JavaStages::new(
@@ -2838,6 +3068,8 @@ impl Renderer {
format,
&pack.id,
java,
&layouts,
self.java_norm16,
)?),
None => Pack::Horizon(pack_stages::PackStages::new(
&self.device,
@@ -2848,6 +3080,8 @@ impl Renderer {
}),
None => None,
};
self.java_table = table;
self.java_pages.clear();
Ok(())
}
/// The folder name of the pack `set_shader_pack` gave.
@@ -2988,6 +3222,15 @@ impl Renderer {
}
fn animate(&mut self, encoder: &mut wgpu::CommandEncoder, tick: u64) {
self.animations.animate(encoder, &mut self.uploads, tick);
// A Java pack's atlas copies what changed, every level.
let levels = self.mip_levels;
self.animated.clear();
self.animated.extend(
self.animations
.changed
.iter()
.flat_map(|&layer| (0..levels).map(move |level| (layer, level))),
);
}
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
let rendered = self.draw_frame(frame);
@@ -3238,8 +3481,59 @@ impl Renderer {
visible_lods.sort_unstable_by_key(|v| (v.distance, v.key));
}
self.drawn_sections = visible.len() + visible_lods.len();
if self.drawn_sections as u64 > self.instance_capacity {
self.instance_capacity = (self.drawn_sections as u64).next_power_of_two();
// A Java pack's shadow map: the sections in the light's orthographic
// box, whatever the camera sees (Iris turns cave culling off too).
let mut shadow_visible = std::mem::take(&mut self.shadow.visible);
shadow_visible.clear();
let shadow_view = match &self.pack {
Some(Pack::Java(j)) if !frame.title && self.java_table.is_some() => j.shadow_view(
frame.shader.dimension,
&java_stages::JavaFrame {
eye_light: frame.shader.eye_light,
sun_angle: frame.sky.sun_angle,
moon_angle: frame.sky.moon_angle,
},
camera.as_vec3(),
),
_ => None,
};
if let Some((light_view, [half, near, far])) = shadow_view {
// Inside when `plane · (p, 1) >= 0`: |x|, |y| <= half and the
// eye depth between the near and far planes (GL looks down -z).
let (x, y, z) = (light_view.row(0), light_view.row(1), light_view.row(2));
let planes = [
Vec4::W * half - x,
Vec4::W * half + x,
Vec4::W * half - y,
Vec4::W * half + y,
-z - Vec4::W * near,
z + Vec4::W * far,
];
// Toward the light: the light's view looks down its -z.
let light = z.truncate().normalize_or_zero();
let casts = |k: SectionKey| {
let min = DVec3::new(
f64::from(k.0 * 16),
f64::from(crate::chunk::MIN_Y + k.1 * 16),
f64::from(k.2 * 16),
) - camera;
let min = min.as_vec3();
view.casts_into(min, min + Vec3::splat(16.0), light)
};
crate::spans::time("shadow visibility", || {
self.sections.visible(
camera,
(frame.detail_distance / 16.0) as i32,
&Frustum { planes },
casts,
|_, _| false,
&mut shadow_visible,
)
});
}
let instances = self.drawn_sections + shadow_visible.len();
if instances as u64 > self.instance_capacity {
self.instance_capacity = (instances as u64).next_power_of_two();
self.instances = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Section origins"),
size: self.instance_capacity * 16,
@@ -3254,8 +3548,63 @@ impl Renderer {
visible
.iter()
.map(|v| section_instance(v.origin))
.chain(visible_lods.iter().map(|v| lod::instance(v.origin))),
.chain(visible_lods.iter().map(|v| lod::instance(v.origin)))
.chain(shadow_visible.iter().map(|v| section_instance(v.origin))),
);
// The shadow map's draws: every solid quad (no face is culled), then
// the translucent ones in the order the camera's sort left them.
let shadow = &mut self.shadow;
shadow.draws.clear();
shadow.pages.clear();
for pass in [false, true] {
for (j, v) in shadow_visible.iter().enumerate() {
let s = self.sections.get(v.slot);
let instance = (self.drawn_sections + j) as u32;
match (pass, s.sorted_indices) {
(false, _) if s.solid > 0 => {
shadow
.draws
.push([s.solid * 6, 1, 0, s.start as u32, instance]);
shadow.pages.push((s.page, None));
}
(true, Some((index_page, index_start))) if s.translucent > 0 => {
let first = s.start + u64::from(s.solid) * 4;
shadow.draws.push([
s.translucent * 6,
1,
index_start as u32,
first as u32,
instance,
]);
shadow.pages.push((s.page, Some(index_page)));
}
_ => {}
}
}
if !pass {
shadow.solid = shadow.draws.len() as u32;
}
}
if self.multi_draw && !shadow.draws.is_empty() {
if shadow.draws.len() as u64 > shadow.capacity || shadow.indirect.is_none() {
shadow.capacity = (shadow.draws.len() as u64).next_power_of_two();
shadow.indirect = Some(self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Java shader pack shadow draws"),
size: shadow.capacity * 20,
usage: wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
}
if let Some(indirect) = &shadow.indirect {
self.uploads.buffer(
&mut encoder,
indirect,
0,
bytemuck::cast_slice(&shadow.draws),
);
}
}
shadow.visible = shadow_visible;
if !self.origins.is_empty() {
self.uploads.buffer(
&mut encoder,
@@ -3272,6 +3621,12 @@ impl Renderer {
let eye = (frame.bob * crate::camera::view_matrix(frame.yaw, frame.pitch))
.inverse()
.transform_point3(Vec3::ZERO);
// A Java pack that draws the terrain (`gfx/java_terrain.rs`): the
// world pass leaves it out, and occlusion culling tests against the
// pack's depth (`Cull::test_gl`).
let java_terrain = !frame.title
&& self.java_table.is_some()
&& matches!(&self.pack, Some(Pack::Java(j)) if j.draws_terrain(frame.shader.dimension));
// Occlusion culling leaves the sections its last result did not see
// to phase 2, after the others, with instance counts of 0. Whether
// the frame has draws enough goes by the last one's.
@@ -3510,6 +3865,7 @@ impl Renderer {
crumbling_quads,
translucent_crumbling_quads,
water_mask_quads,
shadow_quads,
] = self.entity_batch.update(
frame.entities,
frame.additive,
@@ -3517,8 +3873,11 @@ impl Renderer {
frame.see_through,
);
let weather_quads = frame.weather.len() as u32 / 4;
let world_quads =
entity_quads + crumbling_quads + translucent_crumbling_quads + water_mask_quads;
let world_quads = entity_quads
+ crumbling_quads
+ translucent_crumbling_quads
+ water_mask_quads
+ shadow_quads;
let cloud_quads = if let Some(input) = &frame.clouds {
let (rebuilt, uniform) =
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
@@ -3729,6 +4088,14 @@ impl Renderer {
bytemuck::cast_slice(&self.draws),
);
}
if java_terrain {
self.prepare_java_pages();
if let Some(table) = &self.java_table {
let nether = i32::from(frame.shader.dimension == -1);
self.queue
.write_buffer(table, 4, bytemuck::bytes_of(&nether));
}
}
let view_target = target_texture.create_view(&wgpu::TextureViewDescriptor {
format: Some(self.config.format.remove_srgb_suffix()),
..Default::default()
@@ -3890,6 +4257,7 @@ impl Renderer {
}
}
match self.flat.as_ref().filter(|_| flat_frame) {
_ if java_terrain => {}
Some(flat) => {
pass.set_pipeline(&flat.solid);
let vertices = |p| self.arena.buffer(p);
@@ -3897,7 +4265,12 @@ impl Renderer {
// As below: phase 1, the test, then phase 2's draws
// that the test saw, copied after it.
Some(cull) => {
flat.draw(&mut pass, &flat.calls[..flat.phase1_calls], vertices);
flat.draw(
&mut pass,
&flat.calls[..flat.phase1_calls],
vertices,
|_, _| {},
);
cull.test(&mut pass);
drop(pass);
cull.test_pass(&mut encoder);
@@ -3910,9 +4283,14 @@ impl Renderer {
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_pipeline(&flat.solid);
flat.draw_phase2(&mut pass, vertices);
flat.draw_phase2(&mut pass, vertices, |_, _| {});
}
None => flat.draw(&mut pass, &flat.calls[..flat.solid_calls], vertices),
None => flat.draw(
&mut pass,
&flat.calls[..flat.solid_calls],
vertices,
|_, _| {},
),
}
pass.set_bind_group(0, &self.group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
@@ -3972,6 +4350,17 @@ impl Renderer {
pass.draw_indexed(0..crumbling_quads * 6, (entity_quads * 4) as i32, 0..1);
pass.set_bind_group(0, &self.group, &[]);
}
if shadow_quads > 0 {
pass.set_pipeline(&self.shadow_blob_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(
0..shadow_quads * 6,
((world_quads - shadow_quads) * 4) as i32,
0..1,
);
pass.set_bind_group(0, &self.group, &[]);
}
// BoatRenderer's water patch (RenderTypes.waterMask): depth only,
// before translucent terrain, so no water shows inside the boat.
if water_mask_quads > 0 {
@@ -4041,11 +4430,15 @@ impl Renderer {
pass.set_bind_group(0, &self.group, &[]);
}
match self.flat.as_ref().filter(|_| flat_frame) {
_ if java_terrain => {}
Some(flat) => {
pass.set_pipeline(&flat.translucent);
flat.draw(&mut pass, &flat.calls[flat.solid_calls..], |p| {
self.arena.buffer(p)
});
flat.draw(
&mut pass,
&flat.calls[flat.solid_calls..],
|p| self.arena.buffer(p),
|_, _| {},
);
pass.set_bind_group(0, &self.group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
}
@@ -4120,10 +4513,39 @@ impl Renderer {
frame.shader.rain,
);
}
if let (true, Some((color, depth)), Some(size), Some(stages)) =
(pack, &scaled, self.world_post.size(), self.pack.as_mut())
if pack
&& let (Some((color, depth)), Some(size)) = (&scaled, self.world_post.size())
&& let Some(mut stages) = self.pack.take()
{
let output = match stages {
// The pack's terrain programs draw with the frame's draws.
let draws = [phase1_draws, solid_draws, translucent_draws];
let draw = |render: &mut wgpu::RenderPass<'_>,
layer: java_terrain::Layer,
pipelines: &java_terrain::TerrainPipelines| {
self.draw_java_terrain(render, layer, pipelines, flat_frame, draws, culling);
};
let cull = self.cull.as_ref().filter(|_| culling && solid_draws > 0);
let occlusion = |encoder: &mut wgpu::CommandEncoder, depth: &wgpu::TextureView| {
if let Some(cull) = cull {
cull.test_gl(&self.device, encoder, depth);
// The flat path's phase 2: the calls the test saw.
if let Some(flat) = self.flat.as_ref().filter(|_| flat_frame) {
flat.flatten_phase2(encoder);
}
}
};
let world = java_terrain::JavaWorld {
sprites: &self.sprite_textures,
split: sprite_split(),
levels: self.mipmaps,
animated: &self.animated,
lightmap: &self.lightmap_view,
draw: &draw,
occlusion: cull
.is_some()
.then_some(&occlusion as &java_terrain::OcclusionTest),
};
let output = match &mut stages {
Pack::Horizon(stages) => stages.run(
&self.device,
&self.queue,
@@ -4149,10 +4571,12 @@ impl Renderer {
sun_angle: frame.sky.sun_angle,
moon_angle: frame.sky.moon_angle,
},
java_terrain.then_some(&world),
),
};
self.world_post
.finish_with(&mut encoder, &view_target, output);
self.pack = Some(stages);
} else if scaled.is_some() {
self.world_post.finish(&mut encoder, &view_target);
}
@@ -4463,6 +4887,22 @@ impl Frustum {
p.truncate().dot(v) + p.w < 0.0
})
}
/// Whether the camera-relative box `min..max`, lit from the direction
/// `light` (toward the light), can cast a shadow inside: its shadow is
/// the box swept away from the light, which stays outside a plane the
/// box is outside of unless the sweep crosses it (Iris's advanced
/// shadow culling, conservatively).
pub fn casts_into(&self, min: Vec3, max: Vec3, light: Vec3) -> bool {
!self.planes.iter().any(|p| {
let v = Vec3::new(
if p.x >= 0.0 { max.x } else { min.x },
if p.y >= 0.0 { max.y } else { min.y },
if p.z >= 0.0 { max.z } else { min.z },
);
p.truncate().dot(v) + p.w < 0.0 && p.truncate().dot(light) >= 0.0
})
}
/// Whether the camera-relative box `min..max` is entirely inside. Then
/// `intersects` accepts every box within it: each plane's distance only
/// grows from this box's nearest corner to the inner box's farthest, and
@@ -4569,6 +5009,31 @@ fn frustum(m: Mat4) -> [Vec4; 6] {
#[cfg(test)]
mod tests {
#[test]
fn shadow_casters_are_the_boxes_whose_shadow_can_reach_the_view() {
use glam::Vec3;
// Looking north (-z) from the origin, level.
let m = super::world_projection(70.0, 16.0 / 9.0, 256.0)
* crate::camera::view_matrix(180.0, 0.0);
let view = super::Frustum {
planes: super::frustum(m),
};
let cube =
|x: f32, y: f32, z: f32| (Vec3::new(x, y, z), Vec3::new(x + 16.0, y + 16.0, z + 16.0));
let noon = Vec3::Y;
// In view: casts.
let (min, max) = cube(-8.0, -8.0, -64.0);
assert!(view.intersects(min, max) && view.casts_into(min, max, noon));
// Far above the view, the sun overhead: its shadow falls into it.
let (min, max) = cube(-8.0, 200.0, -64.0);
assert!(!view.intersects(min, max) && view.casts_into(min, max, noon));
// Behind the camera: its shadow falls straight down, behind it too.
let (min, max) = cube(-8.0, -8.0, 32.0);
assert!(!view.casts_into(min, max, noon));
// The same box with a low sun behind the camera (south): it does.
assert!(view.casts_into(min, max, Vec3::new(0.0, 0.2, 1.0).normalize()));
}
#[test]
fn surface_is_8_bit_and_not_srgb() {
use super::surface_format;
@@ -4625,14 +5090,14 @@ mod tests {
normal: [0; 4],
}; 4]
};
let entities: Vec<_> = [(0, 0), (4, 1), (11, 2), (7, 3), (4, 4), (12, 7)]
let entities: Vec<_> = [(0, 0), (14, 9), (4, 1), (11, 2), (7, 3), (4, 4), (12, 7)]
.into_iter()
.flat_map(|(layer, id)| quad(layer, id))
.collect();
let mut batch = super::EntityBatch::default();
let counts = batch.update(&entities, &quad(3, 5), &quad(0, 6), &quad(0, 8));
let vertices = &batch.combined;
assert_eq!(counts, [4, 1, 1, 0]);
assert_eq!(counts, [4, 1, 1, 0, 1]);
assert_eq!(
vertices
.as_chunks::<4>()
@@ -4640,7 +5105,7 @@ mod tests {
.iter()
.map(|q| q[0].light)
.collect::<Vec<_>>(),
[0, 1, 3, 4, 2, 7, 5, 6, 8]
[0, 1, 3, 4, 2, 7, 9, 5, 6, 8]
);
}
#[test]
+6
View File
@@ -51,6 +51,9 @@ pub(super) struct SpriteAnimator {
pipeline: wgpu::ComputePipeline,
/// Edge of the arrays' layers.
size: u32,
/// The sprite layers (across the arrays) the last `animate` rewrote, at
/// every mip level: a Java pack's atlas copies them.
pub changed: Vec<u32>,
}
impl SpriteAnimator {
@@ -205,6 +208,7 @@ impl SpriteAnimator {
jobs,
pipeline,
size,
changed: Vec::new(),
}
}
@@ -216,6 +220,7 @@ impl SpriteAnimator {
uploads: &mut Uploads,
tick: u64,
) {
self.changed.clear();
if self.collect(tick) == 0 {
return;
}
@@ -251,6 +256,7 @@ impl SpriteAnimator {
continue;
}
sprite.shown = Some(blend);
self.changed.push(sprite.animation.layer);
let job = [
sprite.layer,
sprite.frames[blend.frame],
+8 -1
View File
@@ -65,8 +65,11 @@ pub struct Arena {
}
impl Arena {
/// Vertices; also storage, which a Java shader pack's terrain programs
/// read a quad's corners from (`gfx/java_terrain.rs`).
pub fn new(device: &wgpu::Device, stride: u64, capacity: u64) -> Self {
Self::with_usage(device, stride, capacity, wgpu::BufferUsages::VERTEX)
let usage = wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE;
Self::with_usage(device, stride, capacity, usage)
}
/// Sorted translucent indices; `storage` when compute passes read them.
pub fn indices(device: &wgpu::Device, capacity: u64, storage: bool) -> Self {
@@ -145,6 +148,10 @@ impl Arena {
pub fn page_vertices(&self) -> u64 {
self.page_limit
}
/// One past the last page index.
pub fn page_count(&self) -> u32 {
self.pages.len() as u32
}
pub fn has_page(&self, page: u32) -> bool {
self.pages.get(page as usize).is_some_and(Option::is_some)
}
+122 -44
View File
@@ -108,6 +108,14 @@ fn fs_box_early(in: Box) -> @location(0) vec4<f32> {
see(in.test);
return vec4(0.0);
}
// fs_box_early against a Java shader pack's depth (`vs_box_gl`), in a pass
// without colour targets.
@fragment
@early_depth_test(force)
fn fs_box_early_depth(in: Box) {
see(in.test);
}
";
/// The test's depth texture binding (`InShader`), for the view it was made with.
@@ -118,6 +126,8 @@ struct DepthGroup {
pub struct Cull {
pipeline: wgpu::RenderPipeline,
/// [`Self::test_gl`]'s: a depth-only pass in OpenGL's layout.
gl_pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
group: Option<Group>,
depth_test: DepthTest,
@@ -261,53 +271,76 @@ impl Cull {
bind_group_layouts: &[Some(&layout), depth_layout.as_ref()],
..Default::default()
});
// In the shader: no colour target, and phase 1's depth attached read
// only (a pass needs an attachment), its test left to the shader.
let (fragment_entry, targets, depth_compare) = match depth_test {
DepthTest::Early => (
"fs_box_early",
&[Some(wgpu::ColorTargetState {
format: color,
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})][..],
wgpu::CompareFunction::GreaterEqual,
),
DepthTest::InShader => ("fs_box", &[][..], wgpu::CompareFunction::Always),
// `gl`: a Java shader pack's depth (`test_gl`), in OpenGL's layout
// (y flipped, and with it the faces' winding; 0 near), without a
// colour target. In the shader (`InShader`): no colour target, and
// the depth attached read only (a pass needs an attachment), its
// test left to the shader.
let pipeline = |label, gl: bool| {
let color_target = [Some(wgpu::ColorTargetState {
format: color,
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})];
let (fragment_entry, targets, depth_compare) = match (depth_test, gl) {
(DepthTest::Early, false) => (
"fs_box_early",
&color_target[..],
wgpu::CompareFunction::GreaterEqual,
),
(DepthTest::Early, true) => (
"fs_box_early_depth",
&[][..],
wgpu::CompareFunction::LessEqual,
),
(DepthTest::InShader, false) => ("fs_box", &[][..], wgpu::CompareFunction::Always),
(DepthTest::InShader, true) => {
("fs_box_gl", &[][..], wgpu::CompareFunction::Always)
}
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some(if gl { "vs_box_gl" } else { "vs_box" }),
compilation_options: Default::default(),
buffers: &[],
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
front_face: if gl {
wgpu::FrontFace::Cw
} else {
wgpu::FrontFace::Ccw
},
..Default::default()
},
// The pass's depth test (reversed in the world pass, OpenGL's
// in a Java pack's), without writes.
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(depth_compare),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(fragment_entry),
compilation_options: Default::default(),
targets,
}),
multiview_mask: None,
cache: None,
})
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Section culling"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_box"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: wgpu::PrimitiveState {
cull_mode: Some(wgpu::Face::Back),
..Default::default()
},
// The world pass's reversed depth test, without writes.
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(depth_compare),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(fragment_entry),
compilation_options: Default::default(),
targets,
}),
multiview_mask: None,
cache: None,
});
let gl_pipeline = pipeline("Section culling, OpenGL layout", true);
let pipeline = pipeline("Section culling", false);
Self {
pipeline,
gl_pipeline,
layout,
group: None,
depth_test,
@@ -553,6 +586,51 @@ impl Cull {
pass.draw(0..36, 0..self.test_data.len() as u32);
}
/// [`Self::test`] for a Java shader pack that draws the terrain
/// (`gfx/java_stages.rs`): in a pass of its own against `depth`, the
/// pack's depth after phase 1 in OpenGL's layout (y up, 0 near), attached
/// read only, and also read by the shader where the test runs there
/// (`InShader`).
pub fn test_gl(
&self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
depth: &wgpu::TextureView,
) {
let Some(Group { group, .. }) = &self.group else {
return;
};
if self.test_data.is_empty() {
return;
}
let depth_group = self.depth_layout.as_ref().map(|layout| {
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Section culling depth, Java shader pack"),
layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(depth),
}],
})
});
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Section culling, Java shader pack"),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: depth,
depth_ops: None,
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&self.gl_pipeline);
pass.set_bind_group(0, group, &[]);
if let Some(depth) = &depth_group {
pass.set_bind_group(1, depth, &[]);
}
pass.draw(0..36, 0..self.test_data.len() as u32);
}
/// After the world passes: copy the results for reading back, when a
/// readback buffer is free.
pub fn end(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder) {
+22
View File
@@ -52,6 +52,10 @@ fn corner(origin: vec3<f32>, c: u32) -> vec3<f32> {
// section fail the depth test against the final depth, which is only nearer.
@vertex
fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box {
return box_vertex(v, t);
}
fn box_vertex(v: u32, t: u32) -> Box {
let origin = origins[tests[t].x].xyz;
var out: Box;
out.test = t;
@@ -86,6 +90,15 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
return out;
}
// The same box in OpenGL's layout, for a Java shader pack's terrain depth
// (gfx/java_stages.rs): y flipped, depth from 0 near to 1 far.
@vertex
fn vs_box_gl(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box {
var out = box_vertex(v, t);
out.position = vec4(out.position.x, -out.position.y, out.position.w - out.position.z, out.position.w);
return out;
}
// The box is seen at this fragment: store the stamp, and give its phase 2
// draws their instance.
fn see(t: u32) {
@@ -111,3 +124,12 @@ fn fs_box(in: Box) {
see(in.test);
}
}
// fs_box against a Java shader pack's depth in OpenGL's layout (`vs_box_gl`):
// less or equal passing.
@fragment
fn fs_box_gl(in: Box) {
if in.position.z <= textureLoad(depth, vec2<u32>(in.position.xy), 0) {
see(in.test);
}
}
+10 -5
View File
@@ -2,11 +2,14 @@
use super::Vertex;
/// Entity materials by a quad's overlay (`Vertex::layer`), in draw order:
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12)
/// and the boat water mask (13, `RenderPipelines.WATER_MASK`).
const MATERIALS: usize = 4;
/// ordinary entities, breaking cracks (11), translucent breaking cracks (12),
/// the boat water mask (13, `RenderPipelines.WATER_MASK`) and entity shadows
/// (14, `RenderPipelines.ENTITY_SHADOW`).
const MATERIALS: usize = 5;
/// The overlay of depth-only quads that hide water (`RenderTypes.waterMask`).
pub const WATER_MASK_OVERLAY: u32 = 13;
/// The overlay of entity shadow quads (`RenderTypes.entityShadow`).
pub const SHADOW_OVERLAY: u32 = 14;
#[derive(Default)]
pub(super) struct EntityBatch {
@@ -37,6 +40,7 @@ impl EntityBatch {
11 => 1,
12 => 2,
WATER_MASK_OVERLAY => 3,
SHADOW_OVERLAY => 4,
_ => 0,
};
self.buckets[material][counts[material]] = index;
@@ -88,12 +92,13 @@ mod tests {
) -> (Vec<Vertex>, [u32; MATERIALS]) {
let mut combined = Vec::with_capacity(entities.len() + additive.len() + hand.len());
let mut counts = [0; MATERIALS];
for (i, layer) in [0, 11, 12, 13].into_iter().enumerate() {
for (i, layer) in [0, 11, 12, 13, 14].into_iter().enumerate() {
for quad in entities.as_chunks::<4>().0 {
let material = match quad[0].layer {
11 => 11,
12 => 12,
13 => 13,
14 => 14,
_ => 0,
};
if material == layer {
@@ -113,7 +118,7 @@ mod tests {
pos: [index as f32, 2.0, 3.0],
uv: [index as f32, 5.0],
layer: if mixed {
[7, 11, 12, 4, 13, 0][index / 4 % 6]
[7, 11, 14, 12, 4, 13, 0][index / 4 % 7]
} else {
0
},
+14 -2
View File
@@ -640,25 +640,36 @@ impl Flat {
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
/// The bind group layout of the draws' group 1 (block slots, slot
/// origins).
pub fn draw_layout(&self) -> &wgpu::BindGroupLayout {
&self.draw_layout
}
/// Draws `calls` with a flat pipeline set and `vertices` giving each
/// page's vertex buffer.
/// page's vertex buffer; `page` binds what else a page's draws need (a
/// Java pack's terrain reads the page's vertices).
pub fn draw<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
calls: &[Call],
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for call in calls {
self.bind_page(pass, call.page, &vertices);
page(pass, call.page);
pass.draw_indexed(call.first..call.first + call.count, 0, 0..1);
}
}
/// Draws phase 2's calls, as `flatten_phase2` left them.
/// Draws phase 2's calls, as `flatten_phase2` left them; `page` as in
/// [`Self::draw`].
pub fn draw_phase2<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
vertices: impl Fn(u32) -> &'a wgpu::Buffer,
page: impl Fn(&mut wgpu::RenderPass<'_>, u32),
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
@@ -666,6 +677,7 @@ impl Flat {
.enumerate()
{
self.bind_page(pass, call.page, &vertices);
page(pass, call.page);
pass.draw_indexed_indirect(&self.phase2_calls, k as u64 * 20);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,667 @@
//! Horizon's terrain drawn with a Java pack's `gbuffers_terrain` and
//! `gbuffers_water` (docs/shader-packs.md). The pack's vertex program runs
//! after a glue that rebuilds what Iris would feed it from Horizon's
//! `TerrainVertex`: the camera-relative position, the quad's normal and
//! tangent (from its four corners, read from the arena page as storage),
//! the colour without vanilla's directional shade, the lightmap
//! coordinates, the sprite's place in a block atlas laid out from Horizon's
//! sprite arrays, and `mc_Entity` from the pack's `block.properties`, by
//! sprite name.
use horizon_shaderpack::translate::Geometry;
use std::collections::BTreeMap;
/// `TerrainVertex` in 32-bit words.
const VERTEX_WORDS: u32 = 6;
/// The glue's declarations and `hz_vertex`; `HZ_ORIGIN_DECL` and
/// `HZ_ORIGIN` are the variant's (instanced or flat draws).
const GLUE: &str = r#"
layout(location = 0) in vec3 hz_position;
layout(location = 1) in uvec2 hz_uv;
layout(location = 2) in uint hz_layer;
layout(location = 3) in vec4 hz_color;
layout(location = 4) in uvec2 hz_light;
HZ_ORIGIN_DECL
layout(std430, set = 3, binding = 0) readonly buffer HzVertices { uint hz_vertices[]; };
// [0]: atlas columns, [1]: 1 in the Nether, then each sprite layer's block id.
layout(std430, set = 3, binding = 1) readonly buffer HzTable { int hz_table[]; };
vec4 hz_pos;
vec3 hz_normal;
vec4 hz_tangent;
vec4 hz_tint;
vec2 hz_atlas;
vec2 hz_mid;
vec2 hz_lightmap;
float hz_block;
vec3 hz_mid_block;
vec3 hz_corner(uint v) {
uint b = v * HZ_WORDSu;
return vec3(uintBitsToFloat(hz_vertices[b]), uintBitsToFloat(hz_vertices[b + 1u]),
uintBitsToFloat(hz_vertices[b + 2u]));
}
vec2 hz_corner_uv(uint v) {
uint w = hz_vertices[v * HZ_WORDSu + 3u];
return vec2(float(w & 65535u), float(w >> 16u));
}
void hz_vertex() {
vec4 origin = HZ_ORIGIN;
hz_pos = vec4(hz_position + origin.xyz, 1.0);
// The quad's corners: quads are four vertices from a multiple of four.
uint q = uint(gl_VertexIndex) & 4294967292u;
vec3 p0 = hz_corner(q);
vec3 e1 = hz_corner(q + 1u) - p0;
vec3 e2 = hz_corner(q + 2u) - p0;
vec3 n = cross(e1, e2);
if (dot(n, n) < 1e-12) {
n = cross(e2, hz_corner(q + 3u) - p0);
}
hz_normal = dot(n, n) > 0.0 ? normalize(n) : vec3(0.0, 1.0, 0.0);
vec2 t0 = hz_corner_uv(q);
vec2 d1 = hz_corner_uv(q + 1u) - t0;
vec2 d2 = hz_corner_uv(q + 2u) - t0;
float s = d1.x * d2.y - d2.x * d1.y < 0.0 ? -1.0 : 1.0;
vec3 t = (e1 * d2.y - e2 * d1.y) * s;
vec3 b = (e2 * d1.x - e1 * d2.x) * s;
t = t - hz_normal * dot(hz_normal, t);
t = dot(t, t) > 0.0 ? normalize(t) : vec3(1.0, 0.0, 0.0);
hz_tangent = vec4(t, dot(cross(hz_normal, t), b) < 0.0 ? -1.0 : 1.0);
// Iris turns vanilla's directional shade off: undo it on axis faces.
vec3 a = abs(hz_normal);
bool nether = hz_table[1] != 0;
float shade = 1.0;
if (a.y > 0.999) {
shade = nether ? 0.9 : (hz_normal.y > 0.0 ? 1.0 : 0.5);
} else if (a.z > 0.999) {
shade = 0.8;
} else if (a.x > 0.999) {
shade = 0.6;
}
hz_tint = vec4(min(hz_color.rgb / shade, vec3(1.0)), hz_color.a);
// The sprite's cell in the atlas, columns of square cells.
int layer = int(hz_layer & 16383u);
int cols = max(hz_table[0], 1);
vec2 cell = vec2(float(layer % cols), float(layer / cols));
vec2 uv = clamp(vec2(hz_uv) * origin.w, 0.0, 1.0);
hz_atlas = (cell + uv) / float(cols);
hz_mid = (cell + 0.5) / float(cols);
// Vanilla's UV2: block light, then sky light, 0..240.
hz_lightmap = vec2(float(hz_light.y), float(hz_light.x));
hz_block = float(hz_table[2 + layer]);
hz_mid_block = (floor(hz_position - hz_normal * 0.01) + 0.5 - hz_position) * 64.0;
}
"#;
/// The glue of one draw path: instanced section draws (the origin is an
/// instance attribute) or flat ones (`gfx/flat.rs`: from the block's slot).
pub fn glue(flat: bool, alpha_test: Option<f32>) -> Geometry {
let (decl, origin) = if flat {
(
"layout(std430, set = 1, binding = 0) readonly buffer HzBlockSlots { uint hz_block_slots[]; };\n\
layout(std430, set = 1, binding = 1) readonly buffer HzSlotOrigins { vec4 hz_slot_origins[]; };",
"hz_slot_origins[hz_block_slots[uint(gl_VertexIndex) >> 8u]]",
)
} else {
("layout(location = 5) in vec4 hz_origin;", "hz_origin")
};
let vertex_glue = GLUE
.replace("HZ_ORIGIN_DECL", decl)
.replace("HZ_ORIGIN", origin)
.replace("HZ_WORDS", &VERTEX_WORDS.to_string());
let lightmap = "vec4(hz_lightmap, 0.0, 1.0)";
let attributes: BTreeMap<String, String> = [
("iris_Vertex", "hz_pos"),
("vaPosition", "hz_pos"),
("iris_Normal", "vec4(hz_normal, 0.0)"),
("vaNormal", "vec4(hz_normal, 0.0)"),
("iris_Color", "hz_tint"),
("vaColor", "hz_tint"),
("iris_MultiTexCoord0", "vec4(hz_atlas, 0.0, 1.0)"),
("vaUV0", "vec4(hz_atlas, 0.0, 1.0)"),
("iris_MultiTexCoord1", lightmap),
("iris_MultiTexCoord2", lightmap),
("vaUV2", lightmap),
// OverlayTexture.NO_OVERLAY.
("vaUV1", "vec4(0.0, 10.0, 0.0, 0.0)"),
("mc_Entity", "vec4(hz_block, -1.0, 0.0, 0.0)"),
("mc_midTexCoord", "vec4(hz_mid, 0.0, 1.0)"),
("at_tangent", "hz_tangent"),
("at_midBlock", "vec4(hz_mid_block, 0.0)"),
]
.into_iter()
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect();
Geometry {
vertex_glue,
attributes,
alpha_test,
}
}
/// The glue's table: atlas columns, the Nether flag, then each sprite
/// layer's block id from `block.properties` (-1 for blocks it doesn't
/// list, as Iris).
pub fn table(sprite_layers: &BTreeMap<String, u32>, block_ids: &BTreeMap<String, i32>) -> Vec<i32> {
let layers = sprite_layers.values().max().map_or(0, |&m| m as usize + 1);
let mut out = vec![-1; layers + 2];
out[0] = atlas_columns(layers as u32) as i32;
out[1] = 0;
for (name, &layer) in sprite_layers {
if let Some(id) = horizon_shaderpack::properties::sprite_block_id(name, block_ids) {
out[layer as usize + 2] = id;
}
}
out
}
/// The atlas's columns (and rows) of sprite cells for `layers` sprites.
pub fn atlas_columns(layers: u32) -> u32 {
(f64::from(layers.max(1)).sqrt().ceil() as u32).max(1)
}
/// Whether `device` renders to and filters the 16-bit normalized formats
/// a Java pack's `RGBA16` targets become (`java_stages::target_format`).
pub fn norm16_targets(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
use wgpu::TextureFormat as F;
let wanted = wgpu::Features::TEXTURE_FORMAT_16BIT_NORM
| wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES;
device.features().contains(wanted)
&& [
F::R16Unorm,
F::Rg16Unorm,
F::Rgba16Unorm,
F::R16Snorm,
F::Rg16Snorm,
F::Rgba16Snorm,
]
.into_iter()
.all(|f| {
let features = adapter.get_texture_format_features(f);
features
.allowed_usages
.contains(wgpu::TextureUsages::RENDER_ATTACHMENT)
&& features
.flags
.contains(wgpu::TextureFormatFeatureFlags::FILTERABLE)
})
}
/// The bind group layout of a page group: the page's vertices and the glue's
/// table, read by the vertex stage.
pub fn page_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
let storage = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Java shader pack terrain page"),
entries: &[storage(0), storage(1)],
})
}
/// Which of Horizon's terrain draws a pack's program draws.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Layer {
/// The solid sections: all of them, or with occlusion culling
/// ([`JavaWorld::occlusion`]) the ones the last test saw (phase 1).
Solid,
/// With occlusion culling, the solid sections the test after phase 1
/// found in sight (phase 2).
Revealed,
Translucent,
/// The shadow map's sections ([`super::Renderer`]'s shadow list), solid
/// then translucent.
ShadowSolid,
ShadowTranslucent,
}
/// Draws Horizon's terrain with a pack's pipelines. Set by the renderer,
/// which owns the sections.
pub type DrawTerrain<'a> = dyn Fn(&mut wgpu::RenderPass<'_>, Layer, &TerrainPipelines<'_>) + 'a;
/// Occlusion culling's test between [`Layer::Solid`] and
/// [`Layer::Revealed`], against the pack's depth (OpenGL's layout).
pub type OcclusionTest<'a> = dyn Fn(&mut wgpu::CommandEncoder, &wgpu::TextureView) + 'a;
/// What the renderer binds to draw with a pack's geometry program: the
/// pipeline of its draw path and the pack's group (group 2). Group 1 is the
/// flat path's, group 3 the page's ([`page_layout`]). `flat` is `None` when
/// the renderer has no flat draws.
pub struct TerrainPipelines<'a> {
pub instanced: &'a wgpu::RenderPipeline,
pub flat: Option<&'a wgpu::RenderPipeline>,
pub group: &'a wgpu::BindGroup,
}
/// What a Java pack's geometry reads from the renderer each frame.
pub struct JavaWorld<'a> {
/// The block sprite arrays, [`super::SPRITE_SPLIT`]-layer each.
pub sprites: &'a [wgpu::Texture],
pub split: u32,
/// Mipmap levels the terrain samples.
pub levels: u32,
/// The sprite layers and mip levels animation rewrote this frame.
pub animated: &'a [(u32, u32)],
pub lightmap: &'a wgpu::TextureView,
pub draw: &'a DrawTerrain<'a>,
/// Set when the frame culls solid sections by occlusion.
pub occlusion: Option<&'a OcclusionTest<'a>>,
}
const ATLAS: &str = r#"
struct Atlas { cols: u32, layers: u32, split: u32, pad: u32 }
@group(0) @binding(0) var<uniform> atlas: Atlas;
@group(0) @binding(1) var s0: texture_2d_array<f32>;
@group(0) @binding(2) var s1: texture_2d_array<f32>;
@group(0) @binding(3) var s2: texture_2d_array<f32>;
@group(0) @binding(4) var s3: texture_2d_array<f32>;
@group(0) @binding(5) var s4: texture_2d_array<f32>;
@group(0) @binding(6) var s5: texture_2d_array<f32>;
@group(0) @binding(7) var s6: texture_2d_array<f32>;
@group(0) @binding(8) var s7: texture_2d_array<f32>;
struct Out {
@builtin(position) p: vec4f,
@location(0) @interpolate(flat) mip: u32,
}
@vertex
fn vs(@builtin(vertex_index) i: u32, @builtin(instance_index) mip: u32) -> Out {
let uv = vec2f(f32((i << 1u) & 2u), f32(i & 2u));
return Out(vec4f(uv * 2.0 - 1.0, 0.0, 1.0), mip);
}
// Each sprite layer into its cell, at mip level `mip`.
@fragment
fn fs(in: Out) -> @location(0) vec4f {
let cell = max(textureDimensions(s0, in.mip).x, 1u);
let px = vec2u(in.p.xy);
let c = px / cell;
let layer = c.y * atlas.cols + c.x;
if c.x >= atlas.cols || layer >= atlas.layers {
return vec4f(0.0);
}
let at = px % cell;
let l = i32(layer % atlas.split);
let m = i32(in.mip);
switch layer / atlas.split {
case 0u: { return textureLoad(s0, at, l, m); }
case 1u: { return textureLoad(s1, at, l, m); }
case 2u: { return textureLoad(s2, at, l, m); }
case 3u: { return textureLoad(s3, at, l, m); }
case 4u: { return textureLoad(s4, at, l, m); }
case 5u: { return textureLoad(s5, at, l, m); }
case 6u: { return textureLoad(s6, at, l, m); }
default: { return textureLoad(s7, at, l, m); }
}
}
"#;
/// Horizon's block sprites as one 2D atlas, as Iris binds `gtexture`:
/// square cells, [`atlas_columns`] to a row. Drawn from the sprite arrays
/// once, then animated sprites are copied in as they change.
pub struct Atlas {
texture: wgpu::Texture,
pub view: wgpu::TextureView,
mips: Vec<wgpu::TextureView>,
pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
uniform: wgpu::Buffer,
/// Layers, sprite size, mip levels it was made for.
key: (u32, u32, u32),
/// The first sprite array it was drawn from (resource reloads make
/// new ones).
source: wgpu::Texture,
drawn: bool,
}
impl Atlas {
/// The atlas for `world`'s sprites; `None` without any.
pub fn new(device: &wgpu::Device, queue: &wgpu::Queue, world: &JavaWorld) -> Option<Self> {
let key = Self::key(world)?;
let (layers, size, levels) = key;
let cols = atlas_columns(layers);
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Java shader pack block atlas"),
size: wgpu::Extent3d {
width: cols * size,
height: cols * size,
depth_or_array_layers: 1,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let mips = (0..levels)
.map(|level| {
texture.create_view(&wgpu::TextureViewDescriptor {
base_mip_level: level,
mip_level_count: Some(1),
..Default::default()
})
})
.collect();
let mut entries = vec![wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}];
entries.extend((1..=8).map(|binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: false },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
}));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Java shader pack block atlas"),
entries: &entries,
});
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Java shader pack block atlas"),
source: wgpu::ShaderSource::Wgsl(ATLAS.into()),
});
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Java shader pack block atlas"),
layout: Some(
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Java shader pack block atlas"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
}),
),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs"),
compilation_options: Default::default(),
targets: &[Some(wgpu::TextureFormat::Rgba8Unorm.into())],
}),
multiview_mask: None,
cache: None,
});
let uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Java shader pack block atlas"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(
&uniform,
0,
bytemuck::cast_slice(&[cols, layers, world.split.max(1), 0]),
);
Some(Self {
view: texture.create_view(&Default::default()),
texture,
mips,
pipeline,
layout,
uniform,
key,
source: world.sprites[0].clone(),
drawn: false,
})
}
fn key(world: &JavaWorld) -> Option<(u32, u32, u32)> {
let first = world.sprites.first()?;
let layers = world
.sprites
.iter()
.map(|t| t.depth_or_array_layers())
.sum();
let levels = (world.levels + 1).min(first.mip_level_count()).max(1);
Some((layers, first.width(), levels))
}
/// Whether it was made for `world`'s sprites.
pub fn fits(&self, world: &JavaWorld) -> bool {
Self::key(world) == Some(self.key) && world.sprites.first() == Some(&self.source)
}
/// Bring it up to date: drawn the first time, then the sprites
/// animation rewrote copied into their cells.
pub fn update(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
world: &JavaWorld,
) {
if !self.drawn {
self.draw(device, encoder, world);
self.drawn = true;
return;
}
let (layers, size, levels) = self.key;
let cols = atlas_columns(layers);
let split = world.split.max(1);
for &(layer, level) in world.animated {
let Some(array) = world.sprites.get((layer / split) as usize) else {
continue;
};
if level >= levels || layer >= layers {
continue;
}
let edge = (size >> level).max(1);
encoder.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo {
texture: array,
mip_level: level,
origin: wgpu::Origin3d {
x: 0,
y: 0,
z: layer % split,
},
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyTextureInfo {
texture: &self.texture,
mip_level: level,
origin: wgpu::Origin3d {
x: (layer % cols) * edge,
y: (layer / cols) * edge,
z: 0,
},
aspect: wgpu::TextureAspect::All,
},
wgpu::Extent3d {
width: edge,
height: edge,
depth_or_array_layers: 1,
},
);
}
}
/// The atlas's size in pixels (`atlasSize`).
pub fn size(&self) -> [u32; 2] {
[self.texture.width(), self.texture.height()]
}
/// Draw the sprites into the atlas, every mip level.
fn draw(&self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, world: &JavaWorld) {
let views: Vec<wgpu::TextureView> = (0..8)
.map(|i| {
let t = world.sprites.get(i).unwrap_or(&world.sprites[0]);
t.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
mip_level_count: Some(self.key.2),
..Default::default()
})
})
.collect();
let mut entries = vec![wgpu::BindGroupEntry {
binding: 0,
resource: self.uniform.as_entire_binding(),
}];
entries.extend(views.iter().enumerate().map(|(i, v)| wgpu::BindGroupEntry {
binding: i as u32 + 1,
resource: wgpu::BindingResource::TextureView(v),
}));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Java shader pack block atlas"),
layout: &self.layout,
entries: &entries,
});
for (mip, view) in self.mips.iter().enumerate() {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Java shader pack block atlas"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, mip as u32..mip as u32 + 1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `module` validates and translates for Metal, Vulkan and DirectX 12.
fn backends(module: &naga::Module, label: &str) {
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(module)
.unwrap_or_else(|e| panic!("{label}: {e:?}"));
naga::back::msl::write_string(
module,
&info,
// wgpu's Metal backend asks for MSL 2.x.
&naga::back::msl::Options {
lang_version: (2, 4),
..Default::default()
},
&naga::back::msl::PipelineOptions::default(),
)
.unwrap_or_else(|e| panic!("{label} (MSL): {e}"));
naga::back::spv::write_vec(module, &info, &Default::default(), None)
.unwrap_or_else(|e| panic!("{label} (SPIR-V): {e}"));
for entry in &module.entry_points {
let mut hlsl = String::new();
naga::back::hlsl::Writer::new(
&mut hlsl,
&naga::back::hlsl::Options::default(),
&naga::back::hlsl::PipelineOptions {
entry_point: Some((entry.stage, entry.name.clone())),
},
)
.write(module, &info, None)
.unwrap_or_else(|e| panic!("{label} {} (HLSL): {e}", entry.name));
}
}
/// A terrain program reading every attribute the glue fills, run after
/// each draw path's glue, and the atlas shader, on every backend.
#[test]
fn the_glue_feeds_a_terrain_program_on_every_backend() {
let program = "#version 120
attribute vec4 mc_Entity;
attribute vec4 mc_midTexCoord;
attribute vec4 at_tangent;
attribute vec3 at_midBlock;
varying vec4 color;
varying vec2 uv;
varying vec2 lm;
varying vec3 normal;
varying float id;
void main() {
vec4 p = gl_ModelViewMatrix * gl_Vertex;
p.xyz += at_midBlock / 64.0 * 0.0 + at_tangent.xyz * 0.0;
gl_Position = gl_ProjectionMatrix * p;
color = gl_Color;
uv = (gl_TextureMatrix[0] * gl_MultiTexCoord0).xy + mc_midTexCoord.xy * 0.0;
lm = (gl_TextureMatrix[1] * gl_MultiTexCoord1).xy;
normal = gl_NormalMatrix * gl_Normal;
id = mc_Entity.x;
}
";
for flat in [false, true] {
let mut link = horizon_shaderpack::translate::Link {
set: 2,
geometry: Some(glue(flat, Some(0.1))),
..Default::default()
};
let t = horizon_shaderpack::translate::translate(
horizon_shaderpack::Stage::Vertex,
program,
&mut link,
&|l| format!("line {l}"),
)
.unwrap_or_else(|e| panic!("flat {flat}: {e}"));
for wanted in ["hz_vertex();", "mc_Entity = vec4(vec4(hz_block"] {
assert!(t.glsl.contains(wanted), "{wanted}\n{}", t.glsl);
}
// The glue's inputs are Horizon's vertex layout.
let inputs = t.module.entry_points[0].function.arguments.len();
assert_eq!(inputs, if flat { 6 } else { 7 }, "{}", t.wgsl);
let reparsed = naga::front::wgsl::parse_str(&t.wgsl).unwrap();
backends(&reparsed, &format!("terrain glue (flat {flat})"));
}
backends(&naga::front::wgsl::parse_str(ATLAS).unwrap(), "atlas");
backends(
&naga::front::wgsl::parse_str(super::super::java_stages::BLIT).unwrap(),
"Java shader pack blits",
);
}
#[test]
fn sprites_take_their_blocks_ids() {
let layers: BTreeMap<String, u32> = [
("minecraft:block/oak_leaves", 0),
("minecraft:block/water_still", 1),
("minecraft:block/stone", 2),
("minecraft:block/oak_log_top", 3),
]
.into_iter()
.map(|(n, l)| (n.to_owned(), l))
.collect();
let ids: BTreeMap<String, i32> = [("oak_leaves", 10003), ("water", 8), ("oak_log", 7)]
.into_iter()
.map(|(n, i)| (n.to_owned(), i))
.collect();
assert_eq!(table(&layers, &ids), [2, 0, 10003, 8, -1, 7]);
assert_eq!(atlas_columns(5), 3);
assert_eq!(atlas_columns(0), 1);
}
}
+178 -5
View File
@@ -2,13 +2,14 @@
//! a folder holding `shaders/`, or a `.zip`. `horizon-shaderpack` translates
//! their full-screen programs (`begin`, `prepare`, `deferred`, `composite`,
//! `final`) to WGSL, and `gfx/java_stages.rs` runs them over Horizon's world
//! as Iris runs them over its own. Geometry programs (`gbuffers_*`,
//! `shadow`) don't run yet: the world is drawn by Horizon.
//! as Iris runs them over its own. Of the geometry programs, the terrain's
//! (`gbuffers_terrain`, `gbuffers_water` and their fallbacks) draw Horizon's
//! terrain (`gfx/java_terrain.rs`); the rest of the world is Horizon's.
use horizon_shaderpack::{
JavaPack,
pipeline::{Directives, Program, TargetSettings},
pipeline::{Directives, GeometryProgram, Pass, Program, TargetSettings},
preprocess::Environment,
properties::SkyDirectives,
properties::{Blend, SkyDirectives},
uniforms::CustomUniforms,
};
use std::{
@@ -36,6 +37,27 @@ pub struct JavaFolder {
/// `sunPathRotation`, `eyeBrightnessHalflife`, `wetnessHalflife` and
/// `drynessHalflife`.
pub directives: Directives,
/// The programs Horizon's solid and translucent terrain are drawn with,
/// each translated for instanced and flat draws (`variants[0]`).
pub terrain: Option<GeometryProgram>,
pub water: Option<GeometryProgram>,
/// How many of `passes` run before translucent terrain (`begin*`,
/// `prepare*`, `deferred*`).
pub translucent_at: usize,
pub flipped_after_translucent: BTreeSet<u32>,
/// Each draw buffer's blending in `terrain` and `water`.
pub terrain_blends: Vec<Option<wgpu::BlendState>>,
pub water_blends: Vec<Option<wgpu::BlendState>>,
/// The shadow map's programs over Horizon's solid and translucent
/// terrain (instanced draws only), drawn after `begin*`, into
/// `shadowcolor` targets set by `shadow_targets`.
pub shadow: Option<GeometryProgram>,
pub shadow_water: Option<GeometryProgram>,
pub shadow_targets: Vec<TargetSettings>,
pub shadow_blends: Vec<Option<wgpu::BlendState>>,
pub shadow_water_blends: Vec<Option<wgpu::BlendState>>,
/// How many of `passes` run before the shadow map (`begin*`).
pub shadow_at: usize,
}
/// The key of `texture.noise` (`noisetex`) in [`JavaPasses::custom_textures`].
@@ -60,6 +82,8 @@ pub struct JavaPasses {
pub uniforms: CustomUniforms,
/// What the pack keeps of the vanilla sky (`clouds`, `sun`, `moon`).
pub sky: SkyDirectives,
/// `block.properties`: block name → `mc_Entity` id.
pub block_ids: BTreeMap<String, i32>,
/// `texture.<stage>.<sampler>` and `texture.noise` images, by
/// `"<stage>.<sampler>"` and [`NOISE_KEY`].
pub custom_textures: BTreeMap<String, crate::shader_pack::Rgba>,
@@ -144,6 +168,64 @@ pub fn load(
if passes.is_empty() && final_pass.is_none() {
continue;
}
// A geometry program that doesn't translate leaves that terrain to
// Horizon, with a warning.
let geometry_with = |pass: &Option<horizon_shaderpack::pipeline::Pass>,
alpha_test,
flat: &[bool]| {
let pass = pass.as_ref()?;
let glues: Vec<_> = flat
.iter()
.map(|&flat| crate::gfx::java_terrain::glue(flat, alpha_test))
.collect();
match pack.compile_geometry(pass, &plan.targets, &values, &env, &properties, &glues) {
Ok(program) => Some(program),
Err(e) => {
log::warn!("Horizon: shader pack {}: {e}", pass.program);
None
}
}
};
let geometry = |pass: &Option<horizon_shaderpack::pipeline::Pass>, alpha_test| {
geometry_with(pass, alpha_test, &[false, true])
};
// Iris's alpha test of cutout terrain (`AlphaTests.ONE_TENTH_ALPHA`).
let terrain = geometry(&plan.terrain, Some(0.1));
let water = terrain.as_ref().and_then(|_| geometry(&plan.water, None));
// The shadow map draws only with the pack's terrain, and only from
// instanced draws (the flat draws hold what the camera sees).
let shadow = terrain
.as_ref()
.and_then(|_| geometry_with(&plan.shadow, Some(0.1), &[false]));
let shadow_water = shadow
.as_ref()
.and_then(|_| geometry_with(&plan.shadow_water, None, &[false]));
let translucent_at = early.clone().count() + plan.deferred.len();
let blends = |g: &Option<GeometryProgram>, default| {
g.as_ref().map_or_else(Vec::new, |g| {
pass_blends(&g.program.pass, &g.program.draw_buffers, default)
})
};
let terrain_blends = blends(&terrain, None);
// Iris's shadow programs don't blend (`BlendModeOverride.OFF`).
let shadow_blends = blends(&shadow, None);
let shadow_water_blends = blends(&shadow_water, None);
// Vanilla's translucent blending (`BlendFunction.TRANSLUCENT`).
let water_blends = blends(
&water,
Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::SrcAlpha,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
operation: wgpu::BlendOperation::Add,
},
}),
);
folders.push(JavaFolder {
folder: name.to_owned(),
targets: plan.targets,
@@ -154,6 +236,18 @@ pub fn load(
final_pass,
flipped_at_end: plan.flipped_at_end,
directives: plan.directives,
terrain,
water,
translucent_at,
flipped_after_translucent: plan.flipped_after_translucent,
terrain_blends,
water_blends,
shadow,
shadow_water,
shadow_targets: plan.shadow_targets,
shadow_blends,
shadow_water_blends,
shadow_at: plan.begin.len(),
});
}
if folders.is_empty() {
@@ -184,11 +278,69 @@ pub fn load(
for problem in &uniforms.problems {
log::warn!("Horizon: shader pack custom uniform {problem}");
}
let block_ids = pack.block_ids(&values, &env).unwrap_or_else(|e| {
log::warn!("Horizon: shader pack {e}");
BTreeMap::new()
});
Ok(JavaPasses {
folders,
uniforms,
sky: properties.sky(),
custom_textures,
block_ids,
})
}
/// Each draw buffer's blending, as Iris's `BlendModeOverride`:
/// `blend.<program>.<buffer>`, else `blend.<program>`, else `default`
/// (none for full-screen passes, vanilla's for the geometry programs).
pub(crate) fn pass_blends(
pass: &Pass,
draw_buffers: &[u32],
default: Option<wgpu::BlendState>,
) -> Vec<Option<wgpu::BlendState>> {
let all = pass.blend.as_ref().map_or(default, blend_state);
draw_buffers
.iter()
.map(|i| {
pass.buffer_blends
.iter()
.find(|(b, _)| b == i)
.map_or(all, |(_, b)| blend_state(b))
})
.collect()
}
/// A `blend.*` value as wgpu's blending (`off`: none). The factors are the
/// GL ones `ShaderProperties` accepts.
pub(crate) fn blend_state(blend: &Blend) -> Option<wgpu::BlendState> {
let Blend::Factors(factors) = blend else {
return None;
};
let factor = |name: &str| {
use wgpu::BlendFactor as F;
match name {
"ZERO" => F::Zero,
"SRC_COLOR" => F::Src,
"ONE_MINUS_SRC_COLOR" => F::OneMinusSrc,
"DST_COLOR" => F::Dst,
"ONE_MINUS_DST_COLOR" => F::OneMinusDst,
"SRC_ALPHA" => F::SrcAlpha,
"ONE_MINUS_SRC_ALPHA" => F::OneMinusSrcAlpha,
"DST_ALPHA" => F::DstAlpha,
"ONE_MINUS_DST_ALPHA" => F::OneMinusDstAlpha,
"SRC_ALPHA_SATURATE" => F::SrcAlphaSaturated,
_ => F::One,
}
};
let component = |src: &str, dst: &str| wgpu::BlendComponent {
src_factor: factor(src),
dst_factor: factor(dst),
operation: wgpu::BlendOperation::Add,
};
Some(wgpu::BlendState {
color: component(&factors[0], &factors[1]),
alpha: component(&factors[2], &factors[3]),
})
}
@@ -242,8 +394,21 @@ mod tests {
.folders
.iter()
.flat_map(|f| f.passes.iter().chain(&f.final_pass));
let geometry = pack.folders.iter().flat_map(|f| {
f.terrain
.iter()
.chain(&f.water)
.chain(&f.shadow)
.chain(&f.shadow_water)
});
passes
.flat_map(|p| [(p, &p.vertex, "vsh"), (p, &p.fragment, "fsh")])
.chain(geometry.flat_map(|g| {
let p = &g.program;
[(p, &p.vertex, "vsh"), (p, &p.fragment, "fsh")]
.into_iter()
.chain(g.variants.iter().map(move |v| (p, v, "vsh (flat)")))
}))
.find_map(|(p, t, ext)| {
let e = naga::front::wgsl::parse_str(&t.wgsl).err()?;
Some(format!(
@@ -283,7 +448,15 @@ mod tests {
.iter()
.map(|f| {
let n = f.passes.len() + usize::from(f.final_pass.is_some());
format!("{} {n} passes", f.folder)
let geometry: Vec<&str> = f
.terrain
.iter()
.chain(&f.water)
.chain(&f.shadow)
.chain(&f.shadow_water)
.map(|g| g.program.pass.program.as_str())
.collect();
format!("{} {n} passes, terrain {geometry:?}", f.folder)
})
.collect();
println!("{name}: loads ({})", folders.join(", "));
+19
View File
@@ -432,6 +432,13 @@ pub fn stack_to_network(
item_id: impl Fn(&str) -> i32,
enchantment_id: impl Fn(&str) -> Option<i32>,
s: &Option<ItemStack>,
) -> Option<Stack> {
network_stack(&item_id, &enchantment_id, s)
}
fn network_stack(
item_id: &dyn Fn(&str) -> i32,
enchantment_id: &dyn Fn(&str) -> Option<i32>,
s: &Option<ItemStack>,
) -> Option<Stack> {
let s = s.as_ref()?;
let mut stack = Stack::new(item_id(&s.item), s.count as i32);
@@ -488,6 +495,18 @@ pub fn stack_to_network(
horizon_protocol::item::Component::Int(i32::from(mode)),
));
}
// A loaded crossbow's ammunition.
if !d.charged.is_empty() {
let charged = d
.charged
.iter()
.filter_map(|c| network_stack(item_id, enchantment_id, &Some(c.clone())))
.collect();
stack.added.push((
horizon_protocol::item::component::CHARGED_PROJECTILES,
horizon_protocol::item::Component::Stacks(charged),
));
}
// The books a lectern shows and a guest reads.
if !d.writable_pages.is_empty() {
stack.added.push((
+4 -3
View File
@@ -1981,14 +1981,15 @@ impl Hub {
self.guests[i].sneaking = p.flags & 32 != 0;
self.guests[i].sprinting = p.flags & 64 != 0 && p.flags & 1 != 0;
}
// OPEN_INVENTORY is the game's: the ridden mount's inventory.
S::PlayerCommand(p) if p.action == 5 => {
// OPEN_INVENTORY (the ridden mount's inventory) and
// START_FALL_FLYING (checked against the guest's chest slot
// and effects) are the game's.
S::PlayerCommand(p) if matches!(p.action, 5 | 6) => {
events.push(HostEvent::Packet(conn, Box::new(S::PlayerCommand(p))));
}
S::PlayerCommand(p) => match p.action {
1 => self.guests[i].sprinting = true,
2 => self.guests[i].sprinting = false,
6 => self.guests[i].fall_flying = true,
// STOP_SLEEPING.
0 => self.wake(i),
_ => {}
+2 -49
View File
@@ -2277,7 +2277,7 @@ mod sensor_scan_tests {
use horizon_core::geometry::Vec3;
use serde_json::json;
fn kind() -> Arc<MobType> {
pub(super) fn kind() -> Arc<MobType> {
Arc::new(MobType {
id: "minecraft:cow".into(),
species: Species::Cow,
@@ -2481,56 +2481,9 @@ mod sensor_scan_tests {
}
#[cfg(test)]
mod push_tests {
use super::sensor_scan_tests::kind;
use super::*;
use horizon_core::geometry::Vec3;
use serde_json::json;
fn kind() -> Arc<MobType> {
Arc::new(MobType {
id: "minecraft:cow".into(),
species: Species::Cow,
width: 0.9,
height: 1.4,
eye_height: 1.3,
category: "creature".into(),
max_health: 10.0,
speed: 0.2,
flying_speed: 0.4,
attack_damage: 0.0,
attack_knockback: 0.0,
follow_range: 16.0,
step_height: 0.6,
armor: 0.0,
armor_toughness: 0.0,
knockback_resistance: 0.0,
jump_strength: 0.42,
attributes: Arc::default(),
gravity: 0.08,
safe_fall_distance: 3.0,
fall_damage_multiplier: 1.0,
water_efficiency: 0.0,
water_slowdown: 0.8,
air_speed: 0.02,
baby_scale: 0.5,
head_rot_speed: 10.0,
max_head_x_rot: 40.0,
max_head_y_rot: 75.0,
ambient_interval: 120,
flags: Flags {
animal: true,
..Default::default()
},
model: Arc::new(Part::parse("root", &json!({}))),
layers: HashMap::new(),
texture: String::new(),
brain: Brain::Passive,
anim: Anim::Rabbit,
sounds: [""; 3],
step_sound: None,
food: Arc::default(),
tempt: Arc::default(),
})
}
/// The pending pushes as one list, scanned for each mob, as before.
fn take_old(events: &mut Vec<MobEvent>, from: usize, pending: &mut Vec<(u32, Vec3)>) {
+24 -65
View File
@@ -9,7 +9,12 @@
//! own random drives positions, sounds and jumps. `RunOne` shuffles with a
//! random of its own, seeded with the brain's `shuffle_seed` when it is
//! built.
use super::{Mob, MobEvent, MobWorld, Species, Who, path::Path, sniffer};
use super::{
Mob, MobEvent, MobWorld, Species, Who,
brain::{Timed, living_near},
path::Path,
sniffer,
};
use horizon_core::{
geometry::Vec3,
random::{LegacyRandom, RandomSource},
@@ -603,22 +608,7 @@ fn tick_sensors(m: &mut Mob, w: &MobWorld) {
/// `NearestLivingEntitySensor`: everything alive within the follow range,
/// nearest first.
fn sense_living(m: &mut Mob, w: &MobWorld) {
let r = m.attrs.follow_range;
let area = m.bounds().inflate(Vec3::new(r, r, r));
let mut found: Vec<(Who, f64)> = Vec::new();
for o in w.others_near(area) {
if o.id != m.id && o.alive && o.bounds.intersects(area) {
found.push((Who::Mob(o.id), m.distance_sqr(o.position)));
}
}
if let Some(p) = &w.player
&& p.alive
&& p.bounds.intersects(area)
{
found.push((Who::Player, m.distance_sqr(p.position)));
}
found.sort_by(|a, b| a.1.total_cmp(&b.1));
let list: Vec<Who> = found.into_iter().map(|(w, _)| w).collect();
let list = living_near(m, w);
let mm = mem(m);
mm.mobs.set_list(list.clone());
mm.visible_mobs.set(Visible {
@@ -794,37 +784,6 @@ fn followable_friendly(s: Species) -> bool {
// ---- Behaviours ------------------------------------------------------------
/// `Behavior`'s running state and end time.
#[derive(Clone, Copy, Debug)]
struct Timed {
running: bool,
end: i64,
min: i32,
max: i32,
}
impl Timed {
fn new(min: i32, max: i32) -> Self {
Self {
running: false,
end: 0,
min,
max,
}
}
/// `Behavior.tryStart`'s duration draw.
fn begin(&mut self, cx: &mut Bx) {
self.running = true;
let bound = self.max.wrapping_add(1).wrapping_sub(self.min);
let d = self
.min
.wrapping_add(cx.lr.next_int_bound(bound).unwrap_or(0));
self.end = cx.now + i64::from(d);
}
fn timed_out(&self, now: i64) -> bool {
now > self.end
}
}
/// `RunOne`: shuffles its children by weight and starts the first that can.
#[derive(Clone, Debug)]
struct Gate {
@@ -1076,7 +1035,7 @@ impl B {
{
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
mem(m).walk_target.clear();
mem(m).look_target.clear();
m.add_effect("minecraft:regeneration", 200, 0);
@@ -1086,7 +1045,7 @@ impl B {
if !swim_condition(m) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::AnimalPanic { t, .. } => {
@@ -1094,7 +1053,7 @@ impl B {
if !(mm.hurt_by.get() == Some(&true) || mm.is_panicking.present()) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
if brain_mut(m).kind == Kind::Sniffer {
reset_sniffing(m, cx);
}
@@ -1123,7 +1082,7 @@ impl B {
let reached = reached(m, cx.w, &walk);
if !reached && compute_path(m, cx, &walk, path, speed) {
*last_target = walk.target.block(cx.w);
t.begin(cx);
t.begin(cx.lr, cx.now);
mem(m).path.set_opt(path.clone());
m.nav_move_to_path(path.clone(), f64::from(*speed));
return true;
@@ -1139,21 +1098,21 @@ impl B {
if !mem(m).temptation_cooldown_ticks.present() {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::CountDownGaze(t) => {
if !mem(m).gaze_cooldown_ticks.present() {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::CountDownTransport(t) => {
if !mem(m).transport_items_cooldown_ticks.present() {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::Transport(x) => x.try_start(m, cx),
@@ -1171,7 +1130,7 @@ impl B {
let Some(partner) = breed_partner(m, cx.w) else {
return false;
};
t.begin(cx);
t.begin(cx.lr, cx.now);
if brain_mut(m).kind == Kind::Sniffer {
reset_sniffing(m, cx);
}
@@ -1200,7 +1159,7 @@ impl B {
{
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
if brain_mut(m).kind == Kind::Sniffer {
reset_sniffing(m, cx);
}
@@ -1210,14 +1169,14 @@ impl B {
if !mem(m).look_target.present() {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::FeelingHappy(t) => {
if !mem(m).sniffer_happy.present() {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
transition(m, cx, sniffer::FEELING_HAPPY);
true
}
@@ -1233,7 +1192,7 @@ impl B {
if !can_sniff(m) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
transition(m, cx, sniffer::DIGGING);
true
}
@@ -1246,7 +1205,7 @@ impl B {
{
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
transition(m, cx, sniffer::RISING);
true
}
@@ -1261,7 +1220,7 @@ impl B {
if !can_sniff(m) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
transition(m, cx, sniffer::SEARCHING);
true
}
@@ -1278,7 +1237,7 @@ impl B {
if is_tempted(m) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
transition(m, cx, sniffer::SCENTING);
true
}
@@ -1293,12 +1252,12 @@ impl B {
if m.is_baby() || !can_sniff(m) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
transition(m, cx, sniffer::SNIFFING);
true
}
B::DoNothing(t) => {
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::AxolotlAction { .. }
+56 -2
View File
@@ -1,8 +1,11 @@
//! Pieces of vanilla's `Brain` that species implemented as brains share:
//! memories that expire, behaviour durations, walk targets and
//! `MoveToTargetSink`.
use super::{Mob, goals::Cx};
use horizon_core::random::RandomSource;
use super::{Mob, MobWorld, Who, goals::Cx};
use horizon_core::{
geometry::Vec3,
random::{LegacyRandom, RandomSource},
};
/// A memory with an expiry (`Brain.setMemoryWithExpiry`): present for the
/// tick it was set and `ttl` more ticks. `Ttl::default()` is absent.
@@ -25,6 +28,57 @@ impl Ttl {
}
}
}
/// `Behavior`'s bookkeeping in the brains ported behaviour for behaviour
/// (villager, animal, monster, warden): running, and until when.
#[derive(Clone, Copy, Debug)]
pub struct Timed {
pub running: bool,
pub end: i64,
pub min: i32,
pub max: i32,
}
impl Timed {
pub fn new(min: i32, max: i32) -> Self {
Self {
running: false,
end: 0,
min,
max,
}
}
/// `Behavior.tryStart`'s duration draw from the level random, in Java's
/// int arithmetic (made even when it cannot vary).
pub fn begin(&mut self, lr: &mut LegacyRandom, now: i64) {
self.running = true;
let bound = self.max.wrapping_add(1).wrapping_sub(self.min);
let d = self.min.wrapping_add(lr.next_int_bound(bound).unwrap_or(0));
self.end = now + i64::from(d);
}
/// `Behavior.timedOut`.
pub fn timed_out(&self, now: i64) -> bool {
now > self.end
}
}
/// `NearestLivingEntitySensor`'s scan: the living entities (the player
/// among them) whose boxes meet the follow range around `m`, nearest first.
pub fn living_near(m: &Mob, w: &MobWorld) -> Vec<Who> {
let r = m.attrs.follow_range;
let area = m.bounds().inflate(Vec3::new(r, r, r));
let mut found: Vec<(Who, f64)> = Vec::new();
for o in w.others_near(area) {
if o.id != m.id && o.alive && o.bounds.intersects(area) {
found.push((Who::Mob(o.id), m.distance_sqr(o.position)));
}
}
if let Some(p) = &w.player
&& p.alive
&& p.bounds.intersects(area)
{
found.push((Who::Player, m.distance_sqr(p.position)));
}
found.sort_by(|a, b| a.1.total_cmp(&b.1));
found.into_iter().map(|(w, _)| w).collect()
}
/// A random duration in `min..=max` (`Behavior`'s end time).
pub fn duration(m: &mut Mob, min: i32, max: i32) -> i32 {
if max <= min {
+17 -99
View File
@@ -11,9 +11,10 @@
//! seeded when the brain is built.
use super::{
Mob, MobEvent, MobWorld, Species, Who,
brain::{Timed, living_near},
goals::Cx,
path::Path,
villager_brain::{NEVER, Slot, Tracker, Visible, WalkTarget},
villager_brain::{NEVER, Slot, Tracker, Visible, WalkTarget, compute_path, reached},
};
use crate::projectiles::{Projectile, ProjectileKind};
use horizon_core::{
@@ -129,6 +130,10 @@ fn brain_mut(m: &mut Mob) -> &mut Brain {
fn mem(m: &mut Mob) -> &mut Memories {
&mut brain_mut(m).mem
}
/// `CANT_REACH_WALK_TARGET_SINCE`, for `compute_path`.
fn cant_reach(m: &mut Mob) -> &mut Slot<i64> {
&mut mem(m).cant_reach_walk_target_since
}
fn mem_ref(m: &Mob) -> &Memories {
&brain(m).expect("brain").mem
}
@@ -292,22 +297,7 @@ fn tick_sensors(m: &mut Mob, w: &MobWorld) {
/// `NearestLivingEntitySensor`: everything alive within the follow range,
/// nearest first.
fn sense_living(m: &mut Mob, w: &MobWorld) {
let r = m.attrs.follow_range;
let area = m.bounds().inflate(Vec3::new(r, r, r));
let mut found: Vec<(Who, f64)> = Vec::new();
for o in w.others_near(area) {
if o.id != m.id && o.alive && o.bounds.intersects(area) {
found.push((Who::Mob(o.id), m.distance_sqr(o.position)));
}
}
if let Some(p) = &w.player
&& p.alive
&& p.bounds.intersects(area)
{
found.push((Who::Player, m.distance_sqr(p.position)));
}
found.sort_by(|a, b| a.1.total_cmp(&b.1));
let list: Vec<Who> = found.into_iter().map(|(w, _)| w).collect();
let list = living_near(m, w);
let mm = mem(m);
mm.mobs.set_list(list.clone());
mm.visible_mobs.set(Visible {
@@ -456,31 +446,6 @@ fn sense_breeze_attack(m: &mut Mob, w: &MobWorld) {
// ---- Behaviours ---------------------------------------------------------
/// `Behavior`'s bookkeeping: running, and until when.
#[derive(Clone, Debug)]
struct Timed {
running: bool,
end: i64,
min: i32,
max: i32,
}
impl Timed {
fn new(min: i32, max: i32) -> Self {
Self {
running: false,
end: 0,
min,
max,
}
}
/// `Behavior.tryStart`'s duration draw from the level random.
fn begin(&mut self, cx: &mut Bx) {
self.running = true;
let d = self.min + cx.lr.next_int_bound(self.max + 1 - self.min).unwrap_or(0);
self.end = cx.now + i64::from(d);
}
}
/// `StartAttacking`'s target finders.
#[derive(Clone, Copy, Debug)]
enum Finder {
@@ -607,14 +572,14 @@ impl B {
if *creaking && !super::creaking::can_move(m) || !swim_condition(m) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::LookAtTargetSink(t) => {
if !mem(m).look_target.present() {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::MoveToTargetSink {
@@ -636,9 +601,9 @@ impl B {
}
let walk = mm.walk_target.value().unwrap();
let reached = reached(m, cx.w, &walk);
if !reached && compute_path(m, cx, &walk, path, speed) {
if !reached && compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach) {
*last_target = walk.target.block(cx.w);
t.begin(cx);
t.begin(cx.lr, cx.now);
// start
mem(m).path.set_opt(path.clone());
m.nav_move_to_path(path.clone(), f64::from(*speed));
@@ -719,7 +684,7 @@ impl B {
true
}
B::DoNothing(t) => {
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::RandomStroll(running, speed) => {
@@ -848,7 +813,7 @@ impl B {
mem(m).breeze_shoot.clear();
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
// start
super::breeze::set_pose(m, super::breeze::SHOOTING);
mem(m).breeze_shoot_charging.set_ttl((), 15);
@@ -867,7 +832,7 @@ impl B {
if !long_jump_can_run(m, cx) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
// start
if !mem(m).breeze_jump_inhaling.present() {
mem(m).breeze_jump_inhaling.set_ttl((), 10);
@@ -905,7 +870,7 @@ impl B {
if !stuck {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
mem(m).breeze_shoot.set_ttl((), 60);
true
}
@@ -924,7 +889,7 @@ impl B {
{
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
slide_start(m, cx);
true
}
@@ -989,7 +954,7 @@ impl B {
&& let Some(now_target) = walk.target.block(cx.w)
{
let d = manhattan_sq(now_target, last);
if d > 4 && compute_path(m, cx, &walk, path, speed) {
if d > 4 && compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach) {
*last_target = Some(now_target);
mem(m).path.set_opt(path.clone());
m.nav_move_to_path(path.clone(), f64::from(*speed));
@@ -1122,53 +1087,6 @@ fn tracker_visible(m: &mut Mob, w: &MobWorld, t: Tracker) -> bool {
_ => true,
}
}
/// `MoveToTargetSink.reachedTarget`.
fn reached(m: &Mob, w: &MobWorld, walk: &WalkTarget) -> bool {
walk.target.block(w).is_some_and(|p| {
let h = block_of(m.position);
(p[0] - h[0]).abs() + (p[1] - h[1]).abs() + (p[2] - h[2]).abs() <= walk.close
})
}
/// `MoveToTargetSink.tryComputePath`.
fn compute_path(
m: &mut Mob,
cx: &mut Bx,
walk: &WalkTarget,
path: &mut Option<Path>,
speed: &mut f32,
) -> bool {
let Some(target) = walk.target.block(cx.w) else {
return false;
};
*path = m.create_path_to(cx.w, target, 0);
*speed = walk.speed;
if reached(m, cx.w, walk) {
mem(m).cant_reach_walk_target_since.clear();
} else {
let can_reach = path.as_ref().is_some_and(|p| p.reached);
let mm = mem(m);
if can_reach {
mm.cant_reach_walk_target_since.clear();
} else if !mm.cant_reach_walk_target_since.present() {
mm.cant_reach_walk_target_since.set(cx.now);
}
if path.is_some() {
return true;
}
let to = [
f64::from(target[0]) + 0.5,
f64::from(target[1]),
f64::from(target[2]) + 0.5,
];
if let Some(step) =
m.default_random_pos_towards(cx.w, 10, 7, to, f64::from(std::f32::consts::FRAC_PI_2))
{
*path = m.create_path_to(cx.w, block_of(Vec3::new(step[0], step[1], step[2])), 0);
return path.is_some();
}
}
false
}
/// `BehaviorUtils.isWithinAttackRange(body, target, 1)` for a mob without a
/// ranged weapon: `Mob.isWithinMeleeAttackRange`.
fn within_attack_range(m: &Mob, w: &MobWorld, target: Who) -> bool {
@@ -11,6 +11,7 @@
//! `Brain::shuffle_seed` when the brain is built.
use super::{
Mob, MobEvent, MobWorld, Species, Who,
brain::{Timed, living_near},
path::{Path, PathNode},
};
use horizon_core::{
@@ -341,6 +342,10 @@ fn brain_mut(m: &mut Mob) -> &mut Brain {
fn mem(m: &mut Mob) -> &mut Memories {
&mut brain_mut(m).mem
}
/// `CANT_REACH_WALK_TARGET_SINCE`, for `compute_path`.
fn cant_reach(m: &mut Mob) -> &mut Slot<i64> {
&mut mem(m).cant_reach_walk_target_since
}
fn mem_ref(m: &Mob) -> Option<&Memories> {
brain(m).map(|b| &b.mem)
}
@@ -565,22 +570,7 @@ const FOLLOW_RANGE: f64 = 16.0;
/// `NearestLivingEntitySensor`: everything alive within the follow range,
/// nearest first.
fn sense_living(m: &mut Mob, w: &MobWorld) {
let r = m.attrs.follow_range;
let area = m.bounds().inflate(Vec3::new(r, r, r));
let mut found: Vec<(Who, f64)> = Vec::new();
for o in w.others_near(area) {
if o.id != m.id && o.alive && o.bounds.intersects(area) {
found.push((Who::Mob(o.id), m.distance_sqr(o.position)));
}
}
if let Some(p) = &w.player
&& p.alive
&& p.bounds.intersects(area)
{
found.push((Who::Player, m.distance_sqr(p.position)));
}
found.sort_by(|a, b| a.1.total_cmp(&b.1));
let list: Vec<Who> = found.into_iter().map(|(w, _)| w).collect();
let list = living_near(m, w);
let mm = mem(m);
mm.mobs.set_list(list.clone());
mm.visible_mobs.set(Visible {
@@ -791,34 +781,6 @@ fn sense_golem(m: &mut Mob, w: &MobWorld) {
// ---- Behaviours ---------------------------------------------------------
/// `Behavior`'s bookkeeping: running, and until when.
#[derive(Clone, Debug)]
struct Timed {
running: bool,
end: i64,
min: i32,
max: i32,
}
impl Timed {
fn new(min: i32, max: i32) -> Self {
Self {
running: false,
end: 0,
min,
max,
}
}
/// `Behavior.tryStart`'s duration draw.
fn begin(&mut self, cx: &mut Bx) {
self.running = true;
let bound = self.max.wrapping_add(1).wrapping_sub(self.min);
let d = self
.min
.wrapping_add(cx.lr.next_int_bound(bound).unwrap_or(0));
self.end = cx.now + i64::from(d);
}
}
/// The POI types villagers use.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Poi {
@@ -1498,18 +1460,18 @@ impl B {
if !swim_condition(m) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::LookAtTargetSink(t) => {
if !mem(m).look_target.present() {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::PanicTrigger(t) => {
t.begin(cx);
t.begin(cx.lr, cx.now);
// VillagerPanicTrigger.start
if panic_condition(m) {
if !brain_mut(m).is_active(Act::Panic) {
@@ -1542,9 +1504,9 @@ impl B {
}
let walk = mm.walk_target.value().unwrap();
let reached = reached(m, cx.w, &walk);
if !reached && compute_path(m, cx, &walk, path, speed) {
if !reached && compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach) {
*last_target = walk.target.block(cx.w);
t.begin(cx);
t.begin(cx.lr, cx.now);
// start
mem(m).path.set_opt(path.clone());
m.nav_move_to_path(path.clone(), f64::from(*speed));
@@ -1561,7 +1523,7 @@ impl B {
if !follow_trading_condition(m, cx.w) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
follow_trading_player(m);
true
}
@@ -1573,7 +1535,7 @@ impl B {
if !matches!(a, Act::Idle | Act::Work | Act::Play) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::JumpOnBed {
@@ -1590,7 +1552,7 @@ impl B {
if mm.walk_target.present() || !m.is_baby() {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
*bed = Some(nearest);
*time_left = 100;
*jumps = 3 + cx.lr.next_int_bound(4).unwrap_or(0);
@@ -1599,7 +1561,7 @@ impl B {
true
}
B::DoNothing(t) => {
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::GiveGiftToHero {
@@ -1618,7 +1580,7 @@ impl B {
*until_next -= 1;
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
*given = false;
*started = now;
let mm = mem(m);
@@ -1630,7 +1592,7 @@ impl B {
if !mem(m).interaction_target.present() || !show_trades_condition(m, cx.w) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
// start
if let Some(target) = mem(m).interaction_target.value() {
mem(m).look_target.set(Tracker::Entity(target, true));
@@ -1646,7 +1608,7 @@ impl B {
if !villager_target_valid(m, cx.w, Mem::InteractionTarget) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
let target = mem(m).interaction_target.value().unwrap();
lock_gaze(m, target, cx);
true
@@ -1659,7 +1621,7 @@ impl B {
if !breeding_possible(m, cx.w) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
let target = mem(m).breed_target.value().unwrap();
lock_gaze(m, target, cx);
*birth = now + 275 + i64::from(m.random.next_int_bound(50).unwrap_or(0));
@@ -1680,7 +1642,7 @@ impl B {
if !closer_to_center(site, m.position, 1.73) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
// start
let mm = mem(m);
mm.last_worked_at_poi.set(now);
@@ -1709,7 +1671,7 @@ impl B {
*valid = farmland_around(m, cx.w);
*target = pick_farmland(valid, cx);
let Some(p) = *target else { return false };
t.begin(cx);
t.begin(cx.lr, cx.now);
if now > *next_ok {
let mm = mem(m);
mm.look_target.set(Tracker::Block(p));
@@ -2288,7 +2250,7 @@ impl B {
{
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
if now > *next_ok {
if super::villager::start_sleeping(m, home, w) {
mem(m).last_slept.set(now);
@@ -2609,7 +2571,7 @@ impl B {
{
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::LocateHidingPlace {
@@ -2769,7 +2731,7 @@ impl B {
&& let Some(now_target) = walk.target.block(cx.w)
{
let d = manhattan_sq(now_target, last);
if d > 4 && compute_path(m, cx, &walk, path, speed) {
if d > 4 && compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach) {
*last_target = Some(now_target);
mem(m).path.set_opt(path.clone());
m.nav_move_to_path(path.clone(), f64::from(*speed));
@@ -3141,33 +3103,37 @@ fn tracker_visible(m: &mut Mob, w: &MobWorld, t: Tracker) -> bool {
}
}
/// `MoveToTargetSink.reachedTarget`.
fn reached(m: &Mob, w: &MobWorld, walk: &WalkTarget) -> bool {
pub(super) fn reached(m: &Mob, w: &MobWorld, walk: &WalkTarget) -> bool {
walk.target
.block(w)
.is_some_and(|p| manhattan(p, block_of(m.position)) <= walk.close)
}
/// `MoveToTargetSink.tryComputePath`.
fn compute_path(
/// `MoveToTargetSink.tryComputePath`, for the brains on these memories
/// (villager, monster, warden): `cant_reach` is the brain's
/// `CANT_REACH_WALK_TARGET_SINCE`.
pub(super) fn compute_path(
m: &mut Mob,
cx: &mut Bx,
w: &MobWorld,
now: i64,
walk: &WalkTarget,
path: &mut Option<Path>,
speed: &mut f32,
cant_reach: fn(&mut Mob) -> &mut Slot<i64>,
) -> bool {
let Some(target) = walk.target.block(cx.w) else {
let Some(target) = walk.target.block(w) else {
return false;
};
*path = m.create_path_to(cx.w, target, 0);
*path = m.create_path_to(w, target, 0);
*speed = walk.speed;
if reached(m, cx.w, walk) {
mem(m).cant_reach_walk_target_since.clear();
if reached(m, w, walk) {
cant_reach(m).clear();
} else {
let can_reach = path.as_ref().is_some_and(|p| p.reached);
let mm = mem(m);
let since = cant_reach(m);
if can_reach {
mm.cant_reach_walk_target_since.clear();
} else if !mm.cant_reach_walk_target_since.present() {
mm.cant_reach_walk_target_since.set(cx.now);
since.clear();
} else if !since.present() {
since.set(now);
}
if path.is_some() {
return true;
@@ -3178,14 +3144,14 @@ fn compute_path(
f64::from(target[2]) + 0.5,
];
if let Some(step) =
m.default_random_pos_towards(cx.w, 10, 7, to, f64::from(std::f32::consts::FRAC_PI_2))
m.default_random_pos_towards(w, 10, 7, to, f64::from(std::f32::consts::FRAC_PI_2))
{
let p = [
step[0].floor() as i32,
step[1].floor() as i32,
step[2].floor() as i32,
];
*path = m.create_path_to(cx.w, p, 0);
*path = m.create_path_to(w, p, 0);
return path.is_some();
}
}
+17 -91
View File
@@ -14,9 +14,10 @@
//! shuffle seed.
use super::{
Mob, MobEvent, MobWorld, Who,
brain::Timed,
goals::Cx,
path::Path,
villager_brain::{Slot, Tracker, Visible, WalkTarget},
villager_brain::{Slot, Tracker, Visible, WalkTarget, compute_path, reached},
warden::{self, Pose},
};
use horizon_core::{
@@ -145,31 +146,6 @@ impl Memories {
}
}
/// `Behavior`'s bookkeeping: running, and until when.
#[derive(Clone, Debug)]
struct Timed {
running: bool,
end: i64,
min: i32,
max: i32,
}
impl Timed {
fn new(min: i32, max: i32) -> Self {
Self {
running: false,
end: 0,
min,
max,
}
}
/// `Behavior.tryStart`'s duration draw (made even when it cannot vary).
fn begin(&mut self, cx: &mut Bx) {
self.running = true;
let d = self.min + cx.lr.next_int_bound(self.max + 1 - self.min).unwrap_or(0);
self.end = cx.now + i64::from(d);
}
}
/// `RunOne`'s `ShufflingList` over a stroll (weight 2) and `DoNothing` (1).
#[derive(Clone, Debug)]
struct Gate {
@@ -383,6 +359,10 @@ fn brain(m: &Mob) -> Option<&Brain> {
pub(super) fn mem(m: &mut Mob) -> &mut Memories {
&mut m.extra.warden.as_mut().expect("a warden").brain.mem
}
/// `CANT_REACH_WALK_TARGET_SINCE`, for `compute_path`.
fn cant_reach(m: &mut Mob) -> &mut Slot<i64> {
&mut mem(m).cant_reach_walk_target_since
}
pub(super) fn mem_ref(m: &Mob) -> Option<&Memories> {
brain(m).map(|b| &b.mem)
}
@@ -558,9 +538,6 @@ fn find_closest(m: &mut Mob, w: &MobWorld, filter: impl Fn(&Mob, Who) -> bool) -
fn block_of(p: Vec3) -> [i32; 3] {
[p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32]
}
fn manhattan(a: [i32; 3], b: [i32; 3]) -> i32 {
(a[0] - b[0]).abs() + (a[1] - b[1]).abs() + (a[2] - b[2]).abs()
}
fn dist_sqr(a: [i32; 3], b: [i32; 3]) -> i64 {
(0..3).map(|i| i64::from(a[i] - b[i]).pow(2)).sum()
}
@@ -607,57 +584,6 @@ fn tracker_visible(m: &mut Mob, w: &MobWorld, t: Tracker) -> bool {
_ => true,
}
}
/// `MoveToTargetSink.reachedTarget`.
fn reached(m: &Mob, w: &MobWorld, walk: &WalkTarget) -> bool {
walk.target
.block(w)
.is_some_and(|p| manhattan(p, block_of(m.position)) <= walk.close)
}
/// `MoveToTargetSink.tryComputePath`.
fn compute_path(
m: &mut Mob,
cx: &mut Bx,
walk: &WalkTarget,
path: &mut Option<Path>,
speed: &mut f32,
) -> bool {
let Some(target) = walk.target.block(cx.w) else {
return false;
};
*path = m.create_path_to(cx.w, target, 0);
*speed = walk.speed;
if reached(m, cx.w, walk) {
mem(m).cant_reach_walk_target_since.clear();
} else {
let can_reach = path.as_ref().is_some_and(|p| p.reached);
let mm = mem(m);
if can_reach {
mm.cant_reach_walk_target_since.clear();
} else if !mm.cant_reach_walk_target_since.present() {
mm.cant_reach_walk_target_since.set(cx.now);
}
if path.is_some() {
return true;
}
let to = [
f64::from(target[0]) + 0.5,
f64::from(target[1]),
f64::from(target[2]) + 0.5,
];
if let Some(step) =
m.default_random_pos_towards(cx.w, 10, 7, to, f64::from(std::f32::consts::FRAC_PI_2))
{
let p = [
step[0].floor() as i32,
step[1].floor() as i32,
step[2].floor() as i32,
];
*path = m.create_path_to(cx.w, p, 0);
return path.is_some();
}
}
false
}
fn same_path(a: Option<&Path>, b: Option<&Path>) -> bool {
match (a, b) {
(None, None) => true,
@@ -710,7 +636,7 @@ impl B {
if !swim_condition(m) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::SetWardenLookTarget(r) => one(r, || {
@@ -732,7 +658,7 @@ impl B {
if !mem(m).look_target.present() {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::MoveToTargetSink {
@@ -755,9 +681,9 @@ impl B {
return false;
};
let reached = reached(m, cx.w, &walk);
if !reached && compute_path(m, cx, &walk, path, speed) {
if !reached && compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach) {
*last_target = walk.target.block(cx.w);
t.begin(cx);
t.begin(cx.lr, cx.now);
// start
mem(m).path.set_opt(path.clone());
m.nav_move_to_path(path.clone(), f64::from(*speed));
@@ -775,7 +701,7 @@ impl B {
if !mm.is_emerging.present() || mm.walk_target.present() {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
warden::set_pose(m, Pose::Emerging);
sound(m, "entity.warden.emerge", 5.0, 1.0, cx.events);
true
@@ -791,7 +717,7 @@ impl B {
if !(m.on_ground || liquid) {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
if m.on_ground {
warden::set_pose(m, Pose::Digging);
sound(m, "entity.warden.dig", 5.0, 1.0, cx.events);
@@ -872,7 +798,7 @@ impl B {
true
}),
B::DoNothing(t) => {
t.begin(cx);
t.begin(cx.lr, cx.now);
true
}
B::GoToDisturbance(r) => one(r, || {
@@ -907,7 +833,7 @@ impl B {
{
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
sound(m, "entity.warden.sniff", 5.0, 1.0, cx.events);
true
}
@@ -919,7 +845,7 @@ impl B {
let Some(target) = mm.roar_target.value() else {
return false;
};
t.begin(cx);
t.begin(cx.lr, cx.now);
let mm = mem(m);
mm.roar_sound_delay.set_ttl((), ROAR_SOUND_DELAY);
mm.walk_target.clear();
@@ -1004,7 +930,7 @@ impl B {
if !close {
return false;
}
t.begin(cx);
t.begin(cx.lr, cx.now);
let mm = mem(m);
mm.attack_cooling_down
.set_ttl(true, i64::from(SONIC_DURATION));
@@ -1092,7 +1018,7 @@ impl B {
&& let Some(walk) = mem(m).walk_target.value()
&& let Some(now_target) = walk.target.block(cx.w)
&& dist_sqr(now_target, last) > 4
&& compute_path(m, cx, &walk, path, speed)
&& compute_path(m, cx.w, cx.now, &walk, path, speed, cant_reach)
{
*last_target = Some(now_target);
mem(m).path.set_opt(path.clone());
+10
View File
@@ -1336,6 +1336,16 @@ impl ParticleEngine {
ParticleType::ExplosionEmitter
};
self.add(&ParticleOptions::simple(ty), center, [1.0, 0.0, 0.0]);
self.explosion_blocks(center, radius, block_count, is_air);
}
/// `ClientExplosionTracker` with `Level.DEFAULT_EXPLOSION_BLOCK_PARTICLES`.
pub fn explosion_blocks(
&mut self,
center: [f64; 3],
radius: f32,
block_count: usize,
is_air: &dyn Fn([i32; 3]) -> bool,
) {
for _ in 0..block_count.min(512) {
let r = &mut self.random;
let d = Vec3::new(
+51 -2
View File
@@ -103,7 +103,10 @@ pub fn shadow(
let v = |wz: f64| (-((wz - pos.z) as f32) / 2.0 / radius + 0.5).clamp(0.0, 1.0);
let a = (alpha * 255.0) as u32;
let color = a << 24 | 0x00FF_FFFF;
let top = f64::from(y) + 0.001;
// ShadowFeatureRenderer: relativeY + shapeBelow.minY, the block top
// itself. A model's feet sit 0.001 above it (LivingEntityRenderer's
// translate(0, -1.501, 0)), so a lifted shadow fights with them.
let top = f64::from(y);
for (wx, wz) in [(bx0, bz0), (bx0, bz1), (bx1, bz1), (bx1, bz0)] {
let p = DVec3::new(wx, top, wz) - camera;
out.push(Vertex {
@@ -113,7 +116,7 @@ pub fn shadow(
sprite.u0 + (sprite.u1 - sprite.u0) * u(wx),
sprite.v0 + (sprite.v1 - sprite.v0) * v(wz),
],
layer: 0,
layer: crate::gfx::SHADOW_OVERLAY,
color,
light: 240 | 240 << 8,
});
@@ -127,6 +130,52 @@ pub fn shadow(
mod tests {
use super::*;
#[test]
fn shadows_lie_on_the_block_top_under_model_feet() {
use crate::chunk::Chunk;
use horizon_world::level::data;
use std::{path::PathBuf, sync::Arc};
let root = std::env::var_os("HORIZON_RESOURCES")
.map(PathBuf::from)
.unwrap_or_else(|| {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION)
});
if !root.join("gameplay-facts.json").exists() {
eprintln!("skipping resource-backed shadow check: {}", root.display());
return;
}
let registry = data::load_registry(&root).unwrap();
let facts = BlockFacts::load(&root, &registry).unwrap();
let stone = registry.default_state("minecraft:stone").unwrap();
let mut world = World::new(facts.air, 0);
world.chunks.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
for x in 0..16 {
for z in 0..16 {
world.set(x, 63, z, stone).unwrap();
}
}
let (camera, pos) = (DVec3::new(8.5, 66.0, 4.0), DVec3::new(8.5, 64.0, 8.5));
let mut out = Vec::new();
let sprite = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
};
shadow(
&world, &facts, sprite, camera, pos, 0.3, 1.0, 0.0, 0, &mut out,
);
assert_eq!(out.len(), 4);
let feet = (pos.y + (1.501 - 24.0 / 16.0) - camera.y) as f32;
for v in &out {
assert_eq!(v.pos[1], (pos.y - camera.y) as f32);
assert!(v.pos[1] < feet);
}
}
#[test]
fn vanilla_radii() {
assert_eq!(shadow_params("minecraft:zombie"), (0.5, 1.0));
assert_eq!(shadow_params("minecraft:item"), (0.15, 0.75));
+2 -2
View File
@@ -17,7 +17,7 @@ pub mod pyjson;
mod sounds;
mod survival;
pub use sounds::{fonts, panorama, sounds};
pub use sounds::{fonts, packs, panorama, sounds};
use pyjson::Value;
use sha2::Digest;
@@ -692,7 +692,7 @@ fn steps(
}
// The JAR holds only gray 1x1 panorama placeholders and an empty Unifont
// include. Import the indexed ones afterwards, including on reruns that just
// include, and none of the built-in packs. Import the indexed ones afterwards, including on reruns that just
// extracted the JAR again. They are optional when preparing from
// already-imported JARs.
sounds::optional_assets(repo, minecraft, run.log);
+8 -2
View File
@@ -19,6 +19,7 @@ Usage: horizon-prepare [--prepare] [--server-jar PATH] [--minecraft-dir PATH] [-
horizon-prepare sounds [--minecraft-dir PATH] [--root PATH]
horizon-prepare panorama [--minecraft-dir PATH] [--root PATH]
horizon-prepare fonts [--minecraft-dir PATH] [--root PATH]
horizon-prepare packs [--minecraft-dir PATH] [--root PATH]
--prepare Import the pinned game files from your own copy first
--server-jar PATH The pinned server jar you downloaded yourself
@@ -34,7 +35,8 @@ The resources go to <root>/.local/horizon-client/<version>; pass that to horizon
horizon-prepare panorama repairs the classic menu background in existing resources without Java.
horizon-prepare fonts adds the GNU Unifont glyphs (characters the bitmap fonts lack) the same way.
Full preparation also imports both after extracting the client JAR's placeholders.
horizon-prepare packs adds the built-in Programmer Art and High Contrast resource packs the same way.
Full preparation also imports all three after extracting the client JAR's placeholders.
horizon-prepare sounds copies the pinned version's sounds (about 380 MB) from the official
launcher's asset store into the same folder, checking every file. It is optional: without it the
@@ -66,7 +68,8 @@ fn main() -> ExitCode {
};
let mut root = None;
let mut args = std::env::args_os().skip(1).peekable();
let assets = args.next_if(|a| a == "sounds" || a == "panorama" || a == "fonts");
let assets =
args.next_if(|a| matches!(a.to_str(), Some("sounds" | "panorama" | "fonts" | "packs")));
while let Some(arg) = args.next() {
let text = arg.to_string_lossy().into_owned();
let (flag, inline) = match text.split_once('=') {
@@ -132,6 +135,9 @@ fn main() -> ExitCode {
Some("fonts") => {
horizon_prepare::fonts(&options.root, options.minecraft_dir.as_deref(), &log)
}
Some("packs") => {
horizon_prepare::packs(&options.root, options.minecraft_dir.as_deref(), &log)
}
_ => horizon_prepare::prepare(&options, &log),
};
match result {
+80 -12
View File
@@ -1,5 +1,5 @@
//! The pinned version's sounds and title panorama from the player's own official
//! launcher install, each object checked against the asset index's SHA-1, copied into
//! The pinned version's sounds, title panorama, Unifont glyphs and built-in resource packs
//! from the player's own official launcher install, each object checked against the asset index's SHA-1, copied into
//! `.local/horizon-client/<version>/assets` where the game reads them (tools/fetch_sounds.py and
//! the tools/lab.py functions it called). Nothing is downloaded.
@@ -124,6 +124,11 @@ pub(crate) fn optional_assets(root: &Path, minecraft: Option<&Path>, log: Log) {
"Unifont glyphs not imported: {error}; run horizon-prepare fonts later"
));
}
if let Err(error) = packs(root, minecraft, log) {
log(&format!(
"Programmer Art and High Contrast not imported: {error}; run horizon-prepare packs later"
));
}
}
const PANORAMA: [&str; 7] = [
@@ -145,6 +150,30 @@ const UNIFONT: [&str; 2] = [
"minecraft/font/unifont.zip",
];
/// Adds the built-in Programmer Art and High Contrast packs, which only the asset index holds
/// (`BuiltInPackSource.populatePackList` lists `minecraft:resourcepacks` of the vanilla pack).
/// Packs made to sit on Programmer Art, like Golden Days, show the default textures without it.
/// Also usable on prepared resources without Java.
pub fn packs(root: &Path, minecraft: Option<&Path>, log: Log) -> Result<PathBuf, String> {
copy_assets(
root,
minecraft,
log,
is_built_in_pack,
"Built-in pack",
&BUILT_IN_PACKS,
)
}
const BUILT_IN_PACKS: [&str; 2] = [
"minecraft/resourcepacks/programmer_art.zip",
"minecraft/resourcepacks/high_contrast.zip",
];
fn is_built_in_pack(name: &str) -> bool {
name.starts_with("minecraft/resourcepacks/")
}
/// The fonts only the asset index holds (the JAR has the bitmap fonts).
fn is_font(name: &str) -> bool {
name.starts_with("minecraft/font/")
@@ -228,11 +257,13 @@ mod tests {
crate::hex(&sha1::Sha1::digest(data))
}
/// Where the fixture's panorama objects start.
const FIRST_FACE: usize = 6;
/// Where the fixture's built-in packs and panorama objects start.
const FIRST_PACK: usize = 6;
const FIRST_FACE: usize = 8;
/// A Horizon folder and an official launcher directory holding version `9.9` with
/// sounds, a language file and the full title panorama in its asset store.
/// sounds, a language file, Unifont, the built-in packs and the full title panorama in its
/// asset store.
struct Fixture {
dir: PathBuf,
root: PathBuf,
@@ -263,6 +294,8 @@ mod tests {
b"{\"providers\": [{\"type\": \"unihex\"}]}".to_vec(),
),
(UNIFONT[1], b"PK unifont".to_vec()),
(BUILT_IN_PACKS[0], b"PK programmer art".to_vec()),
(BUILT_IN_PACKS[1], b"PK high contrast".to_vec()),
];
for &name in panorama {
// Distinct object bytes let the test catch swapped faces too.
@@ -499,15 +532,25 @@ mod tests {
lines[0],
format!("Title panorama not imported: {error}; run horizon-prepare panorama later")
);
// The fonts come on their own when only a panorama face is missing.
// The fonts and packs come on their own when only a panorama face is missing.
match missing {
"last-object" => assert_eq!(lines[1], "Font assets ready: 2 files, 0.0 MB"),
_ => assert!(
lines[1].starts_with("Unifont glyphs not imported: "),
"{lines:?}"
"last-object" => assert_eq!(
lines[1..],
[
"Font assets ready: 2 files, 0.0 MB",
"Built-in pack assets ready: 2 files, 0.0 MB"
]
),
_ => {
assert!(lines[1].starts_with("Unifont glyphs not imported: "));
assert!(
lines[2].starts_with("Programmer Art and High Contrast not imported: ")
&& lines[2].ends_with("; run horizon-prepare packs later"),
"{lines:?}"
);
}
}
assert_eq!(lines.len(), 2);
assert_eq!(lines.len(), 3);
for name in PANORAMA {
assert_eq!(fs::read(target.join(name)).unwrap(), b"jar placeholder");
}
@@ -528,7 +571,7 @@ mod tests {
lines.borrow_mut().push(l.to_string())
});
assert_eq!(done.unwrap(), target);
for (name, data) in &f.objects[4..FIRST_FACE] {
for (name, data) in &f.objects[4..FIRST_PACK] {
assert_eq!(&fs::read(target.join("assets").join(name)).unwrap(), data);
}
assert_eq!(*lines.borrow(), ["Font assets ready: 2 files, 0.0 MB"]);
@@ -536,6 +579,31 @@ mod tests {
assert!(!target.join("assets/minecraft/textures").exists());
}
/// Golden Days is made to sit on Programmer Art, which the client JAR lacks: the asset
/// index has it, where `BuiltInPackSource` lists `minecraft:resourcepacks`.
#[test]
fn packs_add_programmer_art_and_high_contrast_where_the_pack_list_finds_them() {
let f = Fixture::new("packs");
let target = f.root.join(".local/horizon-client/9.9");
let lines = std::cell::RefCell::new(Vec::new());
let done = packs(&f.root, Some(&f.minecraft), &|l| {
lines.borrow_mut().push(l.to_string())
});
assert_eq!(done.unwrap(), target);
for (name, data) in &f.objects[FIRST_PACK..FIRST_FACE] {
assert_eq!(&fs::read(target.join("assets").join(name)).unwrap(), data);
}
assert!(target
.join("assets/minecraft/resourcepacks/programmer_art.zip")
.is_file());
assert_eq!(
*lines.borrow(),
["Built-in pack assets ready: 2 files, 0.0 MB"]
);
assert!(!target.join("assets/minecraft/sounds.json").exists());
assert!(!target.join("assets/minecraft/font").exists());
}
#[test]
fn the_index_must_hold_the_sounds() {
let f = Fixture::new("index");
+26
View File
@@ -32,6 +32,7 @@ pub mod component {
pub const DYED_COLOR: i32 = 47;
pub const MAP_ID: i32 = 48;
pub const MAP_POST_PROCESSING: i32 = 50;
pub const CHARGED_PROJECTILES: i32 = 51;
pub const WRITABLE_BOOK_CONTENT: i32 = 56;
pub const WRITTEN_BOOK_CONTENT: i32 = 57;
}
@@ -75,6 +76,8 @@ pub enum Component {
/// `WritableBookContent`: the pages (raw text).
WritableBook(Vec<String>),
WrittenBook(WrittenBook),
/// `ChargedProjectiles`: a loaded crossbow's ammunition, as templates.
Stacks(Vec<Stack>),
}
/// `Filterable.streamCodec`: the raw value, then an optional filtered one
@@ -125,6 +128,10 @@ impl Component {
resolved: r.bool()?,
})
}
CHARGED_PROJECTILES => {
let n = r.count(1024)?;
Self::Stacks((0..n).map(|_| Template::read(r)).collect::<Result<_>>()?)
}
DYED_COLOR => Self::Int(r.i32()?),
UNBREAKABLE | CREATIVE_SLOT_LOCK => Self::Unit,
ENCHANTMENT_GLINT_OVERRIDE => Self::Bool(r.bool()?),
@@ -283,6 +290,10 @@ impl Component {
w.varint(hidden.len() as i32);
hidden.iter().for_each(|h| w.varint(*h));
}
Self::Stacks(stacks) => {
w.varint(stacks.len() as i32);
stacks.iter().for_each(|s| Template::write(s, w));
}
Self::WritableBook(pages) => {
w.varint(pages.len() as i32);
for page in pages {
@@ -533,6 +544,21 @@ mod tests {
round::<Option<Hashed>, HashedSlot>(None);
}
#[test]
fn charged_projectiles_are_item_stack_templates() {
// A crossbow (id 7) loaded with one arrow (id 9): ChargedProjectiles
// is a list of ItemStackTemplate, the item before the count.
let bytes = [1, 7, 1, 0, 51, 1, 9, 1, 0, 0];
let stack = Slot::read(&mut Reader::new(&bytes)).unwrap().unwrap();
assert_eq!(
stack.component(component::CHARGED_PROJECTILES),
Some(&Component::Stacks(vec![Stack::new(9, 1)]))
);
let mut w = Writer::new();
Slot::write(&Some(stack), &mut w);
assert_eq!(w.data, bytes);
}
#[test]
fn filtered_book_pages_are_read_and_dropped() {
// A writable book with one page "hi" and its filtered "**".
+112
View File
@@ -461,6 +461,15 @@ packet!(OpenScreen { container: i32 => VarInt, menu: i32 => VarInt, title: Tag =
// ClientboundMountScreenOpenPacket: a horse's or llama's inventory, with its
// chest's columns (`getInventoryColumns`).
packet!(MountScreenOpen { container: i32 => VarInt, columns: i32 => VarInt, entity: i32 => Int });
packet!(
/// ClientboundOpenSignEditorPacket: the sign whose side the player edits.
OpenSignEditor { pos: [i32; 3] => Pos, front: bool => SignSide }
);
packet!(
/// ClientboundOpenBookPacket: the hand (InteractionHand, 0 main, 1 off)
/// whose written book the player reads.
OpenBook { hand: i32 => VarInt }
);
packet!(PlayerAbilities { flags: i8 => Byte, flying_speed: f32 => Float, walking_speed: f32 => Float });
/// MessageSignature.Packed: an index into the last-seen cache or a full signature.
pub struct PackedSignature;
@@ -1617,6 +1626,8 @@ packet_enum!(Clientbound, crate::ids::play::clientbound {
MerchantOffers(MerchantOffers) = MERCHANT_OFFERS,
MountScreenOpen(MountScreenOpen) = MOUNT_SCREEN_OPEN,
OpenScreen(OpenScreen) = OPEN_SCREEN,
OpenBook(OpenBook) = OPEN_BOOK,
OpenSignEditor(OpenSignEditor) = OPEN_SIGN_EDITOR,
Ping(Ping) = PING,
PongResponse(crate::packets::status::Ping) = PONG_RESPONSE,
PlayerAbilities(PlayerAbilities) = PLAYER_ABILITIES,
@@ -1917,6 +1928,61 @@ packet!(
/// ServerboundRenameItemPacket: `AnvilMenu.setItemName`.
RenameItem { name: String => Str }
);
packet!(
/// ServerboundSignUpdatePacket: the four lines the player wrote on a side.
SignUpdate { pos: [i32; 3] => Pos, lines: [String; 4] => SignLines, front: bool => SignSide }
);
packet!(
/// ServerboundEditBookPacket: the book's slot (a hotbar slot, or 40 for
/// the off hand), its pages and, to sign it, its title.
EditBook {
slot: i32 => VarInt,
pages: Vec<String> => BookPages,
title: Option<String> => Opt<StrN<32>>,
}
);
/// `stringUtf8(1024)` in `list(100)`: a book's pages.
pub struct BookPages;
impl Codec<Vec<String>> for BookPages {
fn read(r: &mut Reader) -> Result<Vec<String>> {
let n = r.varint()?;
if !(0..=100).contains(&n) {
return Err(Error::new(format!("{n} book pages, at most 100")));
}
(0..n).map(|_| r.string(1024)).collect()
}
fn write(v: &Vec<String>, w: &mut Writer) {
List::<StrN<1024>>::write(v, w);
}
}
/// SignTextSlot.STREAM_CODEC: VarInt 0 for the back, 1 for the front (any
/// other id reads as the back, ByIdMap's ZERO strategy).
pub struct SignSide;
impl Codec<bool> for SignSide {
fn read(r: &mut Reader) -> Result<bool> {
Ok(r.varint()? == 1)
}
fn write(v: &bool, w: &mut Writer) {
w.varint(i32::from(*v));
}
}
/// `stringUtf8(384)` in `fixedSizeList(4)`: four lines, no count.
pub struct SignLines;
impl Codec<[String; 4]> for SignLines {
fn read(r: &mut Reader) -> Result<[String; 4]> {
Ok([
r.string(384)?,
r.string(384)?,
r.string(384)?,
r.string(384)?,
])
}
fn write(v: &[String; 4], w: &mut Writer) {
for line in v {
w.string(line);
}
}
}
packet!(
/// ServerboundSetBeaconPacket: `BeaconMenu.updateEffects`, each power an
/// optional `mob_effect` id.
@@ -2000,6 +2066,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound {
ContainerClick(ContainerClick) = CONTAINER_CLICK,
ContainerClose(ContainerClose) = CONTAINER_CLOSE,
CustomPayload(CustomPayload) = CUSTOM_PAYLOAD,
EditBook(EditBook) = EDIT_BOOK,
Interact(Interact) = INTERACT,
KeepAlive(KeepAlive) = KEEP_ALIVE,
MovePlayerPos(MovePlayerPos) = MOVE_PLAYER_POS,
@@ -2024,6 +2091,7 @@ packet_enum!(Serverbound, crate::ids::play::serverbound {
SetBeacon(SetBeacon) = SET_BEACON,
SetCarriedItem(SetCarriedItem) = SET_CARRIED_ITEM,
SetCreativeModeSlot(SetCreativeModeSlot) = SET_CREATIVE_MODE_SLOT,
SignUpdate(SignUpdate) = SIGN_UPDATE,
UseItem(UseItem) = USE_ITEM,
UseItemOn(UseItemOn) = USE_ITEM_ON,
});
@@ -2748,4 +2816,48 @@ mod tests {
});
round_trip(p, Clientbound::encode, Clientbound::decode);
}
#[test]
fn sign_packets_match_vanilla_stream_codecs() {
// ClientboundOpenSignEditorPacket: BlockPos, then SignTextSlot as a VarInt (FRONT is 1).
let open = Clientbound::OpenSignEditor(OpenSignEditor {
pos: [1, 2, 3],
front: true,
});
let body = open.encode();
assert_eq!(
body[1..9],
crate::buf::pack_block_pos([1, 2, 3]).to_be_bytes()
);
assert_eq!(body[9..], [1]);
round_trip(open, Clientbound::encode, Clientbound::decode);
// ServerboundSignUpdatePacket: BlockPos, four strings with no count, then the slot.
let update = Serverbound::SignUpdate(SignUpdate {
pos: [-4, 70, 9],
lines: ["Hi".into(), String::new(), "x".into(), String::new()],
front: false,
});
let body = update.encode();
assert_eq!(body[9..], [2, b'H', b'i', 0, 1, b'x', 0, 0]);
round_trip(update, Serverbound::encode, Serverbound::decode);
}
#[test]
fn book_packets_match_vanilla_stream_codecs() {
// ClientboundOpenBookPacket: InteractionHand as a VarInt.
let open = Clientbound::OpenBook(OpenBook { hand: 1 });
assert_eq!(open.encode()[1..], [1]);
round_trip(open, Clientbound::encode, Clientbound::decode);
// ServerboundEditBookPacket: the slot, the pages (a counted list), then an optional title.
let sign = Serverbound::EditBook(EditBook {
slot: 40,
pages: vec!["a".into(), String::new()],
title: Some("T".into()),
});
assert_eq!(sign.encode()[1..], [40, 2, 1, b'a', 0, 1, 1, b'T']);
round_trip(sign, Serverbound::encode, Serverbound::decode);
let mut w = Writer::new();
w.varint(101);
assert!(BookPages::read(&mut Reader::new(&w.data)).is_err());
}
}
+16
View File
@@ -182,6 +182,22 @@ impl JavaPack {
}
}
impl JavaPack {
/// The block ids of the pack's `block.properties` (empty without one),
/// [`properties::block_ids`].
pub fn block_ids(
&self,
values: &OptionValues,
env: &Environment,
) -> Result<BTreeMap<String, i32>, String> {
match self.files.read("block.properties") {
Some(text) => properties::block_ids(&text, env, &self.options, values)
.map_err(|e| format!("block.properties: {e}")),
None => Ok(BTreeMap::new()),
}
}
}
/// A program's name in `shaders.properties` (`world-1/composite1.fsh` is
/// `world-1/composite1`).
pub fn program_name(program: &str) -> &str {
+243 -1
View File
@@ -12,7 +12,7 @@ use crate::{
options::OptionValues,
preprocess::Environment,
properties::{Blend, LEGACY_TARGETS, ShaderProperties},
translate::{Link, Translated},
translate::{Geometry, Link, Translated},
};
use std::collections::{BTreeMap, BTreeSet};
@@ -327,8 +327,101 @@ pub struct Passes {
/// next frame starts unflipped.
pub flipped_at_end: BTreeSet<u32>,
pub directives: Directives,
/// The program Horizon's solid terrain is drawn with
/// ([`TERRAIN_PROGRAMS`]), before `deferred`.
pub terrain: Option<Pass>,
/// The program its translucent terrain is drawn with
/// ([`WATER_PROGRAMS`]), after `deferred`.
pub water: Option<Pass>,
/// The programs the shadow map's solid and translucent terrain are
/// drawn with ([`SHADOW_PROGRAMS`], [`SHADOW_WATER_PROGRAMS`]), after
/// `begin`. Their draw buffers are `shadowcolor` targets.
pub shadow: Option<Pass>,
pub shadow_water: Option<Pass>,
/// `shadowcolor0..7`: white and cleared each frame unless the pack
/// says otherwise.
pub shadow_targets: Vec<TargetSettings>,
}
/// The programs Iris draws the shadow map's solid terrain with. Horizon's
/// solid layer holds cutout blocks too, so `shadow_cutout` (with its alpha
/// test) comes first.
pub const SHADOW_PROGRAMS: &[&str] = &["shadow_cutout", "shadow", "shadow_solid"];
/// The programs Iris draws the shadow map's translucent terrain with.
pub const SHADOW_WATER_PROGRAMS: &[&str] = &["shadow_water", "shadow"];
/// How many `shadowcolor` targets Iris has.
pub const MAX_SHADOW_COLOR_BUFFERS: u32 = 8;
/// `shadowcolor<N>Format`, `Clear` and `ClearColor` from `fragments`.
pub fn shadow_target_settings<'a>(
fragments: impl IntoIterator<Item = &'a str>,
) -> Vec<TargetSettings> {
let mut targets = vec![
TargetSettings {
clear_color: Some([1.0; 4]),
..TargetSettings::default()
};
MAX_SHADOW_COLOR_BUFFERS as usize
];
let key = |key: &str, suffix: &str| {
let i: u32 = key
.strip_prefix("shadowcolor")?
.strip_suffix(suffix)?
.parse()
.ok()?;
(i < MAX_SHADOW_COLOR_BUFFERS).then_some(i as usize)
};
for fragment in fragments {
for c in const_directives(fragment) {
match c.ty {
"int" => {
if let Some(i) = key(&c.key, "Format") {
let format = c.value.to_uppercase();
if TEXTURE_FORMATS.contains(&format.as_str()) {
targets[i].format = format;
}
}
}
"bool" => {
if let (Some(i), Ok(clear)) = (key(&c.key, "Clear"), c.value.parse()) {
targets[i].clear = clear;
}
}
"vec4" => {
if let (Some(i), Some(color)) =
(key(&c.key, "ClearColor"), parse_vec4(&c.value))
{
targets[i].clear_color = Some(color);
}
}
_ => {}
}
}
}
targets
}
/// The programs Iris draws solid terrain with, the first the pack has
/// (`ProgramId.Terrain`'s fallbacks).
pub const TERRAIN_PROGRAMS: &[&str] = &[
"gbuffers_terrain",
"gbuffers_textured_lit",
"gbuffers_textured",
"gbuffers_basic",
];
/// The programs Iris draws translucent terrain with (`ProgramId.Water`'s
/// fallbacks).
pub const WATER_PROGRAMS: &[&str] = &[
"gbuffers_water",
"gbuffers_terrain",
"gbuffers_textured_lit",
"gbuffers_textured",
"gbuffers_basic",
];
/// The `const` directives of a program folder that aren't about targets
/// (`PackDirectives`), read from the same programs in the same order.
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -343,6 +436,8 @@ pub struct Directives {
pub wetness_half_life: f32,
/// `drynessHalflife`, tenths of a second: `wetness` smoothing when dry.
pub dryness_half_life: f32,
/// The shadow map's directives.
pub shadow: ShadowDirectives,
}
impl Default for Directives {
@@ -352,6 +447,35 @@ impl Default for Directives {
eye_brightness_half_life: 10.0,
wetness_half_life: 600.0,
dryness_half_life: 200.0,
shadow: ShadowDirectives::default(),
}
}
}
/// The shadow map's `const` directives (`PackShadowDirectives`), with
/// Iris's defaults.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ShadowDirectives {
/// `shadowMapResolution`: the shadow targets' width and height.
pub resolution: u32,
/// `shadowDistance`: half the side of the orthographic projection.
pub distance: f32,
/// `shadowNearPlane` and `shadowFarPlane` (`ShadowMatrices.NEAR`/`FAR`).
pub near: f32,
pub far: f32,
/// `shadowIntervalSize`: the light's view snaps to a grid this size as
/// the camera moves, so shadows don't shimmer.
pub interval: f32,
}
impl Default for ShadowDirectives {
fn default() -> Self {
Self {
resolution: 1024,
distance: 160.0,
near: -100.05,
far: 156.0,
interval: 2.0,
}
}
}
@@ -367,6 +491,13 @@ impl Directives {
match (c.ty, c.key.as_str(), value) {
("float", "sunPathRotation", Ok(v)) => out.sun_path_rotation = v,
("float", "eyeBrightnessHalflife", Ok(v)) => out.eye_brightness_half_life = v,
("int", "shadowMapResolution", Ok(v)) if v >= 1.0 => {
out.shadow.resolution = v as u32;
}
("float", "shadowDistance", Ok(v)) => out.shadow.distance = v,
("float", "shadowNearPlane", Ok(v)) => out.shadow.near = v,
("float", "shadowFarPlane", Ok(v)) => out.shadow.far = v,
("float", "shadowIntervalSize", Ok(v)) => out.shadow.interval = v,
("float", "wetnessHalflife", Ok(v)) => out.wetness_half_life = v,
("float", "drynessHalflife", Ok(v)) => out.dryness_half_life = v,
_ => {}
@@ -492,6 +623,7 @@ impl JavaPack {
}
let mut out = Passes {
targets: target_settings(sources.iter().map(String::as_str), properties),
shadow_targets: shadow_target_settings(sources.iter().map(String::as_str)),
directives: Directives::read(sources.iter().map(String::as_str)),
..Passes::default()
};
@@ -534,6 +666,7 @@ impl JavaPack {
};
let mut flipped = BTreeSet::new();
out.begin = run(&groups[0], "begin_pre", &mut flipped)?;
let flipped_after_begin = flipped.clone();
out.prepare = run(&groups[1], "prepare_pre", &mut flipped)?;
out.flipped_after_prepare = flipped.clone();
// Iris's fallbacks for terrain (`ProgramFallbackResolver`).
@@ -554,6 +687,27 @@ impl JavaPack {
out.flipped_after_translucent = flipped.clone();
out.composite = run(&groups[3], "composite_pre", &mut flipped)?;
out.flipped_at_end = flipped;
// Geometry writes the side the next passes read.
let geometry = |chain: &[&str], flipped: &BTreeSet<u32>| {
let Some(name) = chain.iter().find(|n| exists(n)) else {
return Ok::<_, String>(None);
};
let text = fragment(name)?;
let (blend, buffer_blends) = resolve_blends(&path(name));
Ok(Some(Pass {
program: path(name),
draw_buffers: draw_buffers(&text).map_err(|e| format!("{}: {e}", path(name)))?,
reads_alt: flipped.clone(),
mipmapped: BTreeSet::new(),
scale: 1.0,
blend,
buffer_blends,
}))
};
out.terrain = geometry(TERRAIN_PROGRAMS, &out.flipped_after_prepare)?;
out.water = geometry(WATER_PROGRAMS, &out.flipped_after_translucent)?;
out.shadow = geometry(SHADOW_PROGRAMS, &flipped_after_begin)?;
out.shadow_water = geometry(SHADOW_WATER_PROGRAMS, &flipped_after_begin)?;
if exists("final") {
let prog = path("final");
let (blend, buffer_blends) = resolve_blends(&prog);
@@ -614,6 +768,70 @@ impl JavaPack {
}
}
/// A geometry program translated: the program (its vertex with the first
/// glue), and its vertex with each other glue.
pub struct GeometryProgram {
pub program: Program,
pub variants: Vec<Translated>,
}
impl JavaPack {
/// Translate geometry program `pass` (`gbuffers_*`) to run over
/// Horizon's geometry: its bindings in group 2, its attributes from
/// each of `glues` (the vertex program is translated once per glue).
#[allow(clippy::too_many_arguments)]
pub fn compile_geometry(
&self,
pass: &Pass,
targets: &[TargetSettings],
values: &OptionValues,
env: &Environment,
properties: &ShaderProperties,
glues: &[Geometry],
) -> Result<GeometryProgram, String> {
let (first, rest) = glues
.split_first()
.ok_or("a geometry program needs a glue")?;
let mut link = Link {
image_formats: properties.image_formats(),
shared_samplers: shared_samplers(targets),
set: 2,
geometry: Some(first.clone()),
..Link::default()
};
let vertex = self.translate(&format!("{}.vsh", pass.program), values, env, &mut link)?;
let fragment = self.translate(&format!("{}.fsh", pass.program), values, env, &mut link)?;
let mut variants = Vec::new();
for glue in rest {
let mut other = link.clone();
other.geometry = Some(glue.clone());
variants.push(self.translate(
&format!("{}.vsh", pass.program),
values,
env,
&mut other,
)?);
if other.uniforms != link.uniforms || other.bindings != link.bindings {
return Err(format!("{}: its variants bind differently", pass.program));
}
}
let draw_buffers = match &pass.draw_buffers {
Some(buffers) => buffers.clone(),
None => (0..fragment.interface.outputs.keys().max().map_or(1, |m| m + 1)).collect(),
};
Ok(GeometryProgram {
program: Program {
pass: pass.clone(),
vertex,
fragment,
draw_buffers,
link,
},
variants,
})
}
}
/// Iris's sampler states for the textures it binds: colour targets
/// linear (nearest for integer formats, and for 32-bit floats, which wgpu
/// can't filter everywhere), depth nearest, noise repeating. For
@@ -794,6 +1012,30 @@ mod tests {
assert_eq!(d.dryness_half_life, 200.0, "the default");
}
#[test]
fn shadow_directives_and_targets() {
let text = "\
const int shadowMapResolution = 2048;
const float shadowDistance = 128.0;
const float shadowIntervalSize = 3.0;
const bool shadowcolor0Clear = false;
const int shadowcolor1Format = RGBA16;
const vec4 shadowcolor1ClearColor = vec4(0.0, 0.0, 0.0, 0.0);
";
let d = Directives::read([text]);
assert_eq!(d.shadow.resolution, 2048);
assert_eq!(d.shadow.distance, 128.0);
assert_eq!(d.shadow.interval, 3.0);
assert_eq!(d.shadow.near, ShadowDirectives::default().near);
let targets = shadow_target_settings([text]);
assert!(!targets[0].clear);
assert_eq!(targets[1].format, "RGBA16");
assert_eq!(targets[1].clear_color, Some([0.0; 4]));
// Iris clears the others to white.
assert_eq!(targets[2].clear_color, Some([1.0; 4]));
assert!(targets[2].clear);
}
#[test]
fn const_directives_and_target_settings() {
let text = "\
@@ -639,6 +639,58 @@ impl ShaderProperties {
}
}
/// `block.properties` (`block.<id>=<blocks>`), preprocessed as
/// `shaders.properties` is: each block's id by its name without namespace
/// (`oak_leaves`). A block listed with a state (`grass_block:snowy=true`)
/// takes that id only when no entry names it without one; numeric ids
/// (before 1.13) are skipped.
pub fn block_ids(
text: &str,
env: &Environment,
options: &OptionSet,
values: &OptionValues,
) -> Result<BTreeMap<String, i32>, String> {
let properties = Properties::parse(&preprocess(text, env, options, values)?);
let mut plain = BTreeMap::new();
let mut with_state = BTreeMap::new();
for (key, value) in properties.iter() {
let Some(Ok(id)) = key.strip_prefix("block.").map(str::parse::<i32>) else {
continue;
};
for entry in value.split_whitespace() {
let entry = entry.strip_prefix("minecraft:").unwrap_or(entry);
if entry.contains(':') && entry.split(':').nth(1).is_some_and(|s| s.contains('=')) {
let name = entry.split(':').next().unwrap_or(entry);
with_state.entry(name.to_owned()).or_insert(id);
} else if !entry.chars().all(|c| c.is_ascii_digit()) && !entry.contains('=') {
plain.insert(entry.to_owned(), id);
}
}
}
for (name, id) in with_state {
plain.entry(name).or_insert(id);
}
Ok(plain)
}
/// The block a block sprite (`minecraft:block/oak_log_top`) belongs to in
/// `ids` ([`block_ids`]): the block named like it, else the longest one its
/// name starts with followed by `_` (`oak_log`, `water` for `water_still`).
pub fn sprite_block_id(sprite: &str, ids: &BTreeMap<String, i32>) -> Option<i32> {
let name = sprite.rsplit(['/', ':']).next()?;
if let Some(&id) = ids.get(name) {
return Some(id);
}
let mut end = name.len();
while let Some(cut) = name[..end].rfind('_') {
if let Some(&id) = ids.get(&name[..cut]) {
return Some(id);
}
end = cut;
}
None
}
/// Iris's `BooleanParser`: options joined by `!`, `&&`, `||` and brackets,
/// `&&` binding tighter. `true`/`1` and `false`/`0` are literals; a name that
/// isn't a boolean option is true; a malformed expression is true.
@@ -757,6 +809,42 @@ mod tests {
set
}
/// `block.properties` as Chocapic13' writes it, and sprites matched to
/// its blocks.
#[test]
fn block_ids_and_the_sprites_of_their_blocks() {
let text = "\
#if MC_VERSION >= 11300
block.10001= minecraft:wheat minecraft:short_grass
block.10003 = minecraft:oak_leaves \\
minecraft:vine
block.10006 = minecraft:grass_block:snowy=true minecraft:snow
block.8 = minecraft:water
block.2 = minecraft:grass_block
#else
block.10001= 6 31
#endif
layer.translucent=minecraft:glass
";
let env = Environment {
defines: vec![("MC_VERSION".into(), "260300".into())],
};
let ids = block_ids(text, &env, &OptionSet::default(), &OptionValues::default()).unwrap();
assert_eq!(ids.get("vine"), Some(&10003));
// The stateless entry wins over the snowy one.
assert_eq!(ids.get("grass_block"), Some(&2));
assert!(!ids.contains_key("6") && !ids.contains_key("glass"));
for (sprite, id) in [
("minecraft:block/oak_leaves", Some(10003)),
("minecraft:block/water_still", Some(8)),
("minecraft:block/grass_block_top", Some(2)),
("minecraft:block/wheat_stage7", Some(10001)),
("minecraft:block/stone", None),
] {
assert_eq!(sprite_block_id(sprite, &ids), id, "{sprite}");
}
}
#[test]
fn java_properties_syntax() {
let p = Properties::parse(
+142 -16
View File
@@ -45,7 +45,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
/// Locations, bindings and uniforms shared by the programs of one pass: a
/// pass's vertex program is translated first, its fragment program second.
#[derive(Default, Debug)]
#[derive(Clone, Default, Debug)]
pub struct Link {
/// Varying name → location (split matrices and arrays as `name#i`).
pub varyings: BTreeMap<String, u32>,
@@ -64,6 +64,27 @@ pub struct Link {
/// 16). Once any is named, comparison samplers share `iris_compare` and
/// integer ones `iris_nearest`; textures not named keep `{name}_s`.
pub shared_samplers: BTreeMap<String, String>,
/// The bind group of the pack's bindings (0; 2 for geometry programs,
/// whose first groups are Horizon's).
pub set: u32,
/// Set for `gbuffers_*` programs drawn over Horizon's own geometry.
pub geometry: Option<Geometry>,
}
/// How a `gbuffers_*` program runs over Horizon's geometry: its vertex
/// attributes aren't inputs but globals the glue fills from Horizon's
/// vertices before the program's `main`.
#[derive(Clone, Debug, Default)]
pub struct Geometry {
/// GLSL put before the vertex program: Horizon's vertex inputs and
/// buffers, and a `void hz_vertex()` run first in `main`.
pub vertex_glue: String,
/// Each attribute's value as a `vec4` expression of the glue
/// (`iris_Vertex`, `mc_Entity`, `vaUV0`...). Others are zero.
pub attributes: BTreeMap<String, String>,
/// Iris's alpha test: the fragment is discarded unless output 0's
/// alpha is above it.
pub alpha_test: Option<f32>,
}
impl Link {
@@ -385,6 +406,7 @@ pub fn translate(
.parse(&options, &glsl)
.map_err(|e| first_lines(&e.emit_to_string(&glsl)))?;
unpad_private_arrays(&mut module);
end_returning_cases(&mut module);
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -403,6 +425,49 @@ pub fn translate(
})
}
/// A `case` that ends with `return` or `discard` rather than `break`
/// (Photon's displacement) is a fall-through one to naga's GLSL front end,
/// which its WGSL writer refuses: nothing falls through it.
fn end_returning_cases(module: &mut naga::Module) {
fn block(b: &mut naga::Block) {
for s in b.iter_mut() {
match s {
naga::Statement::Block(inner) => block(inner),
naga::Statement::If { accept, reject, .. } => {
block(accept);
block(reject);
}
naga::Statement::Loop {
body, continuing, ..
} => {
block(body);
block(continuing);
}
naga::Statement::Switch { cases, .. } => {
for case in cases {
block(&mut case.body);
if case.fall_through
&& matches!(
case.body.last(),
Some(naga::Statement::Return { .. } | naga::Statement::Kill)
)
{
case.fall_through = false;
}
}
}
_ => {}
}
}
}
for (_, f) in module.functions.iter_mut() {
block(&mut f.body);
}
for e in &mut module.entry_points {
block(&mut e.function.body);
}
}
/// Built-in functions naga folds in `const` initialisers.
const FOLDABLE: &[&str] = &[
"radians",
@@ -634,6 +699,8 @@ struct Legaliser<'a> {
mat2_ctor: bool,
/// Constants turned into globals ([`needs_runtime`]).
runtime_constants: HashSet<String>,
/// Geometry programs: each attribute's type, for the glue.
attribute_types: BTreeMap<String, String>,
}
fn var(name: &str) -> ast::Expr {
@@ -952,6 +1019,7 @@ impl<'a> Legaliser<'a> {
gather: false,
mat2_ctor: false,
runtime_constants: HashSet::new(),
attribute_types: BTreeMap::new(),
}
}
@@ -1043,7 +1111,8 @@ impl<'a> Legaliser<'a> {
show_declaration(&mut text, d, &mut FormattingState::default())
.map_err(|e| e.to_string())?;
self.header.push(format!(
"layout({kind}, set = 0, binding = {binding}) {}",
"layout({kind}, set = {}, binding = {binding}) {}",
self.link.set,
text.trim()
));
Ok(false)
@@ -1099,7 +1168,8 @@ impl<'a> Legaliser<'a> {
for (name, _) in &names {
let t = self.link.binding(&format!("{name}_t"));
self.header.push(format!(
"layout(set = 0, binding = {t}) uniform {texture} {name}_t;"
"layout(set = {}, binding = {t}) uniform {texture} {name}_t;",
self.link.set
));
let shared = &self.link.shared_samplers;
let var = if shared.is_empty() {
@@ -1117,7 +1187,8 @@ impl<'a> Legaliser<'a> {
if self.sampler_vars.insert(var.clone()) {
let s = self.link.binding(&var);
self.header.push(format!(
"layout(set = 0, binding = {s}) uniform {sampler} {var};"
"layout(set = {}, binding = {s}) uniform {sampler} {var};",
self.link.set
));
}
self.interface
@@ -1156,7 +1227,8 @@ impl<'a> Legaliser<'a> {
.unwrap_or_else(|| "rgba8".to_owned());
let binding = self.link.binding(name);
layout.push(format!(
"layout({f}, set = 0, binding = {binding}) {memory}uniform {ty} {name};"
"layout({f}, set = {}, binding = {binding}) {memory}uniform {ty} {name};",
self.link.set
));
self.interface.images.push((name.clone(), f));
}
@@ -1199,10 +1271,8 @@ impl<'a> Legaliser<'a> {
for (i, (name, array)) in names.iter().enumerate() {
let full_array = format!("{ty_array}{array}");
if input {
let at = Link::location(&mut self.link.attributes, name);
self.header.push(format!(
"layout(location = {at}) in {ty} {name}{full_array};"
));
let decl = self.attribute(&ty, name, &full_array);
self.header.push(decl);
self.interface.attributes.insert(name.clone());
} else if !varying {
let at = explicit
@@ -1670,9 +1740,8 @@ impl<'a> Legaliser<'a> {
let ty = LEGACY_ATTRIBUTES.iter().find(|(a, _)| *a == n).unwrap().1;
let renamed = n.replacen("gl_", "iris_", 1);
if self.interface.attributes.insert(renamed.clone()) {
let at = Link::location(&mut self.link.attributes, &renamed);
self.header
.push(format!("layout(location = {at}) in {ty} {renamed};"));
let decl = self.attribute(ty, &renamed, "");
self.header.push(decl);
}
renamed
}
@@ -1694,6 +1763,21 @@ impl<'a> Legaliser<'a> {
}
}
/// A vertex attribute's declaration: an input, or for geometry programs
/// a global the glue fills ([`Geometry`]).
fn attribute(&mut self, ty: &str, name: &str, array: &str) -> String {
match &self.link.geometry {
Some(_) => {
self.attribute_types.insert(name.to_owned(), ty.to_owned());
format!("{ty} {name}{array};")
}
None => {
let at = Link::location(&mut self.link.attributes, name);
format!("layout(location = {at}) in {ty} {name}{array};")
}
}
}
fn frag_data(&mut self, n: u32) -> ast::Expr {
let name = format!("iris_FragData{n}");
if self.interface.outputs.insert(n, name.clone()).is_none() {
@@ -1959,9 +2043,8 @@ impl<'a> Legaliser<'a> {
continue;
}
if attribute && self.stage == Stage::Vertex {
let at = Link::location(&mut self.link.attributes, name);
self.header
.push(format!("layout(location = {at}) in {ty} {name};"));
let decl = self.attribute(ty, name, "");
self.header.push(decl);
self.interface.attributes.insert(name.to_owned());
} else if !attribute {
self.link.uniform(name, format!("{ty} {name}"))?;
@@ -1971,6 +2054,42 @@ impl<'a> Legaliser<'a> {
if !self.errors.is_empty() {
return Err(self.errors.join("\n"));
}
if let Some(geometry) = &self.link.geometry {
match self.stage {
Stage::Vertex => {
// The glue first, then the attributes from it, before
// the global initialisers (which may read them).
let mut first = vec!["hz_vertex();".to_owned()];
for (name, ty) in &self.attribute_types {
first.push(match geometry.attributes.get(name) {
Some(value) => format!("{name} = {ty}({value});"),
None => format!("{name} = {ty}(0);"),
});
}
self.prologue.splice(0..0, first);
}
Stage::Fragment => {
if let (Some(reference), Some(name)) =
(geometry.alpha_test, self.interface.outputs.get(&0))
{
let written = format!("iris_out_{name}");
let name = if self.declared.contains(&written)
|| self
.header
.iter()
.any(|h| h.ends_with(&format!(" {written};")))
{
written
} else {
name.clone()
};
self.epilogue
.push(format!("if (!({name}.a > {reference:?})) discard;"));
}
}
_ => {}
}
}
if self.stage == Stage::Vertex {
// GL's clip space to wgpu's: y flipped, so texture row 0 stays
// the bottom of the picture as in GL (texture coordinates and
@@ -1982,6 +2101,10 @@ impl<'a> Legaliser<'a> {
}
let mut out = String::from("#version 450 core\n");
out.push_str(HELPERS);
if let (Some(geometry), Stage::Vertex) = (&self.link.geometry, self.stage) {
out.push_str(&geometry.vertex_glue);
out.push('\n');
}
if self.modulo {
out.push_str(&modulo_helpers());
}
@@ -1995,7 +2118,10 @@ impl<'a> Legaliser<'a> {
out.push_str(MAT2_HELPERS);
}
if !self.link.uniforms.is_empty() {
out.push_str("layout(std140, set = 0, binding = 0) uniform IrisUniforms {\n");
out.push_str(&format!(
"layout(std140, set = {}, binding = 0) uniform IrisUniforms {{\n",
self.link.set
));
for (_, decl) in &self.link.uniforms {
out.push_str(&format!(" {decl};\n"));
}
+90 -1
View File
@@ -7,7 +7,7 @@ use horizon_shaderpack::{
include::Inlined,
preprocess::{Environment, preprocess},
program_name,
translate::{Link, Translated, translate},
translate::{Geometry, Link, Translated, translate},
};
use std::collections::{BTreeMap, BTreeSet};
@@ -440,6 +440,95 @@ void main() {{
);
}
/// A `gbuffers_*` program over Horizon's geometry: its attributes are
/// globals the glue fills before `main`, its bindings in group 2, and its
/// fragment discards below Iris's alpha test. A `case` ending in `return`
/// (Photon's displacement) isn't a fall-through for naga.
#[test]
fn geometry_programs_take_their_attributes_from_the_glue() {
let glue = Geometry {
vertex_glue: "layout(location = 0) in vec3 hz_position;
layout(std430, set = 3, binding = 0) readonly buffer HzTable { int hz_table[]; };
vec4 hz_pos;
void hz_vertex() { hz_pos = vec4(hz_position, 1.0) + float(hz_table[0]); }"
.into(),
attributes: [
("iris_Vertex".to_owned(), "hz_pos".to_owned()),
(
"mc_Entity".to_owned(),
"vec4(8.0, -1.0, 0.0, 0.0)".to_owned(),
),
]
.into(),
alpha_test: Some(0.1),
};
let mut link = Link {
set: 2,
geometry: Some(glue),
..Link::default()
};
let vs = run(
Stage::Vertex,
"uniform sampler2D noisetex;
attribute vec4 mc_Entity;
attribute vec2 mc_midTexCoord;
varying vec2 uv;
vec3 sway(vec3 p, int id) {
switch (id) {
case 8:
return p + 1.0;
case 9:
case 10:
return p - 1.0;
default:
return p;
}
}
void main() {
uv = gl_MultiTexCoord0.xy + mc_midTexCoord;
gl_Position = gl_ModelViewProjectionMatrix * vec4(sway(gl_Vertex.xyz, int(mc_Entity.x)), 1.0);
}
",
&mut link,
)
.unwrap();
for wanted in [
"hz_vertex();",
"mc_Entity = vec4(vec4(8.0, -1.0, 0.0, 0.0));",
"iris_Vertex = vec4(hz_pos);",
"mc_midTexCoord = vec2(0);",
"set = 2, binding = 0) uniform IrisUniforms",
] {
assert!(vs.glsl.contains(wanted), "{wanted}\n{}", vs.glsl);
}
// Horizon's vertex input is the only one.
assert_eq!(
io(&vs, false).into_keys().collect::<Vec<_>>(),
["hz_position"]
);
reparse(&vs.wgsl).unwrap();
let fs = run(
Stage::Fragment,
"uniform sampler2D gtexture;
varying vec2 uv;
void main() {
/* DRAWBUFFERS:02 */
gl_FragData[0] = texture2D(gtexture, uv);
gl_FragData[1] = vec4(1.0);
}
",
&mut link,
)
.unwrap();
assert!(
fs.glsl.contains("if (!(iris_FragData0.a > 0.1)) discard;"),
"{}",
fs.glsl
);
assert!(fs.glsl.contains("set = 2, binding = "), "{}", fs.glsl);
reparse(&fs.wgsl).unwrap();
}
/// A fragment output narrower than its four-channel target (Steadfast's
/// `final` writes `out vec3 color`) is written through a `vec4` with alpha 1:
/// GL fills the missing channels, wgpu refuses the pipeline.
@@ -266,8 +266,8 @@ fn is_virtual(block: usize) -> bool {
VIRTUAL.contains(&block)
}
/// `getSideMask`.
fn side_mask(west: bool, east: bool, north: bool, south: bool) -> i32 {
/// `getSideMask` (LeavesFix has the same copy).
pub(super) fn side_mask(west: bool, east: bool, north: bool, south: bool) -> i32 {
let mut s = 0;
if north {
if east {
@@ -6,6 +6,7 @@
//! emulated here (`IntOpenHashSet` and the key order of an `Int2ObjectOpenHashMap`).
use std::collections::{HashMap, HashSet};
use super::chunk_flatten::side_mask;
use super::dynamic::{as_int, as_long_stream, as_str, ceillog2, set};
use crate::nbt::Tag;
@@ -355,32 +356,6 @@ fn get_index(x: i32, y: i32, z: i32) -> i32 {
y << 8 | z << 4 | x
}
fn side_mask(west: bool, east: bool, north: bool, south: bool) -> i32 {
let mut s = 0;
if north {
if east {
s |= 2;
} else if west {
s |= 128;
} else {
s |= 1;
}
} else if south {
if west {
s |= 32;
} else if east {
s |= 8;
} else {
s |= 16;
}
} else if east {
s |= 4;
} else if west {
s |= 64;
}
s
}
/// `LeavesFix` over a chunk (`{Level: ...}`).
pub(super) fn leaves_fix(chunk: &mut Tag) {
let Some(level) = chunk.get_mut("Level") else {
+2 -2
View File
@@ -53,7 +53,7 @@ Start the client.
- `--world`: Open or create this world (a folder under saves) at once
- `--creative`: Creative mode for a new world
- `--join`: Join a friend's world with its invite code
- `--arg`: An extra argument for the game, passed through as-is (repeatable), for example --server-thread
- `--arg`: An extra argument for the game, passed through as-is (repeatable), for example --in-frame
MCP: `play`, then the `game_*` tools.
@@ -143,7 +143,7 @@ MCP: `settings`.
## `horizon-launcher game-args`
Show or set the extra arguments the game starts with (for example --server-thread); quotes group a word with spaces in it.
Show or set the extra arguments the game starts with (for example --in-frame); quotes group a word with spaces in it.
horizon-launcher game-args [ARGS]
+2 -1
View File
@@ -7,7 +7,8 @@ screen (the same screen as vanilla's) or in game, and every loader reloads from
## How it works
`packs.rs` finds the packs (`FolderRepositorySource`: folders and zips, plus the built-in Programmer Art and High
Contrast), reads `pack.mcmeta` (format range, overlays, filters) and builds one overlay directory that layers the
Contrast, which only the asset index holds: preparation copies them, `horizon-prepare packs` repairs older resources),
reads `pack.mcmeta` (format range, overlays, filters) and builds one overlay directory that layers the
selected packs over the prepared vanilla resources, later packs winning. Files vanilla reads as a resource *stack*
(language files, `sounds.json`, font definitions, atlas definitions) are merged layer by layer as vanilla merges them.
Every loader then reads that one tree; `game/packs.rs` (`reload_packs`) reloads all of them in game, as
+3 -4
View File
@@ -26,10 +26,9 @@ the other dimensions' levels as `Parked` values and swaps one into place (`enter
The packets of a guest are read in its own level's pass (`Hub::tick_level`), so its actions and
commands run with its level in place.
The window's own game (single player's default, `--no-server-thread`, `HORIZON_SERVER_THREAD=0`, and the
command harness) keeps one level, which follows its player (`enter_dimension`). LAN guests of a world run
that way stay in the host's dimension. It is the default again only until the server thread reaches parity
(`--server-thread` or `HORIZON_SERVER_THREAD=1` opts in), so it gets no multi-level work.
The window's own game (`--in-frame`, `--no-server-thread`, `HORIZON_SERVER_THREAD=0`, and the command
harness) keeps one level, which follows its player (`enter_dimension`). LAN guests of a world run that way
stay in the host's dimension. That mode is the escape hatch, so it gets no multi-level work.
## One code path for both
+67 -18
View File
@@ -24,7 +24,9 @@ Both are off by default. With no pack selected the renderer draws exactly what i
| Java shader packs: full-screen pass plan (`horizon_shaderpack::pipeline`: `DRAWBUFFERS`/`RENDERTARGETS`, `colortexN` formats and clearing, buffer flips, `blend.*`) | done: planned for the three test packs |
| Java shader packs: full-screen passes drawn (`java_pack.rs`, `gfx/java_stages.rs`: `begin`, `prepare`, `deferred*`, `composite*`, `final` over Horizon's world) | first slice: BSL and Complementary run (below); `depthtex1`/`depthtex2` before translucent geometry, `blend.*`, PNG `texture.<stage>.*` and `texture.noise` |
| Java shader packs: custom uniforms (`horizon_shaderpack::uniforms`), `sunPathRotation`, `clouds`/`sun`/`moon`, eye light, `wetness` | done for the full-screen passes (below) |
| Java shader packs: `gbuffers_*`, `shadow`, `.dat` and resource-location textures, images, compute | planned |
| Java shader packs: `gbuffers_terrain`, `gbuffers_water` (`gfx/java_terrain.rs`: Horizon's terrain fed to the pack's programs) | first slice: MakeUp Ultra Fast, Chocapic13' and BSL draw their own terrain and water (below) |
| Java shader packs: `shadow` programs (`gfx/java_stages.rs`: the shadow map from Horizon's terrain) | first slice: terrain shadows for MakeUp Ultra Fast, Chocapic13' and BSL (below) |
| Java shader packs: other `gbuffers_*`, entity shadows, `shadowcomp`, `.dat` and resource-location textures, images, compute | planned |
## How the renderer draws a frame today
@@ -396,6 +398,49 @@ Picking one translates every planned pass of each dimension folder (`java_pack.r
4. `final` draws into a target that replaces the world's picture, where Horizon packs' `composite` output goes: the
hand, menus and HUD are drawn over it.
When the pack has a `gbuffers_terrain` (or `textured_lit`, `textured`, `basic`, the program Iris falls back to), the
pack draws the terrain itself (`gfx/java_terrain.rs`): Horizon's world pass skips its own solid and translucent
terrain, then
- at step 2, after Horizon's sky, entities and the rest enter `colortex0` and depth, the pack's terrain program draws
every visible section into its `RENDERTARGETS` (the side the next pass reads), tested against that depth; the
depth is then copied into `depthtex0` and `depthtex1`;
- before the first `composite`, as Iris does, `gbuffers_water` (or the terrain program) draws the translucent
layer with the pack's `blend.*` or vanilla's blending; the depth goes into `depthtex0` only.
The pack's vertex program reads Horizon's own terrain vertices (the arena pages, instanced or flat draws): a glue
function written before `main` decodes them into Iris's attributes (`vaPosition`/`gl_Vertex` relative to the camera,
`gl_Color` with vanilla's face shading undone, `gl_MultiTexCoord0/1`, the normal and `at_tangent` from the quad's
four corners, `mc_midTexCoord`, `at_midBlock`), and `mc_Entity.x` is the block's id from the pack's
`block.properties`, matched to the sprite's block by name. `gtexture`/`texture` is a 2D atlas built from Horizon's
sprite arrays (animated sprites copied in each frame), `lightmap` is Horizon's lightmap with Iris's texture matrix,
and fragments under alpha 0.1 are discarded (`alphaTestRef`). `RGBA16` targets are 16-bit unorm where the GPU can
render them (Chocapic13' packs its G-buffer into them; half floats broke it).
Occlusion culling works as in Horizon's world pass where the GPU has it: the pack's terrain program draws the
sections the last test saw, the boxes of the others are tested against that depth (in OpenGL's layout, in a pass of
their own since the pack's targets vary; on Metal the test reads the depth in the shader, as Horizon's does), and the
pack's program then draws the ones in sight, flat draws included.
When the pack has a `shadow` program (`shadow_cutout`, `shadow` or `shadow_solid` for the solid layer, which holds
cutout blocks; `shadow_water` or `shadow` for the translucent one), it draws a shadow map after `begin*`, as Iris's
`ShadowRenderer`:
- the light's matrices are Iris's `ShadowMatrices`: `shadowModelView` turns camera-relative positions toward the sun
(the moon at night) with the pack's `sunPathRotation`, snapped to `shadowIntervalSize`; `shadowProjection` is
orthographic, `shadowDistance` each way, between `shadowNearPlane` and `shadowFarPlane` (Iris's defaults when the
pack sets none); the shadow programs get them as `gl_ModelViewMatrix` and `gl_ProjectionMatrix`;
- the renderer draws every section inside that box whose shadow can reach the camera's view (the box swept away
from the light against the view's planes, as Iris's advanced shadow culling), from its own list (cave culling is
off as in Iris, and no face is culled), solid then translucent, instanced draws only;
- `shadowtex0` holds all of it, `shadowtex1` the depth before translucent terrain; both are bound as depth textures
for `sampler2DShadow` (linear comparison, Iris's hardware filtering) and as float copies for `sampler2D` reads;
- `shadowcolor0..7` are `shadowMapResolution` square in their `shadowcolor<N>Format`, cleared each frame to
`shadowcolor<N>ClearColor` (white by default) unless `shadowcolor<N>Clear` is false.
A shadow program that doesn't translate or that wgpu refuses leaves the pack without its shadow map, with a warning:
the terrain programs still run and every `shadowtex` reads as far (nothing in shadow).
What makes Horizon look like OpenGL to the pack:
- **Layout.** The translator ends every vertex shader by flipping `gl_Position.y` and moving depth from `[-w, w]` to
@@ -426,12 +471,16 @@ What makes Horizon look like OpenGL to the pack:
Measured on the Mac mini (M4 Pro) with the packs' default options: BSL v10.1.8 and Complementary Reimagined r5.9.3
grade and light Horizon's world as their post-processing would. Sildur's Vibrant v2.02 draws only its sky: its
`deferred` reads the albedo `gbuffers_*` write to `colortex4`. Without `gbuffers_*` and `shadow`, no pack has its
shadows, water or sky look yet; Distant Horizons' and Voxy's depth read as far. Horizon's world goes into the target
the pack's `gbuffers_terrain` (or the program Iris falls back to) draws to first: `colortex0` for most packs,
`colortex1` for MakeUp. Not supported yet: images and compute (`.csh`; a pack that needs them doesn't load),
mipmapped targets (`colortexNMipmapEnabled` is read but ignored), `.dat` and resource-location custom textures (PNG
files in the pack only); shadow matrices are identity.
`deferred` reads the albedo `gbuffers_*` write to `colortex4` (not checked again since the terrain programs run).
Without the other `gbuffers_*`, no pack has its own sky yet, and only terrain casts shadows; Distant Horizons' and
Voxy's depth read as far. Horizon's world goes into the target the pack's `gbuffers_terrain` (or the program Iris falls
back to) draws to first: `colortex0` for most packs, `colortex1` for MakeUp. Not supported yet: images and compute
(`.csh`; a pack that needs them doesn't load), mipmapped targets (`colortexNMipmapEnabled` is read but ignored), `.dat`
and resource-location custom textures (PNG files in the pack only), `shadowcomp` passes, `shadowMapFov` (a perspective
shadow map), `shadow.culling` and `shadowDistanceRenderMul` (the box above is the only bound). With the pack's terrain:
entities, the sky, the hand, weather and clouds are still Horizon's (in that first target only), block ids come from
sprite names rather than block states, distant (LOD) terrain doesn't repeat its textures, and only the overworld has
been checked.
### Compatibility
@@ -439,19 +488,19 @@ Tested on 2026-10-22 on the Mac mini (M4 Pro, Metal) with each pack's default op
the authors' pages (dev-only test inputs, kept outside the repository). "Loads" means every pass the pack enables for
the overworld, Nether and End is preprocessed, translated and validated, as the game does when the pack is picked
(`java_pack::tests::real_packs_load` with `HORIZON_TEST_SHADERPACKS`); "drawn" means it was also picked in the game
and screenshotted. Only full-screen passes run (above), so no pack has its own terrain, water, shadows or sky yet.
The MakeUp, Chocapic13' and Photon rows are from 2026-10-23, on Metal and on Vulkan (RTX 4080).
and screenshotted. Full-screen passes, `gbuffers_terrain`, `gbuffers_water` and the terrain's shadow map run
(above); no pack has its own entities or sky yet.
| Pack | Version | Result | First thing that stops it |
| --- | --- | --- | --- |
| BSL | v10.1.8 | drawn: 26 passes | |
| BSL | v10.1.8 | drawn: 26 passes and its terrain and water programs | |
| Complementary Reimagined | r5.9.3 | drawn: 27 passes | |
| Complementary Unbound | r5.8 | loads: 27 passes | |
| Sildur's Vibrant Extreme | 2.02 | drawn: 18 passes, only its sky (above) | |
| Steadfast | 0.8.0 | drawn: 3 passes | |
| MakeUp Ultra Fast | 9.6 | drawn: 6 passes, its fog, light shafts and grade over Horizon's world | |
| Chocapic13' | V9 High | drawn: 16 passes; its default TAA upscaling shrinks the world in its `gbuffers_*` vertex programs, which don't run, so the picture is magnified 1/0.7: turn TAA Upscaling off in its settings | |
| Photon | v1.4 | loads: 27 passes; draws black: it lights the scene from the G-buffer its `gbuffers_*` write | |
| Chocapic13' | V9 High | drawn: 16 passes and its terrain and water programs | |
| MakeUp Ultra Fast | 9.6 | drawn: 6 passes and its terrain and water programs | |
| Photon | v1.4 | loads: 27 passes and its terrain and water programs; draws black | its sky and light come from a compute pass and `.dat` look-up tables, not loaded yet |
| Bliss | 2.1.2 | fails | `world0/composite1.fsh`: a vector component passed to an `inout` parameter (WGSL can't point at one) |
| Bloop | 1.8.0-Alpha-3 | fails | `world0/prepare1.fsh`: `mat2(vec4)` and other constructors naga's GLSL front end refuses |
| Noble | v1.9.9 | fails (Apple and NVIDIA) | `world0/deferred4.vsh`: a `vec3[9][9]` varying (nested arrays between stages) |
@@ -492,11 +541,11 @@ What the translator does for these packs, as Iris (JCPP and its GLSL transformer
3. **Full-screen programs**: `deferred*`, `composite*`, `final` with `colortex0..15` (formats from the pack),
`depthtex0..2`, `noisetex` and the uniforms (most are already computed for the world shaders). This alone runs
Sildur's and BSL post-processing over Horizon's own world.
4. **`gbuffers_terrain`, `gbuffers_water`, `gbuffers_sky*`**: Horizon's terrain vertices extended with Iris's
attributes (`mc_Entity` from `block.properties`, `mc_midTexCoord`, `at_tangent`, lightmap), a second set of
terrain pipelines writing the pack's render targets.
4. **`gbuffers_terrain`, `gbuffers_water`** (done, above), **`gbuffers_sky*`**: Horizon's terrain vertices decoded
into Iris's attributes (`mc_Entity` from `block.properties`, `mc_midTexCoord`, `at_tangent`, lightmap), a second
set of terrain pipelines writing the pack's render targets.
5. **Entities, block entities, hand, weather, clouds, particles** `gbuffers_*`.
6. **Shadow pass**: terrain and entities from the sun into `shadowtex0/1` and `shadowcolor0/1`, culled from the
light's view, with the pack's distortion.
6. **Shadow pass** (terrain done, above): entities and block entities from the sun into `shadowtex0/1` and
`shadowcolor0/1`, culled from the light's view, with the pack's distortion; `shadowcomp`.
7. **The rest**: custom textures, compute programs and images, per-dimension
folders, `entity.properties`/`item.properties`.
+726 -2
View File
@@ -189,6 +189,39 @@ address. The script then checks, from both sides, what a guest's game used to ge
- totem: the host and the guest each take a lethal hit holding a totem of undying in the off
hand, then the main hand: the server uses it up and keeps them alive at 1 health with its
effects, and each side hears the totem once (entity event 35) (#1292).
- keepinv: the host and the guest each die holding diamonds and wearing a helmet: with keep_inventory
off both drop where they died and the player respawns without them; with it on nothing
drops and the player keeps them (#1284).
- signs: the host and the guest each place an oak sign: the editor opens on their own screen
(ClientboundOpenSignEditorPacket), the two lines they write reach the server
(ServerboundSignUpdatePacket) and both sides show them (#1380).
- books: the host and the guest each write in a book and quill on their own screen, sign it
(ServerboundEditBookPacket), read the written book the server gave them
(ClientboundOpenBookPacket), then drop it: the other side sees its title, author and
page (#1366).
- elytra: the host and the guest, high up with an Elytra on, jump in the air and glide
(ServerboundPlayerCommandPacket START_FALL_FLYING): both their client and the server
have them gliding, they go forward far more than down, and the glide ends on both once
they land (#1288).
- helduse: the host and the guest raise a shield, charge and fire a crossbow, throw a trident
and scope with a spyglass: each use starts on their own client and on the server, and
letting go ends it on both (RELEASE_USE_ITEM), so the shield lowers, the trident flies
and the loaded crossbow fires on the next use (#1300, #1281, #1282, #1301).
- wind: the host and the guest each throw a wind charge at their feet: the server's burst
launches them about six blocks up (the push in ClientboundExplodePacket) and they land
unhurt (#1386).
- fishing: the host and the guest each cast a fishing rod: the hook stays out on the server and
both sides draw it and it reels back in from the ground, and in open water a fish
bites and reeling in catches it (#1348).
- maps: the host and the guest each make a map and hold it: their client draws the terrain
around them and their marker, from the server's map data (#1382).
- body: the host and the guest each walk while drawing a bow (slowed), stand on powder snow
with leather boots (held up) and without (sinking), and sprint-swim looking down
(diving): their own client decides it (#1278, #1296, #1303).
- strikes: a bolt strikes the host and the guest standing in water and hurts them, and a cave
spider's bite poisons them (#1480, #1484).
- cursor: the host and the guest each close their inventory with a stack on the cursor: it
goes back into their inventory, none is thrown and the cursor is empty (#1283).
Each check is reported on its own (a failing one doesn't stop the others) and the screenshots of
both sides go to --out (host-*.png, guest-*.png).
@@ -4517,8 +4550,699 @@ def check_totem(host, guest, out):
return seen
CHECKS += ['totem']
CHECK_FNS.update(totem=check_totem)
def check_keep_inventory(host, guest, out):
"""ServerPlayer.die's dropAllDeathLoot for each player (Player.dropEquipment): with keep_inventory
off, the diamonds and the helmet a player dies with lie where it died and it respawns with
neither; with it on, nothing drops and it respawns with both (#1284)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
host('command', command=f'setworldspawn {hx} {hy} {hz}')
host('command', command='gamerule respawn_radius 0')
try:
for keep in ('false', 'true'):
host('command', command=f'gamerule keep_inventory {keep}')
for game, dz in ((host, -3), (guest, 3)):
# 14 blocks from the world spawn: nothing dropped is picked up on respawning.
x, z = hx + 14, hz + dz
host('command', command=f'fill {x - 2} {hy - 1} {z - 1} {x + 2} {hy - 1} {z + 1} minecraft:stone')
host('command', command=f'fill {x - 2} {hy} {z - 1} {x + 2} {hy + 2} {z + 1} minecraft:air')
host('command', command='kill @e[type=minecraft:item]')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='give @s minecraft:diamond 7')
game('command', command='item replace entity @s armor.head with minecraft:iron_helmet')
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5}')
game.wait(20)
game('command', command='kill @s')
game.wait(30)
dropped = sorted((e['item']['item'], e['item']['count'])
for e in game.raw('entities', 8, 'minecraft:item')['entities'])
game.shot(out, f'keepinv-{keep}')
respawn_when_ready(game)
game.wait(40)
back_to_game(game)
seen[f'{game.name}-keep-{keep}'] = {
'dropped': dropped,
'kept': {item: server_count(game, item) for item in ('diamond', 'iron_helmet')}}
finally:
host('command', command='gamerule keep_inventory false')
host('command', command='gamerule respawn_radius 10')
host('command', command='kill @e[type=minecraft:item]')
for game in (host, guest):
game('command', command='clear @s')
face_each_other(host, guest)
for case, s in seen.items():
want = ({'dropped': [], 'kept': {'diamond': 7, 'iron_helmet': 1}} if case.endswith('true') else
{'dropped': [('minecraft:diamond', 7), ('minecraft:iron_helmet', 1)],
'kept': {'diamond': 0, 'iron_helmet': 0}})
assert s == want, f'{case}: {seen}'
return seen
def check_signs(host, guest, out):
"""SignBlock.openTextEdit on the placing player's client, for the host and the guest: placing
an oak sign opens its editor there and nowhere else, the lines typed into it go to the server
when it closes, and both sides then show them (#1380)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
for game, other, dz in ((host, guest, -2), (guest, host, 2)):
spot = (hx + 2, hy, hz + dz)
game('command', command=f'setblock {spot[0]} {spot[1] - 1} {spot[2]} minecraft:stone')
game('command', command=f'setblock {spot[0]} {spot[1]} {spot[2]} minecraft:air')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='give @s minecraft:oak_sign 1')
game.wait(10)
game.raw('place', *spot, '--item', 'minecraft:oak_sign')
game.wait(10)
opened = {g.name: g('state').get('screen') for g in (game, other)}
game.shot(out, 'signs-editor')
lines = [f'{game.name} wrote', 'line two']
game.raw('type', lines[0])
game.raw('key', 'enter', 'tap')
game.raw('type', lines[1])
game.raw('key', 'esc', 'tap')
game.wait(20)
back_to_game(other)
seen[game.name] = {'opened': opened,
'shown': {g.name: (g.raw('block', *spot).get('sign') or {}).get('front')
for g in (game, other)}}
other.shot(out, 'signs-shown')
for name, s in seen.items():
other = 'guest' if name == 'host' else 'host'
want = [f'{name} wrote', 'line two', '', '']
assert s['opened'] == {name: 'sign', other: 'playing'}, f'{name}: {seen}'
assert s['shown'] == {name: want, other: want}, f'{name}: {seen}'
return seen
def book_screen(game):
"""The open book (`book`'s page and page count), once `game` shows it."""
game.raw('wait-for', 'screen', 'book', '--timeout', 10)
return game.raw('book')
def check_books(host, guest, out):
"""Books in hand for the host and the guest: a book and quill opens its editor on the player's
own client (LocalPlayer.openItemGui), what it writes and signs reaches the server
(ServerboundEditBookPacket), and the written book opens on the player's client when the server
says so (ServerPlayer.openItemGui's ClientboundOpenBookPacket); dropped, the other side sees the
server's book (#1366)."""
seen = {}
for game, other in ((host, guest), (guest, host)):
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='item replace entity @s weapon.mainhand with minecraft:writable_book')
x, y, z = pos_of(game)
game.raw('look-at', f'{x + 0.5:.2f}', f'{y + 30:.2f}', f'{z:.2f}')
game.wait(10)
case = seen[game.name] = {}
game.raw('use-item')
case['edit'] = book_screen(game)
case['other_screen'] = other('state').get('screen')
game.raw('type', f'{game.name} page one')
game.shot(out, 'books-edit')
game.raw('book', 'sign')
game.raw('type', f'{game.name} book')
game.raw('book', 'sign')
game.wait(20)
case['signed'] = {item: server_count(game, item) for item in ('writable_book', 'written_book')}
game.raw('use-item')
case['view'] = book_screen(game)
game.shot(out, 'books-view')
game.raw('book', 'done')
game.wait(5)
game.raw('key', 'q', 'tap')
game.wait(30)
books = [e['item'].get('components', '') for e in other.raw('entities', 16, 'minecraft:item')['entities']
if e['item']['item'] == 'minecraft:written_book']
case['dropped'] = [all(f'{game.name} {t}' in c for t in ('page one', 'book')) for c in books]
host('command', command='kill @e[type=minecraft:item]')
for name, case in seen.items():
assert case['edit'] == {'page': 0, 'pages': 1, 'lectern': None}, f'{name}: {seen}'
assert case['other_screen'] == 'playing', f'{name}: {seen}'
assert case['signed'] == {'writable_book': 0, 'written_book': 1}, f'{name}: {seen}'
assert case['view'] == {'page': 0, 'pages': 1, 'lectern': None}, f'{name}: {seen}'
assert case['dropped'] == [True], f'{name}: {seen}'
return seen
CHECKS += ['totem', 'keepinv', 'signs', 'books']
CHECK_FNS.update(totem=check_totem, keepinv=check_keep_inventory, signs=check_signs, books=check_books)
def check_elytra(host, guest, out):
"""LivingEntity.updateFallFlying for each player: a jump while falling with an Elytra on starts a
glide that the server takes too (Player.tryToStartFallFlying), the glide carries the player
forward rather than down, and landing ends it on both sides (canGlide fails on the ground)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
try:
for game, dx in ((host, -2), (guest, 2)):
x, z = hx + dx, hz
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 1} {hy - 1} {z + 1} minecraft:stone')
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 1} {hy + 3} {z + 1} minecraft:air')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='effect give @s minecraft:resistance 60 4 true')
game('command', command='item replace entity @s armor.chest with minecraft:elytra')
game('command', command=f'tp @s {x + 0.5} {hy + 60} {z + 0.5} 0 0')
game.wait(6)
start = pos_of(game)
game.raw('key', 'space', 'down')
game.wait(1)
game.raw('key', 'space', 'up')
game.wait(30)
end = pos_of(game)
glide = {'client': own(game).get('fall_flying'),
'server': server_player(host, game).get('fall_flying'),
'forward': round(math.hypot(end[0] - start[0], end[2] - start[2]), 1),
'down': round(start[1] - end[1], 1)}
game.shot(out, 'elytra')
game('command', command=f'tp @s {x + 0.5} {hy + 1} {z + 0.5}')
game.wait(30)
landed = {'on_ground': own(game).get('on_ground'),
'client': own(game).get('fall_flying'),
'server': server_player(host, game).get('fall_flying')}
seen[game.name] = {'glide': glide, 'landed': landed}
finally:
for game in (host, guest):
game('command', command='clear @s')
game('command', command='effect clear @s')
face_each_other(host, guest)
for name, s in seen.items():
g, l = s['glide'], s['landed']
# Falling alone covers about 25 blocks in those 30 ticks and goes nowhere.
assert g['client'] and g['server'] and g['forward'] > 8 and g['down'] < 20, f'{name} glide: {seen}'
assert l['on_ground'] and not l['client'] and not l['server'], f'{name} landed: {seen}'
return seen
CHECKS += ['elytra']
CHECK_FNS.update(elytra=check_elytra)
def server_using(host, game):
"""The item the server has the player using ({slot, ticks}), or None."""
if game is host and not SERVER_THREAD:
return host('state').get('using')
return server_player(host, game).get('using')
def flying(game, kind):
"""How many of the projectiles this side draws are of `kind` (a trident, an arrow)."""
return sum(kind in q['kind'].lower() for q in game('status')['projectiles'])
def check_held_use(host, guest, out):
"""Items used until the button lets go, for each player: the client starts the use too
(Item.use runs on both sides), so letting go sends RELEASE_USE_ITEM and the server ends it."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
try:
for game, dz in ((host, -3), (guest, 3)):
x, z = hx, hz + dz
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 12} {hy - 1} {z + 1} minecraft:stone')
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 12} {hy + 3} {z + 1} minecraft:air')
host('command', command=f'fill {x + 12} {hy} {z - 1} {x + 12} {hy + 3} {z + 1} minecraft:stone')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
game.wait(10)
s = {}
# ShieldItem: raised while held, lowered once let go.
game('command', command='item replace entity @s weapon.offhand with minecraft:shield')
game.wait(5)
game.raw('mouse', 'right', 'down')
game.wait(10)
s['shield'] = {'held': (bool(game('state').get('using')), bool(server_using(host, game)))}
game.raw('mouse', 'right', 'up')
game.wait(10)
s['shield']['let_go'] = (bool(game('state').get('using')), bool(server_using(host, game)))
game('command', command='item replace entity @s weapon.offhand with minecraft:air')
# SpyglassItem: scoping while held.
game('command', command='item replace entity @s weapon.mainhand with minecraft:spyglass')
game.wait(5)
game.raw('mouse', 'right', 'down')
game.wait(10)
s['spyglass'] = {'held': (bool(game('state').get('using')), bool(server_using(host, game)))}
game.raw('mouse', 'right', 'up')
game.wait(10)
s['spyglass']['let_go'] = (bool(game('state').get('using')), bool(server_using(host, game)))
# TridentItem.releaseUsing after 10 ticks: thrown.
host('command', command='kill @e[type=minecraft:trident]')
game('command', command='item replace entity @s weapon.mainhand with minecraft:trident')
game.wait(5)
# /kill finds no thrown trident (Horizon's selectors miss them): count what's new.
before = flying(game, 'trident')
game.raw('use-item', '--hold', 1.2)
game.wait(10)
s['trident'] = {'kept': server_count(game, 'trident'),
'thrown': flying(game, 'trident') - before}
host('command', command='kill @e[type=minecraft:trident]')
# CrossbowItem: charged by holding (25 ticks), fired by the next use.
host('command', command='kill @e[type=minecraft:arrow]')
game('command', command='clear @s')
game('command', command='item replace entity @s weapon.mainhand with minecraft:crossbow')
game('command', command='give @s minecraft:arrow 16')
game.wait(5)
before = flying(game, 'arrow')
game.raw('use-item', '--hold', 1.6)
game.wait(10)
held = game('state').get('held') or {}
s['crossbow'] = {'loaded': 'charged_projectiles' in str(held.get('components', '')),
'arrows': server_count(game, 'arrow')}
game.raw('use-item')
game.wait(10)
held = game('state').get('held') or {}
s['crossbow'].update(
fired=flying(game, 'arrow') - before,
emptied='charged_projectiles' not in str(held.get('components', '')))
game.shot(out, 'helduse')
seen[game.name] = s
finally:
host('command', command='kill @e[type=minecraft:arrow]')
host('command', command='kill @e[type=minecraft:trident]')
for game in (host, guest):
back_to_game(game)
game.raw('mouse', 'right', 'up')
game('command', command='clear @s')
face_each_other(host, guest)
for name, s in seen.items():
for item in ('shield', 'spyglass'):
assert s[item] == {'held': (True, True), 'let_go': (False, False)}, f'{name} {item}: {seen}'
assert s['trident'] == {'kept': 0, 'thrown': 1}, f'{name} trident: {seen}'
assert s['crossbow'] == {'loaded': True, 'arrows': 15, 'fired': 1, 'emptied': True}, \
f'{name} crossbow: {seen}'
return seen
CHECKS += ['helduse']
CHECK_FNS.update(helduse=check_held_use)
def check_wind(host, guest, out):
"""A wind charge each player throws at its own feet (ServerExplosion with TRIGGER_BLOCK): the
server's burst pushes the thrower through its ClientboundExplodePacket (hitPlayers), which
launches it about six blocks up, and onExplosionHit spares it the fall damage where it lands
(#1386)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
host('command', command='gamerule natural_health_regeneration false')
seen = {}
try:
for game, dz in ((host, -4), (guest, 4)):
x, z = hx, hz + dz
host('command', command=f'fill {x - 2} {hy - 1} {z - 2} {x + 2} {hy - 1} {z + 2} minecraft:stone')
host('command', command=f'fill {x - 2} {hy} {z - 2} {x + 2} {hy + 10} {z + 2} minecraft:air')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='effect clear @s')
game('command', command='effect give @s minecraft:instant_health 1 5 true')
game('command', command='give @s minecraft:wind_charge 2')
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} 0 90')
game.wait(20)
start = pos_of(game)[1]
game.raw('use-item')
peak = start
for _ in range(30):
game.wait(2)
peak = max(peak, pos_of(game)[1])
game.wait(40)
game.shot(out, 'wind')
me = own(game)
seen[game.name] = {'rose': round(peak - start, 1),
'landed': round(me['position'][1] - start, 1),
'health': me['health'],
'server_health': server_player(host, game)['health'],
'charges': server_count(game, 'wind_charge')}
finally:
host('command', command='gamerule natural_health_regeneration true')
for game in (host, guest):
game('command', command='clear @s')
face_each_other(host, guest)
for name, s in seen.items():
# Vanilla rises about 6 blocks from a charge at the feet.
assert 4 < s['rose'] < 8 and s['landed'] == 0, f'{name}: {seen}'
assert s['health'] == 20 and s['server_health'] == 20 and s['charges'] == 1, f'{name}: {seen}'
return seen
CHECKS += ['wind']
CHECK_FNS.update(wind=check_wind)
def bobbers(game):
"""The fishing bobbers this side draws, as (owner) ids: 0 for its own player's."""
return [q.get('owner') for q in game('status')['projectiles'] if q['kind'] == 'minecraft:fishing_bobber']
def check_fishing(host, guest, out):
"""FishingHook for each player, on the server: a cast hook stays out (ticked by the server and
tracked to the clients as a fishing bobber under its owner), lands on the ground and reels back
in; cast into open water with Lure III, a fish bites and reeling in
during the bite catches it for 1 (#1348)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
host('command', command='weather clear')
seen = {}
try:
for game, other, dz in ((host, guest, -5), (guest, host, 5)):
x, z = hx, hz + dz
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 14} {hy - 1} {z + 1} minecraft:stone')
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 14} {hy + 4} {z + 1} minecraft:air')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod')
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
game.wait(10)
# On the ground: the floor four blocks ahead.
game.raw('look-at', f'{x + 4.5:.2f}', f'{hy:.2f}', f'{z + 0.5:.2f}')
game.raw('use-item')
game.wait(40)
s = {'own': bobbers(game), 'other': bobbers(other)}
game.shot(out, 'fishing-cast')
game.raw('use-item')
game.wait(10)
s['reeled'] = {'own': bobbers(game), 'damage': (game('state').get('held') or {}).get('damage')}
# Open water: a pool 7x7 and 3 deep, ahead.
host('command', command=f'fill {x + 3} {hy - 3} {z - 3} {x + 9} {hy - 1} {z + 3} minecraft:water')
game('command', command='item replace entity @s weapon.mainhand with minecraft:fishing_rod[enchantments={lure:3}]')
game.wait(10)
game.raw('look-at', f'{x + 6.5:.2f}', f'{hy - 1:.2f}', f'{z + 0.5:.2f}')
game.raw('use-item')
bit = None
for t in range(0, 900, 5):
game.wait(5)
if (game('status').get('fishing') or {}).get('nibble', 0) > 0:
bit = t
break
game.raw('use-item')
game.wait(40)
counts = game.raw('inventory')['counts']
s['caught'] = {'bit': bit is not None,
'damage': (game('state').get('held') or {}).get('damage'),
'items': sum(counts.values()) - 1}
game.shot(out, 'fishing-caught')
host('command', command=f'fill {x + 3} {hy - 3} {z - 3} {x + 9} {hy - 2} {z + 3} minecraft:stone')
host('command', command=f'fill {x + 3} {hy - 1} {z - 3} {x + 9} {hy - 1} {z + 3} minecraft:stone')
seen[game.name] = s
finally:
host('command', command='kill @e[type=minecraft:item]')
for game in (host, guest):
game('command', command='clear @s')
face_each_other(host, guest)
for name, s in seen.items():
other = guest if name == 'host' else host
assert s['own'] == [0], f'{name}: {seen}'
# The in-frame host draws only its own hook.
if other is guest or SERVER_THREAD:
assert len(s['other']) == 1 and s['other'][0] != 0, f'{name}: {seen}'
# FishingHook.retrieve costs 2 on the ground, but a resting hook's onGround
# alternates every tick on the server (Entity.move with no vertical motion
# clears it, the next tick's gravity sets it again): 2 or nothing.
assert s['reeled']['own'] == [] and s['reeled']['damage'] in (None, 0, 2), f'{name}: {seen}'
assert s['caught']['bit'] and s['caught']['damage'] == 1 and s['caught']['items'] >= 1, f'{name}: {seen}'
return seen
CHECKS += ['fishing']
CHECK_FNS.update(fishing=check_fishing)
def check_maps(host, guest, out):
"""MapItem.update for each player, on the server: a map the player makes and holds reveals the
terrain around them and shows their marker, which reach their client in
ClientboundMapItemDataPacket (#1382)."""
seen = {}
try:
for game in (host, guest):
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='item replace entity @s weapon.mainhand with minecraft:map')
x, y, z = pos_of(game)
game.raw('look-at', f'{x + 5:.2f}', f'{y + 1.6:.2f}', f'{z:.2f}')
game.wait(5)
game.raw('use-item')
# 16 ticks sweep every column once; the marker goes out every 5th tick.
game.wait(60)
maps = game('status').get('maps', [])
game.raw('look-at', f'{x + 1:.2f}', f'{y:.2f}', f'{z:.2f}')
game.wait(5)
game.shot(out, 'maps-held')
seen[game.name] = [{'pixels': m['pixels'], 'decorations': m['decorations']} for m in maps]
finally:
for game in (host, guest):
game('command', command='clear @s')
face_each_other(host, guest)
for name, maps in seen.items():
# Revealed out to the server's view distance, at least a 32-block disk.
assert len(maps) == 1 and maps[0]['pixels'] > 3000, f'{name}: {seen}'
assert maps[0]['decorations'] == ['map/decorations/player'], f'{name}: {seen}'
return seen
CHECKS += ['maps']
CHECK_FNS.update(maps=check_maps)
def check_body(host, guest, out):
"""What each player's own client decides for its body (#1278, #1303, #1296): walking while
drawing a bow is slowed to a fifth (LocalPlayer.modifyInput), sprint-swimming follows the look
down (Player.travel's look angle) and leather boots hold on powder snow
(PowderSnowBlock.canEntityWalkOnPowderSnow)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
def walked(game, x, z, use):
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
game.wait(10)
if use:
game.raw('mouse', 'right', 'down')
game.wait(5)
a = pos_of(game)
game.raw('move', 'forward', '0.8')
game.wait(10)
b = pos_of(game)
game.raw('mouse', 'right', 'up')
game.wait(5)
return round(math.dist((a[0], a[2]), (b[0], b[2])), 2)
try:
for game, dz in ((host, -4), (guest, 4)):
x, z = hx, hz + dz
host('command', command=f'fill {x - 1} {hy - 1} {z - 1} {x + 10} {hy - 1} {z + 1} minecraft:stone')
host('command', command=f'fill {x - 1} {hy} {z - 1} {x + 10} {hy + 3} {z + 1} minecraft:air')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='effect clear @s')
s = {'walk': walked(game, x, z, False)}
game('command', command='item replace entity @s weapon.mainhand with minecraft:bow')
game('command', command='give @s minecraft:arrow 4')
game.wait(5)
s['drawing'] = walked(game, x, z, True)
game('command', command='clear @s')
# Powder snow under the feet: leather boots stand on it, bare feet sink.
host('command', command=f'setblock {x + 3} {hy - 1} {z} minecraft:powder_snow')
for boots in ('leather_boots', 'air'):
game('command', command=f'item replace entity @s armor.feet with minecraft:{boots}')
game('command', command=f'tp @s {x + 3.5} {hy} {z + 0.5} -90 0')
game.wait(30)
s[boots] = round(pos_of(game)[1] - hy, 2)
host('command', command=f'setblock {x + 3} {hy - 1} {z} minecraft:stone')
# Deep water: sprint-swimming while looking down dives.
wx, wz = x, z + 2 * (1 if dz > 0 else -1) * 3
host('command', command=f'fill {wx - 1} {hy - 14} {wz - 2} {wx + 12} {hy + 2} {wz + 2} minecraft:stone')
host('command', command=f'fill {wx} {hy - 13} {wz - 1} {wx + 11} {hy - 1} {wz + 1} minecraft:water')
game.wait(10)
game('command', command=f'tp @s {wx + 1.5} {hy - 6} {wz + 0.5} -90 60')
game.wait(10)
a = pos_of(game)
game.raw('move', 'forward', '1.5', '--sprint')
game.wait(5)
s['dive'] = round(pos_of(game)[1] - a[1], 2)
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5} -90 0')
game.wait(5)
host('command', command=f'fill {wx - 1} {hy - 14} {wz - 2} {wx + 12} {hy + 2} {wz + 2} minecraft:air')
host('command', command=f'fill {wx - 1} {hy - 14} {wz - 2} {wx + 12} {hy - 1} {wz + 2} minecraft:stone')
game.shot(out, 'body')
seen[game.name] = s
finally:
for game in (host, guest):
back_to_game(game)
game.raw('mouse', 'right', 'up')
game('command', command='clear @s')
face_each_other(host, guest)
for name, s in seen.items():
assert s['walk'] > 2.5 and s['drawing'] < s['walk'] * 0.4, f'{name} drawing a bow: {seen}'
assert s['leather_boots'] > -0.01 and s['air'] < -0.5, f'{name} powder snow: {seen}'
assert s['dive'] < -2, f'{name} sprint-swimming down: {seen}'
return seen
CHECKS += ['body']
CHECK_FNS.update(body=check_body)
def check_strikes(host, guest, out):
"""What a mob or the sky does to every player in reach, the server's players too (#1480,
#1484): a bolt striking a player standing in water costs them health (Entity.thunderHit), and
a cave spider's bite poisons them (CaveSpider.doHurtTarget on Normal)."""
hx, hy, hz = (int(v // 1) for v in pos_of(host))
seen = {}
try:
host('command', command='difficulty normal')
host('command', command='time set 18000')
for game, dz in ((host, -5), (guest, 5)):
x, z = hx, hz + dz
host('command', command=f'fill {x - 3} {hy - 1} {z - 3} {x + 3} {hy + 3} {z + 3} minecraft:stone')
host('command', command=f'fill {x - 2} {hy} {z - 2} {x + 2} {hy + 3} {z + 2} minecraft:air')
host('command', command=f'fill {x - 2} {hy} {z - 2} {x + 2} {hy} {z + 2} minecraft:water')
game('command', command='gamemode survival')
game('command', command='clear @s')
game('command', command='effect clear @s')
game('command', command='effect give @s minecraft:instant_health 1 5')
game('command', command=f'tp @s {x + 0.5} {hy} {z + 0.5}')
game.wait(20)
before = game('state')['player'].get('health')
game('command', command=f'summon minecraft:lightning_bolt {x + 0.5} {hy} {z + 0.5}')
game.wait(20)
s = {'bolt': (before, game('state')['player'].get('health'))}
host('command', command=f'summon minecraft:cave_spider {x + 1.5} {hy} {z + 0.5}')
poisoned = False
for _ in range(40):
game.wait(5)
if 'poison' in effect_ids(game):
poisoned = True
break
s['poisoned'] = poisoned
s['bitten'] = game('state')['player'].get('health')
host('command', command='kill @e[type=minecraft:cave_spider]')
game.wait(5)
game.shot(out, 'strikes')
host('command', command=f'fill {x - 3} {hy - 1} {z - 3} {x + 3} {hy + 3} {z + 3} minecraft:air')
host('command', command=f'fill {x - 3} {hy - 1} {z - 3} {x + 3} {hy - 1} {z + 3} minecraft:stone')
seen[game.name] = s
finally:
host('command', command='kill @e[type=minecraft:cave_spider]')
host('command', command='time set day')
for game in (host, guest):
back_to_game(game)
game('command', command='effect clear @s')
game('command', command='effect give @s minecraft:instant_health 1 5')
face_each_other(host, guest)
for name, s in seen.items():
a, b = s['bolt']
assert a is not None and b is not None and a - b >= 3, f'{name} struck by lightning: {seen}'
assert s['poisoned'], f'{name} bitten by a cave spider: {seen}'
return seen
CHECKS += ['strikes']
CHECK_FNS.update(strikes=check_strikes)
def check_cursor(host, guest, out):
"""Closing the inventory with a stack on the cursor puts it back into the player's inventory
(AbstractContainerMenu.removed, placeItemBackInInventory) and empties the cursor, for each
player: nothing is thrown out and no copy stays on the cursor (#1283)."""
seen = {}
try:
for game in (host, guest):
game('command', command='clear @s')
host('command', command='kill @e[type=minecraft:item]')
game('command', command='item replace entity @s hotbar.0 with minecraft:diamond 7')
game.wait(10)
game.raw('open-inventory')
game.raw('wait-for', 'screen', 'inventory', '--timeout', 10)
game.raw('click-slot', 0)
game.wait(5)
held = (game('inventory').get('carried') or {}).get('count')
game.raw('key', 'esc', 'tap')
game.wait(40)
inv = game('inventory')
seen[game.name] = {'held': held, 'carried': inv.get('carried'),
'client': inv['counts'].get('minecraft:diamond', 0),
'server': server_count(game, 'diamond'),
'thrown': len(game.raw('entities', 8, 'minecraft:item')['entities'])}
finally:
for game in (host, guest):
back_to_game(game)
game('command', command='clear @s')
host('command', command='kill @e[type=minecraft:item]')
for name, s in seen.items():
assert s == {'held': 7, 'carried': None, 'client': 7, 'server': 7, 'thrown': 0}, f'{name}: {seen}'
return seen
CHECKS += ['cursor']
CHECK_FNS.update(cursor=check_cursor)
def check_digging(host, guest, out):
"""The server breaks a block a player digs only once that player's client is done
(ServerPlayerGameMode: STOP_DESTROY_BLOCK, or the delayed destroy), never on its own count, so
the player's client always breaks it first, with its particles and sound. The guest holds
attack across a wall of six oak logs with a copper axe and hears each one break; then it digs
stone with a wooden pickaxe and its game stalls for a second halfway: the stone is still there
on both sides when it resumes, and the guest's own crack finishes it."""
gx, gy, gz = (int(v // 1) for v in pos_of(guest))
x, y, z = gx + 3, gy, gz + 4
heard = lambda sound: guest('status')['heard'].get(sound, 0)
at = lambda b: (f'{b[0] + 0.02:.2f}', f'{b[1] + 0.5:.2f}', f'{b[2] + 0.5:.2f}')
guest('command', command='gamemode survival')
guest('command', command='item replace entity @s weapon.mainhand with minecraft:copper_axe')
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 4} {y + 3} {z + 2} minecraft:air')
host('command', command=f'fill {x - 2} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone')
host('command', command=f'fill {x + 2} {y} {z - 1} {x + 2} {y + 1} {z + 1} minecraft:oak_log')
guest('command', command=f'tp @s {x + 0.5} {y} {z + 0.5}')
guest.wait(30)
logs = [(x + 2, y + dy, z + dz) for dz in (-1, 0, 1) for dy in (0, 1)]
before = heard('minecraft:block.wood.break')
guest.raw('look-at', *at(logs[0]))
guest.raw('mouse', 'left', 'down')
left = list(logs)
deadline = time.time() + 20
while left and time.time() < deadline:
if guest.raw('block', *left[0]).get('block') != 'minecraft:oak_log':
left.pop(0)
if left:
guest.raw('look-at', *at(left[0]))
guest.raw('mouse', 'left', 'up')
guest.wait(10)
seen = {'logs_left': left, 'log_breaks_heard': heard('minecraft:block.wood.break') - before}
# Stone with a wooden pickaxe: 23 ticks. The guest stalls for a second after about 8.
stone = (x + 2, y, z)
guest('command', command='item replace entity @s weapon.mainhand with minecraft:wooden_pickaxe')
host('command', command=f'setblock {stone[0]} {stone[1]} {stone[2]} minecraft:stone')
guest.wait(10)
before = heard('minecraft:block.stone.break')
guest.raw('look-at', *at(stone))
guest.raw('mouse', 'left', 'down')
time.sleep(0.4)
os.kill(guest.pid, signal.SIGSTOP)
try:
time.sleep(1.0)
finally:
os.kill(guest.pid, signal.SIGCONT)
resumed = {'guest': guest.raw('block', *stone).get('block'), 'server': host.raw('block', *stone).get('block'),
'mining': guest('state').get('mining')}
# Let go once it breaks: past it the crosshair reaches the floor.
deadline = time.time() + 5
while guest.raw('block', *stone).get('block') == 'minecraft:stone' and time.time() < deadline:
pass
guest.raw('mouse', 'left', 'up')
guest.wait(10)
seen.update(resumed=resumed, stone_after=host.raw('block', *stone).get('block'),
stone_breaks_heard=heard('minecraft:block.stone.break') - before)
guest.shot(out, 'digging')
assert not left and seen['log_breaks_heard'] == 6, f'the server broke logs before the guest did: {seen}'
assert resumed['guest'] == resumed['server'] == 'minecraft:stone', f'the server broke the stone while the guest stalled: {seen}'
assert seen['stone_after'] == 'minecraft:air' and seen['stone_breaks_heard'] == 1, f'the guest did not break the stone: {seen}'
return seen
CHECKS += ['digging']
CHECK_FNS.update(digging=check_digging)
if __name__ == '__main__':