Compare commits

...
Author SHA1 Message Date
Ludovic Marie 643f331f8c Merge pull request #1561 from ludovic111/fix/remove-shader-packs 2026-10-24 17:42:17 +02:00
Ludovic MarieandClaude Opus 5.5 4e7c25bb55 Remove shader packs
A developer of Iris showed that Horizon's shader pack code was written from
Iris's source. This removes it: the horizon-shaderpack crate, Java pack
loading and its render stages, Horizon's own WGSL packs and the example
packs, the Shader Packs screen and its option, the launcher's shader pack
browsing and installing, and the docs about them. Shader packs may come
back later as a clean-room rewrite.

The vanilla renderer and its post-processing (resolution scale, FXAA) are
unchanged. Options files that name a pack load and drop the key; the
launcher reads manifests that list a shader pack, and files in
`shaderpacks/` are never deleted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 16:15:39 +02:00
Ludovic Marie e423e570c9 Merge pull request #1557 from ludovic111/fix/launcher-cmd-q
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 15:52:15 +02:00
Ludovic MarieandClaude Sonnet 5.5 87aa88de54 Launcher: Cmd+Q quits and Cmd+W closes the window on macOS
The launcher had no app menu and no key bindings, so Cmd+Q did nothing.
Add Quit and CloseWindow actions, bind them to cmd-q and cmd-w on macOS,
and set an app menu with "Quit Horizon Launcher" and a Window menu with
"Close Window". Quit shares one shut_down path with closing the window
(stop the servers this launcher started, send telemetry, exit), so a
running game is treated exactly as it is today.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-24 15:45:29 +02:00
Ludovic Marie 535f2d2a04 Merge pull request #1556 from ludovic111/docs/agents-server-thread-default
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 15:09:23 +02:00
Ludovic MarieandClaude Opus 5.5 7eb7791240 AGENTS.md: the server thread is single player's default (#1514), releases ship in batches (#1554)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:08:04 +02:00
Ludovic Marie ca90a20992 Merge pull request #1551 from levzzz5154/fix/vehicles-server-thread
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 14:32:57 +02:00
Ludovic Marie 923368f4df Merge pull request #1553 from ludovic111/cg3/pr1534-fixup 2026-10-24 14:31:42 +02:00
Ludovic Marie 25bdaf5321 Merge pull request #1552 from ludovic111/cg3/pr1502-fixup 2026-10-24 14:30:12 +02:00
Ludovic MarieandClaude Opus 5.5 8a04e18b91 Measure the minecart push with the player standing half in it
Walking through the cart pushes it back once past its middle, so the
check stands the player half into the cart from the north and waits.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 14:23:40 +02:00
Ludovic Marie 5d54f28c42 Merge pull request #1547 from ludovic111/fix/kb1-cooldown-knockback 2026-10-24 14:12:58 +02:00
Ludovic Marie 818d1515c6 Merge pull request #1554 from ludovic111/fix/release-batches
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 13:53:46 +02:00
Ludovic MarieandClaude Opus 5.5 e61e6345bc Releases: in batches of 5 pull requests, or when the oldest has waited 6 hours
One release per merge made about 40 releases in 31 hours, hard for players to follow (asked on Discord).
release-now still releases at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 13:47:19 +02:00
Ludovic Marie 02039fed5b Merge pull request #1539 from ludovic111/fix/launcher-builtin-packs 2026-10-24 13:43:38 +02:00
Ludovic Marie 976956fc3e Merge pull request #1546 from ludovic111/fix/kb1-enchantment-ids 2026-10-24 13:41:22 +02:00
Ludovic Marie dfae12cfe2 Merge pull request #1550 from ludovic111/fix/kb1-knockback-yaw 2026-10-24 13:34:45 +02:00
Ludovic MarieandClaude Opus 5.5 8a8348c93f Merge origin/main into fix/vehicles-server-thread
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 13:29:05 +02:00
Ludovic MarieandClaude Opus 5.5 0a3965a7b1 Check vehicles on both sides of a LAN game
lan_e2e's vehicles check: a creative break whose block is put back stays,
a player stands on a boat, lava breaks a ridden boat and lets the rider
out, a minecart in lava breaks and a TNT one goes off, and walking into a
minecart pushes it, for the host and the guest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 13:28:50 +02:00
Ludovic MarieandClaude Opus 5.5 704931ed14 Keep horizon-ctl getblock on a client's copy, run server ones in the owner's level
On a LAN guest or any remote client, getblock and container read what
that client has again (test_friends waits on a guest's getblock). On the
server thread the block commands run in the owner's level, not whichever
level the server ticked last.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 13:27:50 +02:00
Ludovic MarieandClaude Opus 5.5 a713dbd46d Break vehicles in lava on their first tick, as vanilla does
AbstractBoat.tick runs applyEffectsFromBlocks twice, and a minecart takes
lavaHurt from its behaviour's applyEffectsFromBlocks and again from its
own isInLava check: two 40-point hurts a tick, so lava breaks either on
its first tick inside (a TNT minecart primes instead). A minecart has no
baseTick and a boat never outlives lava, so the fire countdown is gone.

Minecart pushing takes the player's own box and returns at once when
there are no carts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 13:27:50 +02:00
Ludovic Marie 1868fc39ae Merge pull request #1549 from ludovic111/fix/kb1-sprint-reset 2026-10-24 13:18:50 +02:00
Ludovic Marie 50f2894ca8 Merge pull request #1548 from ludovic111/fix/kb1-server-velocity 2026-10-24 13:13:08 +02:00
Ludovic Marie 4b44399242 Merge pull request #1545 from ludovic111/fix/kb1-knockback-harness 2026-10-24 13:11:32 +02:00
Ludovic Marie 43e6bfcf98 Merge pull request #1542 from Excurs1ons/fix/java-pack-bob-in-view
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 13:07:33 +02:00
levzzzandClaude Opus 5.5 f688726b34 Let server players push minecarts, without pushing the player back
Guests on the server thread (and LAN) now push carts with vanilla's push
math. The push back on the player is dropped in both modes: vanilla only
syncs it to other players, so the pusher's own client never feels it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:56:12 +05:00
levzzzandClaude Opus 5.5 826cf8ffe6 Burn boats and minecarts in lava, and place boats on lava
Vehicles take lava damage and keep burning for 15 seconds like vanilla
(lavaHurt, remainingFireTicks), breaking and dropping their items; a TNT
minecart primes from fire. Boat placement stops at lava as well as water.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:56:12 +05:00
levzzzandClaude Opus 5.5 e2dd6b67df Make boats solid to players
Boats join happy ghasts in the player's entity collision list, so a
player can't walk through a boat and steps up onto it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:56:12 +05:00
levzzzandClaude Opus 5.5 ebd25abd0e Run horizon-ctl block commands in the server's world
With the integrated server thread on, setblock, fill, getblock and
container ran on the client's copy only. They now run on the server, and
on a remote server setblock and fill go out as chat commands.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:56:11 +05:00
levzzzandClaude Opus 5.5 9624e99801 Clear a creative player's dig after the block breaks on the server
A creative START destroys the block at once and its client never sends
STOP, so the kept dig broke whatever was placed in that cell next, every
destroy delay, until the player broke something else.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 15:56:11 +05:00
Ludovic MarieandClaude Opus 5.5 6a5c749f7c Aim extra knockback with Mth's sine table
Player.causeExtraKnockback pushes along Mth.sin/Mth.cos of the attacker's
yaw, which come from vanilla's 65536-entry table: facing east, the push
has no z at all. Horizon took f32 sin/cos, which left -4.4e-8 of z per
block of push (a sprinting or Knockback hit, on mobs and players).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 12:13:31 +02:00
Ludovic MarieandClaude Opus 5.5 c33acfdd05 Stop a guest's sprint when its hit knocks back
Player.causeExtraKnockback stops the attacker's sprint on the server, and
the shared flag reaches the attacker's own client (ServerEntity sends a
player its own dirty entity data). A guest's sprint was only stopped on
the server copy for the length of the hit and never sent, so a guest (and,
on the server thread, the window) kept sprinting: every hit while
sprinting had the sprint knockback, where vanilla's needs the sprint to
start again.

- leave_guest keeps the sprint stop a guest's hit made; a guest hitting a
  player stops its sprint too.
- The hub sends each guest its own shared flags (crouching, sprinting,
  gliding) when they change.
- A remote client takes the sprinting bit of its own shared flags (the
  flag only, as vanilla's assignValues: the speed modifier and the
  wasSprinting it compares stay, so it sends START_SPRINTING again only
  after it has stopped).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 12:13:31 +02:00
Ludovic MarieandClaude Opus 5.5 7c83fbb6df Travel a guest's server-side velocity, and send hurts after it
A guest's (and, on the server thread, the window's) knockback started from
the velocity the last hit left on its server copy, which nothing ever
slowed down, and each hit went out at once. So every hit after the first
pushed harder (0.72, then 0.76 blocks a tick where vanilla sends 0.4 each
time), netherite resisted from the wrong base, and a zombie's hit reached
the client before the friction of the tick it landed in.

Vanilla's server simulates a player too (MoveSimulationType
AUTHORITATIVE_SIDE_AND_SERVER): ServerPlayer travels every tick with no
input, and ServerEntity.sendChanges sends a velocity a hurt marked
(Entity.markHurt) as it is after that travel. A player's melee hit is the
exception (Player.causeExtraKnockback): it sends at once, then puts the
server copy back to its velocity from before the hit.

- MotionWorld::server_travel: Player.travel with no input, keeping only
  the velocity (the client's moves place the player). The server's copy
  checks no fall damage on its own moves (isLocalInstanceAuthoritative).
- guest_server_tick runs it last, then sends the velocity if a hurt
  marked it since the last tick.
- guest_hit_by leaves a marked velocity to that; guest_struck_by (melee
  from a player) sends at once and restores.
- lan_e2e `knockback`: three hits each way push the victim as far each
  time.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 12:13:30 +02:00
Ludovic MarieandClaude Opus 5.5 c26ced533e Merge origin/main into fix/worldgen-clippy-bool-20261023
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:31:38 +02:00
Ludovic MarieandClaude Opus 5.5 e0d29dc233 Drop the aquifer helper and correct the fire burnout comment
Stable Clippy (1.99, what CI runs) does not flag q < -2. || q > 2., so the
helper and its test only served Rust 1.90's Clippy. Fire burnout is not
'intentionally disabled': vanilla's FireBlock.tick reads the
INCREASED_FIRE_BURNOUT environment attribute, set by eight overworld biomes,
which Horizon does not read yet; the comment now says so.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:31:38 +02:00
Ludovic MarieandClaude Opus 5.5 6eb34eb868 Merge origin/main into fix/client-clippy-baseline-20261023
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:31:10 +02:00
Ludovic MarieandClaude Opus 5.5 b21423ae01 Keep only the Clippy rewrites that read as well on stable
CI runs stable Clippy (1.99), which is clean on main. The #[expect(clippy::boxed_local)]
in streaming.rs is unfulfilled there and fails CI under -D warnings, so it goes.
Also back out the rewrites that only satisfied Rust 1.90's Clippy and read
worse: the dragon's range helper and its test, the axolotl test's restated
formula, and two De Morgan expansions (first_person_probe, explosion).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:31:10 +02:00
Ludovic MarieandClaude Opus 5.5 a6e9374f88 Java shader packs: say where vanilla and Iris put the view bob
Vanilla 26.3 multiplies bobHurt/bobView into the projection
(GameRenderer.renderLevel); Iris's MixinModelViewBobbing moves them into
the model-view when a pack is in use. The comment said vanilla keeps the
bob in the PoseStack, which is Iris's behaviour, not vanilla's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:30:12 +02:00
Ludovic MarieandClaude Opus 5.5 50aee22996 Add a knockback harness: a dedicated server against vanilla's
tools/test_server_knockback_parity.py runs vanilla's dedicated server and
Horizon's (`--dedicated --nogui`) on one flat world and joins both with two
vanilla-codec players (KnockbackClient):

- Alice hits a cow and a zombie (plain, sprinting, Knockback II, critical,
  in the air); the game is frozen around the hit and stepped a tick at a
  time, and the mob's Motion and Pos over 20 ticks are compared exactly;
- Alice hits Bob (the same, a shield, netherite, two hits, two sprinting
  hits, a mace smash, a stronger hit inside the hurt cooldown): Bob's own
  motion packets, Alice's and her sprint flag are compared;
- a zombie hits Bob twice: his motion packets.

7 of 21 cases match on main. KNOWN lists the other 14 with their causes:
the guest's server-side velocity never travels (knockback adds up from hit
to hit), the server never sends a guest its own sprint flag, the extra
knockback's direction uses f32 sin/cos instead of Mth's table, a hit in
the hurt cooldown still knocks back, and a mace smash on a player runs
none of MaceItem.hurtEnemy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:23:04 +02:00
Ludovic MarieandClaude Opus 5.5 01133fac82 No default knockback from a hit inside the hurt cooldown
A hit that lands while the player is still in its hurt cooldown, harder
than the last one, only hurts by the difference (LivingEntity.hurtServer):
it marks nothing and deals no default knockback. Horizon knocked the
player back 0.4 for it anyway, so a quick second hit (a weak swing, then a
charged one) pushed twice.

The hurt now reports whether its knockback hook ran (a fresh hit, not
fully blocked), and mob_hits_player knocks back only then.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:13:27 +02:00
Ludovic MarieandClaude Opus 5.5 4507ded0e4 Read enchantment ids without a namespace as minecraft ones
`/give @s wooden_sword[enchantments={knockback:2}]` (or an item saved
that way) kept the key `knockback`, which no enchantment lookup matched:
the sword showed no enchantment and knocked back like a plain one.
Vanilla reads the keys as Identifiers, so `knockback` is
`minecraft:knockback`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:13:27 +02:00
Ludovic Marie 46304cc1a7 Merge pull request #1489 from rdbtCVS/docs/generic-agent-attribution
🤖 Agent: merged after verify passed and the integration run on the desktop.
2026-10-24 11:10:16 +02:00
Ludovic MarieandClaude Opus 5.5 377d81a329 AGENTS.md: keep Ludovic's markers, let a contributor's agent name its own person and tool
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 11:07:44 +02:00
Ludovic Marie 905acfe7cd Merge remote-tracking branch 'origin/main' into pr-1489 2026-10-24 11:07:43 +02:00
Ludovic Marie c18f3dbfde Merge pull request #1536 from ludovic111/fix/dg1-villager-doors 2026-10-24 09:50:38 +02:00
JasonGuandDeepSeek V4.1 Flash 9c843819b7 Java shader packs: keep the view bob in the view, not the projection
A pack rebuilds view positions from the depth buffer with
`inverse_projection` and then multiplies by `inverse_view`
(`gbufferModelViewInverse`), and Horizon's own packs do the same through
`world_offset` and `frame.inverse_view`. Passing
`world_projection(fov, aspect, far) * bob` as the projection while `view`
stayed unbobbed left the two halves disagreeing by the walk bob, so
everything a pack reconstructed — reflections, ambient occlusion,
godrays — turned with the bob. That reads as reflections wobbling in
step with the first-person hand, and changing the FOV made it worse.

Vanilla and Iris carry the bob in the world's pose stack (the model-view
matrix), not in the projection. Pass `bob * view` as the view and the
plain world projection instead: `projection * view` is unchanged, so
terrain, entities and the pack's own geometry programs draw as before,
while depth reconstruction is now consistent. The hand's uniforms
already carried the bob in its pose and are untouched.

Checked with Complementary Reimagined r5.9.3 over the depth-feedback
passes at 70 and 110 FOV.

Co-Authored-By: DeepSeek V4.1 Flash (OpenCode agent) <[email protected]>
2026-10-24 15:49:14 +08:00
Ludovic Marie c466c5ddad Merge remote-tracking branch 'origin/main' into fix/dg1-villager-doors 2026-10-24 09:07:55 +02:00
Ludovic Marie d5bd35025e Merge pull request #1541 from ludovic111/fix/probe-target-names 2026-10-24 08:57:26 +02:00
Ludovic Marie 2cd8b88a5b Merge pull request #1540 from ludovic111/fix/villager-golem-summon 2026-10-24 08:18:46 +02:00
Ludovic MarieandClaude Opus 5.5 0b581f9ffe Give each crate's probe example its own target name
horizon-client, horizon-world and horizon-protocol each had an example
named `probe`. Cargo copies every crate's examples into one
<profile>/examples/ directory, so parallel parity harnesses sharing a
target dir could run another crate's probe (CI parity run 40055656303:
command_parse ran the horizon-world dispatcher). The examples are now
client_probe, world_probe and protocol_probe ([[example]] in each
Cargo.toml; the files stay where they are), and every harness builds
and runs its own crate's name.

tools/parity.py recognises the new names when it counts the probes a
harness ran, and renames the old `probe` in recorded artifacts to the
crate's target (from the record's -p, else its probe sources), so the
prebuild still builds them instead of dropping them as unknown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 08:08:18 +02:00
Ludovic Marie f67b2b3204 Merge pull request #1537 from ludovic111/fix/mob-path-max-visited 2026-10-24 07:52:25 +02:00
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 MarieandClaude Opus 5.5 1b14e0d171 Villagers summon iron golems the vanilla way and remember them
Villager.spawnGolemIfNeeded set GOLEM_DETECTED_RECENTLY on the villagers
near a new golem, but Horizon wrote only villager::Ext, and the next brain
tick copied the empty memory back over it: the same villagers could summon
another golem before their sensor saw the first. golem_detected now sets the
brain memory (599 ticks).

IronGolem.checkSpawnObstruction checks the floor's top face (no golems on
dirt paths, farmland or slabs) and three blocks, the feet whatever fluid is
there; Horizon checked four blocks and no floor, so a roof three blocks up
kept golems out. The search's open block is air or a liquid block, not any
waterlogged one, and the villagers asked are those whose boxes meet the
caller's box grown by 10. The golem is created (yaw, follow range bonus,
left-handedness from the level random) before the obstruction check, as
trySpawnMob does.

The search and checks move to mobs::villager so the mob_ai probe runs the
game's code: four new scenarios (gossip, glass roof, dirt paths, panic) match
vanilla; with the old checks all four diverge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 07:25:46 +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 MarieandClaude Opus 5.5 13c14563c4 Launcher adds Programmer Art and High Contrast to resources prepared without them
Resources prepared before #1538, and every version that reused them, lack vanilla's built-in
packs, so players had to click Prepare again. The launcher now copies them itself, with
horizon-prepare's `packs` (the asset index's SHA-1 checked), when it starts, installs a version
or starts the game: only for prepared versions that lack a zip, once per launcher run. Without
them in any Minecraft folder's asset store it logs one line and the game starts as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 07:07:28 +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 MarieandClaude Opus 5.5 5205b909f7 Villagers, llamas, bees, allays and copper golems search the full path budget
PathNavigation.setRequiredPathLength calls updatePathfinderMaxVisitedNodes, so a
villager's PathFinder visits up to max(follow range, 48) * 16 = 768 nodes, not the
256 of its follow range alone. Horizon always used follow range * 16, so a villager
gave up on any job site, bed or bell whose path needed more than 256 nodes: in the
seed 12345 village it never found the way round to the mason's stonecutter door.

Navigation now keeps PathFinder.maxVisitedNodes: the base follow range times 16 when
the mob is made, then max(follow range, required path length) * 16 after
setRequiredPathLength and after any follow range change (Mob.onAttributeUpdated).
A happy ghast that grows up or becomes a baby gets a new navigation, as in vanilla.

mob_ai: villager_job_in_box, a composter inside a stone box whose doorway faces away
from the villager.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 05:33:52 +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
Ganer 82218db860 Merge commit '59f018eabe26093274d34942365d31010c3e3e95' into fix/worldgen-clippy-bool-20261023 2026-10-23 21:58:59 -05:00
Ganer 040ac1bec4 Merge commit '59f018eabe26093274d34942365d31010c3e3e95' into fix/client-clippy-baseline-20261023 2026-10-23 21:58:59 -05:00
Ludovic MarieandClaude Opus 5.5 969bcec6c9 Villagers open and close doors on their paths
InteractWithDoor was a stub, so a villager whose path went through a
closed oak door walked into it and stayed there. In generated villages,
villagers who were inside a house never reached a job site, a bed or
the meeting point.

Like vanilla, a villager now opens the closed mob-interactable doors on
its next path step (DoorBlock.setOpen: both halves, the door sound and
the block_open game event), remembers the door it leaves and the doors
it opened, and closes them once it is more than one step past them,
unless another villager within 2 blocks is walking through. A villager
going to sleep closes them too.

The mob events apply after the mob loop, so the door opens one tick
later than in vanilla, where DoorBlock.setOpen changes the world during
the villager's brain tick.

lan_e2e's new doors check shuts a villager in a room with a closed
door and a free composter outside. Before, it never opened the door;
now it opens it, becomes a farmer, and the door is closed again on the
server and on the guest.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-24 04:47:39 +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
Ganer d66466db81 Merge commit '22b2711cf9557d53d21080828c895dbff6027151' into fix/worldgen-clippy-bool-20261023 2026-10-23 20:25:14 -05:00
Ganer 6b55abd66a Merge commit '22b2711cf9557d53d21080828c895dbff6027151' into fix/client-clippy-baseline-20261023 2026-10-23 20:25:14 -05:00
Ganer 6ea7167cbb Merge commit 'd8dd2023dd9c6129e9e628a66d61c1b10e1435cf' into fix/worldgen-clippy-bool-20261023 2026-10-23 20:17:24 -05:00
Ganer 51de8afe95 Merge commit 'd8dd2023dd9c6129e9e628a66d61c1b10e1435cf' into fix/client-clippy-baseline-20261023 2026-10-23 20:08:07 -05:00
Ganer 3e04df41c8 Merge commit 'd84d7a7e6178ec1a6afab302c4b8ce80e5d2d9ee' into fix/client-clippy-baseline-20261023 2026-10-23 19:55:56 -05:00
Ganer 7371b2cccd Merge commit 'd84d7a7e6178ec1a6afab302c4b8ce80e5d2d9ee' into fix/worldgen-clippy-bool-20261023 2026-10-23 19:55:55 -05:00
Ganer 077c4c9214 Merge remote-tracking branch 'upstream/main' into fix/client-clippy-baseline-20261023 2026-10-23 19:53:00 -05:00
Ganer 8f081ae460 Merge remote-tracking branch 'upstream/main' into fix/client-clippy-baseline-20261023 2026-10-23 19:44:31 -05:00
Ganer 14461c1d96 fix(client): preserve behavior while clearing Rust 1.90 Clippy errors 2026-10-23 19:44:13 -05:00
Ganer fc9822b241 Merge commit 'd6dc08098a30a08be7ec168fa3e6b756aa3ac493' into fix/worldgen-clippy-bool-20261023 2026-10-23 17:12:21 -05:00
Ganer 4b1e298a44 fix(worldgen): resolve upstream Rust 1.90 Clippy boolean warnings 2026-10-23 16:55:08 -05: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 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
rdbtCVS d7b5cd8c85 docs: make agent identity instructions user independent
🤖 Generated with Codex
2026-10-23 21:28:06 +01: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 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 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 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
424 changed files with 4501 additions and 19241 deletions

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
name: release
# Automatic game releases (docs/release-flow.md). After every green `checks` run on main, every 10 minutes (so a
# release waiting for merges to settle goes out without another merge) and by hand, tools/release_train.py decides:
# a release when pull requests merged since the last one changed what players run and the newest merged 5 minutes
# ago (the oldest 20 minutes ago, if merges never pause), or at once for a pull request labelled release-now.
# a release when 5 pull requests merged since the last one changed what players run and the newest merged 5 minutes
# ago, or when the oldest has waited 6 hours, or at once for a pull request labelled release-now.
# RELEASES_PAUSED=1 (repository variable) stops them; so does an open issue labelled release-blocker, forced runs too.
# Builds Linux (an older Ubuntu, so the binary runs on any recent distribution and on NixOS through nix-ld) and
# Windows on GitHub, macOS on the Mac mini's runner (MAC_RUNNER=macos-latest sends it to GitHub), then
+18 -17
View File
@@ -12,17 +12,20 @@ Feature → files → harness for the most-changed areas: [docs/architecture.md]
folders in `HORIZON_HOME` (the game's data), `HORIZON_LAUNCHER_HOME`, `XDG_DATA_HOME` and
`XDG_CONFIG_HOME`, plus `HORIZON_LAUNCHER_NO_SELF_UPDATE=1` and `HORIZON_NO_AUDIO=1`. Never write to
`~/.local/share/{applications,horizon,horizon-launcher}` or `~/.local/bin`.
- **Mark what an agent posts.** A PR body starts with
`> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.` (issues: "Filed by", reviews:
"Review by"); every comment starts with `🤖 Agent:`. Never write as Ludovic.
- **Attribution.** Commits end with `Co-Authored-By: Claude <model> <[email protected]>`; PR bodies end
with `🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- **Mark what an agent posts.** A PR body starts with a line naming whose agent opened it and with what.
Ludovic's agents write `> 🤖 Opened by Ludovic's AI agent (Claude), not by Ludovic himself.`, and a
contributor's agent names its own person and tool (issues: "Filed by", reviews: "Review by"). Every
comment starts with `🤖 Agent:`. Never write as Ludovic or as anyone else.
- **Attribution.** Commits end with a `Co-Authored-By:` line for the agent that wrote them, and PR bodies
with that agent's own "Generated with" line. Ludovic's agents use
`Co-Authored-By: Claude <model> <[email protected]>` and
`🤖 Generated with [Claude Code](https://claude.com/claude-code)`.
- Everything players run is Rust. Python/Java only for dev tools and harness drivers in `tools/`.
- Horizon-only features follow [docs/design-system.md](docs/design-system.md) and are off by default where
they would change the vanilla look.
- Small PRs (< ~500 lines). Labels: `feature` (players get something new), `skip-release` (they see no
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Every merge to main ships
([docs/release-flow.md](docs/release-flow.md)).
change), none for a fix. Agents never add `release-now`: the maintainer decides it. Merges ship in batches of 5, or after
6 hours ([docs/release-flow.md](docs/release-flow.md)).
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
## Crates
@@ -40,7 +43,6 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-shaderpack` | OptiFine/Iris packs: preprocessor, GLSL → WGSL ([docs/shader-packs.md](docs/shader-packs.md)) |
| `horizon-mods`, `horizon-mod-sdk`, `cargo-horizon-mod` | Sandboxed WebAssembly mods: runtime, SDK + WIT, `cargo horizon-mod` ([docs/mods.md](docs/mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
@@ -123,7 +125,6 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; L: `keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `osk.rs`, `clipboard.rs`; G: `pad.rs`, `pad_menu.rs` |
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `lang.rs`; crate `horizon-assets`; G: `packs.rs` |
| Shader packs | L: `shader_pack.rs` (Horizon WGSL packs), `java_pack.rs` + crate `horizon-shaderpack` (Iris/OptiFine), `gfx/pack_stages.rs`, `gfx/java_stages.rs`; G: `shader_packs.rs` |
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
@@ -148,19 +149,19 @@ python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
## The two single-player paths
1. **In-frame (default since #1428):** the window's `Game` ticks the world itself, one level that follows
the player (`fn enter_dimension`). This is what Stable players run and what harnesses test.
2. **Server thread (`--server-thread` or `HORIZON_SERVER_THREAD=1`):** `game/integrated.rs` runs a second
`Game` (`serving`) on a "Server thread" with one level per dimension; the window joins it as a client
through memory (`remote`), exactly as a LAN guest or a dedicated server's player does. Not at parity yet:
open `[server-thread]` issues list the gaps.
1. **Server thread (default since #1514):** `game/integrated.rs` runs a second `Game` (`serving`) on a
"Server thread" with one level per dimension; the window joins it as a client through memory (`remote`),
exactly as a LAN guest or a dedicated server's player does. This is what players run. Open
`[server-thread]` issues list the gaps left.
2. **In-frame (`--in-frame`, `--no-server-thread` or `HORIZON_SERVER_THREAD=0`):** the window's `Game` ticks
the world itself, one level that follows the player (`fn enter_dimension`). Most harnesses test this path.
LAN guests and dedicated servers always use the server code, so a fix there serves both. Test both paths
when you touch player-facing gameplay:
```sh
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # in-frame
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --server-thread # server thread
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T # server thread
cargo run --profile fast -p horizon-client -- --resources .local/horizon-client/26.3 --world T --in-frame # in-frame
python3 tools/lan_e2e.py --check <name> # host in-frame, guest is a server client
python3 tools/lan_e2e.py --server-thread --check <name> # host on its server thread (portals, levels, distance, lava need it)
python3 tools/parity.py dedicated_server server_entities server_player_damage
Generated
+7 -398
View File
@@ -178,15 +178,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
[[package]]
name = "ascii-canvas"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1e3e699d84ab1b0911a1010c5c106aa34ae89aeac103be5ce0c3859db1e891"
dependencies = [
"term",
]
[[package]]
name = "ash"
version = "0.38.0+1.3.281"
@@ -214,30 +205,15 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bit-set"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
dependencies = [
"bit-vec 0.8.0",
]
[[package]]
name = "bit-set"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
dependencies = [
"bit-vec 0.9.1",
"bit-vec",
]
[[package]]
name = "bit-vec"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]]
name = "bit-vec"
version = "0.9.1"
@@ -283,16 +259,6 @@ dependencies = [
"objc2 0.6.5",
]
[[package]]
name = "borsh"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
dependencies = [
"bytes",
"cfg_aliases",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -484,15 +450,6 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "convert_case"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9"
dependencies = [
"unicode-segmentation",
]
[[package]]
name = "coords-hud"
version = "0.1.0"
@@ -797,41 +754,6 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn 2.0.119",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
"syn 2.0.119",
]
[[package]]
name = "dasp_sample"
version = "0.11.0"
@@ -849,29 +771,6 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "derive_more"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134"
dependencies = [
"derive_more-impl",
]
[[package]]
name = "derive_more-impl"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb"
dependencies = [
"convert_case",
"proc-macro2",
"quote",
"rustc_version",
"syn 2.0.119",
"unicode-xid",
]
[[package]]
name = "digest"
version = "0.10.7"
@@ -958,15 +857,6 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]]
name = "ena"
version = "0.14.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1"
dependencies = [
"log",
]
[[package]]
name = "encoding_rs"
version = "0.8.42"
@@ -1230,58 +1120,6 @@ version = "0.30.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
[[package]]
name = "glsl-lang"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "983a18d0c2ecad18ac9ff3c94b069d6bd03b47405a200836d9d8d63def8e34de"
dependencies = [
"glsl-lang-lexer",
"glsl-lang-types",
"lalrpop",
"lalrpop-util",
"lang-util",
"once_cell",
"thiserror 2.0.21",
]
[[package]]
name = "glsl-lang-lexer"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abdbc0bdfbc81d557f4865e695457eb23d68031bfb061fda3444f56511b26eee"
dependencies = [
"glsl-lang-pp",
"glsl-lang-types",
"lalrpop-util",
"lang-util",
"thiserror 2.0.21",
]
[[package]]
name = "glsl-lang-pp"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88677876fb27f116015d0ecebda6d77bdf65f3a6fa3f5dfeed10b8c8f04cb2c5"
dependencies = [
"arrayvec",
"derive_more",
"lang-util",
"string_cache",
"string_cache_codegen",
"thiserror 2.0.21",
]
[[package]]
name = "glsl-lang-types"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd8b1532b424d40df634cf2dc2be56d59f994b9ac07fa7c1e3183fd33db9cb07"
dependencies = [
"lang-util",
"thiserror 2.0.21",
]
[[package]]
name = "gpu-allocator"
version = "0.28.0"
@@ -1428,7 +1266,6 @@ dependencies = [
"horizon-mods",
"horizon-protocol",
"horizon-server-files",
"horizon-shaderpack",
"horizon-telemetry",
"horizon-theme",
"horizon-threads",
@@ -1443,7 +1280,6 @@ dependencies = [
"serde_json",
"sha2",
"symphonia",
"toml",
"ureq",
"wgpu",
"winit",
@@ -1594,16 +1430,6 @@ dependencies = [
"ureq",
]
[[package]]
name = "horizon-shaderpack"
version = "0.1.0"
dependencies = [
"glsl-lang",
"naga",
"pp-rs",
"zip",
]
[[package]]
name = "horizon-telemetry"
version = "0.1.0"
@@ -1755,12 +1581,6 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.1.0"
@@ -1943,73 +1763,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keccak"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
dependencies = [
"cpufeatures",
]
[[package]]
name = "lalrpop"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba4ebbd48ce411c1d10fb35185f5a51a7bfa3d8b24b4e330d30c9e3a34129501"
dependencies = [
"ascii-canvas",
"bit-set 0.8.0",
"ena",
"itertools",
"lalrpop-util",
"petgraph 0.7.1",
"pico-args",
"regex",
"regex-syntax",
"sha3",
"string_cache",
"term",
"unicode-xid",
"walkdir",
]
[[package]]
name = "lalrpop-util"
version = "0.22.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5baa5e9ff84f1aefd264e6869907646538a52147a755d494517a8007fb48733"
dependencies = [
"regex-automata",
"rustversion",
]
[[package]]
name = "lang-util"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1715a1e56998f119cd6d49ac8a69802dcbe4fca96d8291243d6dae87abeb913"
dependencies = [
"derive_more",
"lalrpop-util",
"lang-util-derive",
"line-span",
"smol_str 0.3.6",
"text-size",
]
[[package]]
name = "lang-util-derive"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f2acc66aedf0e445534e0a6b3f0f6ee20ef6ebb9647dbe10735e52ea8d6a096"
dependencies = [
"darling",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "lazy_static"
version = "1.5.1"
@@ -2066,12 +1819,6 @@ dependencies = [
"pkg-config",
]
[[package]]
name = "line-span"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29fc123b2f6600099ca18248f69e3ee02b09c4188c0d98e9a690d90dec3e408a"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
@@ -2183,7 +1930,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
dependencies = [
"arrayvec",
"bit-set 0.10.0",
"bit-set",
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
@@ -2196,7 +1943,6 @@ dependencies = [
"naga-types",
"num-traits",
"once_cell",
"pp-rs",
"rustc-hash 1.1.0",
"spirv",
"thiserror 2.0.21",
@@ -2245,12 +1991,6 @@ dependencies = [
"jni-sys 0.3.1",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.31.3"
@@ -2750,16 +2490,6 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "petgraph"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772"
dependencies = [
"fixedbitset",
"indexmap",
]
[[package]]
name = "petgraph"
version = "0.8.3"
@@ -2771,31 +2501,6 @@ dependencies = [
"indexmap",
]
[[package]]
name = "phf_generator"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
dependencies = [
"phf_shared",
"rand",
]
[[package]]
name = "phf_shared"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
dependencies = [
"siphasher",
]
[[package]]
name = "pico-args"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
[[package]]
name = "pin-project"
version = "1.1.13"
@@ -2914,21 +2619,6 @@ dependencies = [
"zerovec",
]
[[package]]
name = "pp-rs"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb458bb7f6e250e6eb79d5026badc10a3ebb8f9a15d1fff0f13d17c71f4d6dee"
dependencies = [
"unicode-xid",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "presser"
version = "0.3.1"
@@ -3022,15 +2712,6 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
dependencies = [
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
@@ -3369,16 +3050,6 @@ dependencies = [
"digest",
]
[[package]]
name = "sha3"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
dependencies = [
"digest",
"keccak",
]
[[package]]
name = "shlex"
version = "2.0.1"
@@ -3407,12 +3078,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
[[package]]
name = "slab"
version = "0.4.12"
@@ -3462,16 +3127,6 @@ dependencies = [
"serde",
]
[[package]]
name = "smol_str"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523"
dependencies = [
"borsh",
"serde_core",
]
[[package]]
name = "socket2"
version = "0.5.10"
@@ -3521,37 +3176,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "string_cache"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"precomputed-hash",
"serde",
]
[[package]]
name = "string_cache_codegen"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
@@ -3668,15 +3292,6 @@ version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "term"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8c27177b12a6399ffc08b98f76f7c9a1f4fe9fc967c784c5a071fa8d93cf7e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "termcolor"
version = "1.4.1"
@@ -3686,12 +3301,6 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "text-size"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -3852,7 +3461,7 @@ checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
dependencies = [
"memchr",
"nom",
"petgraph 0.8.3",
"petgraph",
]
[[package]]
@@ -4573,8 +4182,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
dependencies = [
"arrayvec",
"bit-set 0.10.0",
"bit-vec 0.9.1",
"bit-set",
"bit-vec",
"bitflags 2.13.2",
"bytemuck",
"cfg_aliases",
@@ -4624,7 +4233,7 @@ dependencies = [
"android_system_properties",
"arrayvec",
"ash",
"bit-set 0.10.0",
"bit-set",
"bitflags 2.13.2",
"block2 0.6.2",
"bytemuck",
@@ -5122,7 +4731,7 @@ dependencies = [
"rustix 0.38.44",
"sctk-adwaita",
"smithay-client-toolkit",
"smol_str 0.2.2",
"smol_str",
"tracing",
"unicode-segmentation",
"wasm-bindgen",
+1 -1
View File
@@ -1,6 +1,6 @@
[workspace]
exclude = ["launcher", "crates/horizon-telemetry-server", "third_party/wgpu-hal"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-shaderpack", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
members = ["crates/horizon-core", "crates/horizon-dualsense", "crates/horizon-threads", "crates/horizon-world", "crates/voxel-kernels", "crates/horizon-client", "crates/horizon-server-files", "crates/horizon-server", "crates/horizon-bots", "crates/horizon-assets", "crates/horizon-protocol", "crates/horizon-theme", "crates/horizon-prepare", "crates/horizon-telemetry", "crates/horizon-bench", "crates/horizon-friends", "crates/horizon-relay", "crates/horizon-mod-sdk", "crates/horizon-mods", "crates/horizon-mods/tests/guest", "crates/cargo-horizon-mod", "crates/horizon-port-pack", "crates/horizon-release-sign", "crates/horizon-mod-sdk/examples/coords-hud", "xtask"]
resolver = "2"
[workspace.package]
+2 -2
View File
@@ -45,7 +45,7 @@ The [documentation](https://playhorizon.dev/docs/) covers each of these.
- **The game:** singleplayer with vanilla's world generation, mobs, villagers, raids, redstone, every command, the HUD and the recipe book, on saves in vanilla's format. Worlds from 1.8 to 26.2 are upgraded as vanilla does. [Status table](#project-status).
- **Horizon's own extras:** new menus (vanilla's are one setting away), graphics presets, distant terrain up to 1024 chunks, controller support, optional zoom, minimap and more, all off by default where they would change the vanilla look. See [Settings](https://playhorizon.dev/docs/settings).
- **Resource packs, shader packs and data packs:** resource packs work, Java shader packs run their full-screen effects (experimental), and data packs load functions, predicates and item modifiers.
- **Resource packs and data packs:** resource packs work, and data packs load functions, predicates and item modifiers.
- **Playing with others:** invite friends from the pause menu's Friends button with a code, Open to LAN, and joining offline-mode servers. Horizon cannot sign in to a Microsoft account yet, so it cannot join servers that check accounts. See [Multiplayer](https://playhorizon.dev/docs/multiplayer).
- **Mods:** Horizon has its own mod system (Rust compiled to WebAssembly). Java mod jars do not run. See [Mods and data packs](https://playhorizon.dev/docs/mods).
@@ -57,7 +57,7 @@ Screenshots, captured in the Rust client itself with vanilla assets and no mods
</tr>
<tr>
<td width="50%"><img src="docs/media/survival.webp" alt="Survival at dusk: a zombie walks up, with the hotbar, health, hunger and experience"></td>
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics, Advanced Graphics and Shader Packs"></td>
<td width="50%"><img src="docs/media/settings.webp" alt="Vanilla's Video Settings screen with Horizon's own buttons at the top: Horizon Graphics and Advanced Graphics"></td>
</tr>
<tr>
<td width="50%"><img src="docs/media/village.webp" alt="A plains village among savanna hills, with farms, villagers and an iron golem"></td>
+5 -4
View File
@@ -29,13 +29,9 @@ png = "0.18"
sha2 = "0.10.9"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1.0.151", features = ["raw_value"] }
# Shader packs: pack.toml, and WGSL checked before it reaches wgpu.
toml = "1.1"
# hlsl-out: the gfx.rs tests translate every shader as wgpu's DirectX 12 backend does. A normal feature,
# not a dev-dependency's, so builds, tests and examples share one build of naga, wgpu and this crate.
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
# Java (OptiFine/Iris) shader packs, translated to WGSL.
horizon-shaderpack = { path = "../horizon-shaderpack" }
# Sound (ALSA on Linux); the `audio` feature.
cpal = { version = "0.16", optional = true }
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
@@ -85,6 +81,11 @@ mods = ["horizon-mods/runtime"]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
[[example]]
name = "client_probe"
path = "examples/probe/main.rs"
[lints]
workspace = true
@@ -41,7 +41,7 @@ pub fn main(names: &[&str], run: fn(&str) -> Option<ExitCode>) -> ExitCode {
let name = std::env::args().nth(1).unwrap_or_default();
run(&name).unwrap_or_else(|| {
eprintln!(
"usage: probe <name> [arguments...]\nprobes: {}",
"usage: client_probe <name> [arguments...]\nprobes: {}",
names.join(" ")
);
ExitCode::from(2)
@@ -293,6 +293,47 @@ fn spawn_seed(index: usize) -> i64 {
1000 + 7919 * index as i64
}
/// `Villager.spawnGolemIfNeeded` for villager `a`, the game's way: the golem
/// draws from the level's random and joins the scenario's mobs next tick,
/// seeded as the oracle seeds what a tick spawned.
fn summon_golem(
mobs: &mut [Mob],
a: usize,
needed: usize,
w: &MobWorld,
types: &MobTypes,
born: &mut Vec<Mob>,
level_random: &mut LegacyRandom,
) {
use horizon_client::mobs::{villager, villager_brain};
let Some(near) = villager::golem_summoners(mobs, a, w.game_time, needed) else {
return;
};
let kind = Arc::new(
types
.types
.get("minecraft:iron_golem")
.expect("iron golem")
.clone(),
);
let index = mobs.len() + born.len();
let p = mobs[a].position;
let start = [p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32];
let golem = villager::summon_golem(w, start, level_random, |random, at, yaw| {
let mut m = Mob::new(100_000 + index as u32, kind.clone(), at, yaw);
villager::finalize_summoned_golem(&mut m, random);
m
});
let Some(mut golem) = golem else {
return;
};
golem.random = LegacyRandom::new(spawn_seed(index));
born.push(golem);
for j in near {
villager_brain::golem_detected(&mut mobs[j], w.game_time);
}
}
/// Villager events that act at once in vanilla (`TradeWithVillager` and
/// `VillagerMakeLove` write the other villager directly).
fn villager_events(
@@ -304,6 +345,7 @@ fn villager_events(
items: &Items,
born: &mut Vec<Mob>,
day_time: i64,
level_random: &mut LegacyRandom,
) {
use horizon_client::mobs::villager;
let index = |mobs: &[Mob], id: u32| {
@@ -314,6 +356,7 @@ fn villager_events(
while i < events.len() {
match events[i] {
MobEvent::VillagerMeet { mob, other } => {
events.remove(i);
if let (Some(a), Some(b)) = (index(mobs, mob), index(mobs, other)) {
let meeting = villager::meet(mobs, a, b, w.game_time, |item| {
items.get(item).map_or(64, |i| i.max_stack)
@@ -322,8 +365,16 @@ fn villager_events(
meeting.thrown.is_empty(),
"villagers toss items: not modelled here"
);
if meeting.gossiped {
summon_golem(mobs, a, 5, w, types, born, level_random);
}
}
}
MobEvent::GolemCheck { mob, needed } => {
events.remove(i);
if let Some(a) = index(mobs, mob) {
summon_golem(mobs, a, needed, w, types, born, level_random);
}
}
MobEvent::VillagerBirth {
parent,
@@ -1167,6 +1218,7 @@ pub fn main() -> horizon_client::Result<()> {
&items,
&mut born,
s.time,
&mut scratch,
);
horizon_client::mobs::warden::hear_mob_events(&mut mobs, &events, from, &w);
// Melee hits on other mobs land during the attacker's tick
@@ -1323,6 +1375,31 @@ pub fn main() -> horizon_client::Result<()> {
world.set(at[0], at[1], at[2], registry.resolve(&st)?)?;
}
}
// Doors villagers opened or closed (DoorBlock.setOpen), both halves.
let doors: Vec<([i32; 3], bool)> = events
.iter()
.filter_map(|e| match e {
MobEvent::SetDoorOpen { pos, open } => Some((*pos, *open)),
_ => None,
})
.collect();
for (at, open) in doors {
let Some(half) = registry
.state(world.block(at[0], at[1], at[2]))
.and_then(|s| s.properties.get("half").cloned())
else {
continue;
};
let other = if half == "lower" { 1 } else { -1 };
for y in [at[1], at[1] + other] {
if let Some(mut st) = registry.state(world.block(at[0], y, at[2])).cloned()
&& st.properties.contains_key("open")
{
st.properties.insert("open".to_owned(), open.to_string());
world.set(at[0], y, at[2], registry.resolve(&st)?)?;
}
}
}
mobs.append(&mut born);
mobs.append(&mut added);
for e in &heart_events {
@@ -147,7 +147,6 @@ const PAGES: Ty = Ty::Enum(&[
"graphics-advanced",
"extras",
"horizon",
"shader-packs",
"agent",
]);
+7 -7
View File
@@ -382,7 +382,7 @@ impl Providers {
);
Self { ints, floats }
}
fn eval(&self, v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
fn eval(v: &Value, fast: bool, table: &HashMap<String, Value>, depth: u32) -> f64 {
if depth > 16 {
return 0.0;
}
@@ -390,10 +390,10 @@ impl Providers {
Value::Number(n) => n.as_f64().unwrap_or(0.0),
Value::String(name) => table
.get(name)
.map_or(0.0, |v| self.eval(v, fast, table, depth + 1)),
.map_or(0.0, |v| Self::eval(v, fast, table, depth + 1)),
Value::Object(o) => {
let f =
|k: &str| self.eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
|k: &str| Self::eval(o.get(k).unwrap_or(&Value::Null), fast, table, depth + 1);
match o.get("type").and_then(Value::as_str).unwrap_or("") {
"minecraft:div" => {
let r = f("right");
@@ -425,14 +425,14 @@ impl Providers {
}
/// A plain `context_int_provider` value or reference (no context conditions).
pub fn int_value(&self, v: &Value) -> i32 {
self.eval(v, false, &self.ints, 0) as i32
Self::eval(v, false, &self.ints, 0) as i32
}
/// A plain `context_float_provider` value or reference; `default` when absent.
pub fn float_value(&self, v: &Value, default: f32) -> f32 {
if v.is_null() {
default
} else {
self.eval(v, false, &self.floats, 0) as f32
Self::eval(v, false, &self.floats, 0) as f32
}
}
#[cfg(test)]
@@ -446,14 +446,14 @@ impl Providers {
items
.get(&stack.item)
.and_then(|i| i.fuel.as_ref())
.map_or(0, |(time, _)| self.eval(time, fast, &self.ints, 0) as i32)
.map_or(0, |(time, _)| Self::eval(time, fast, &self.ints, 0) as i32)
}
pub fn speed(&self, items: &Items, stack: &ItemStack, fast: bool) -> f32 {
items
.get(&stack.item)
.and_then(|i| i.fuel.as_ref())
.map_or(1.0, |(_, speed)| {
self.eval(speed, fast, &self.floats, 0) as f32
Self::eval(speed, fast, &self.floats, 0) as f32
})
}
}
+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,
}
}
+151 -121
View File
@@ -138,7 +138,6 @@ mod screens;
mod sculk;
mod sculk_fixture;
mod server_travel;
mod shader_packs;
mod shield;
mod sign_edit;
mod sniffers;
@@ -285,11 +284,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 +344,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 +354,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 +369,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()
@@ -1201,8 +1198,6 @@ struct Game<'a> {
modifiers: winit::keyboard::ModifiersState,
/// The window's fullscreen request and reported state.
fullscreen: horizon_client::window::FullscreenState,
/// The selected Horizon shader pack (`game/shader_packs.rs`).
shader_packs: shader_packs::ShaderPacks,
/// MouseHandler's accumulated look and wheel state.
mouse: MouseLook,
/// An agent moved the mouse (`mouse-move`) since the pointer was last
@@ -1446,6 +1441,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.
@@ -1741,10 +1738,10 @@ impl Game<'_> {
let state = self.world.block(pos[0], pos[1], pos[2]);
let f = self.res.facts().get(state);
if !self.res.facts().is_air(state)
&& !(f.fluid != Fluid::None
&& !f.waterlogged
&& &*f.name != "minecraft:kelp"
&& f.render_shape != horizon_client::blocks::RenderShape::Model)
&& (f.fluid == Fluid::None
|| f.waterlogged
|| &*f.name == "minecraft:kelp"
|| f.render_shape == horizon_client::blocks::RenderShape::Model)
&& !matches!(
&*f.name,
"minecraft:water" | "minecraft:lava" | "minecraft:bubble_column"
@@ -1827,6 +1824,55 @@ impl Game<'_> {
.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,
@@ -3461,7 +3507,7 @@ impl Game<'_> {
return Ok(true);
}
// Block foods attempt placement before starting consumption.
if !on_block && !(info.block.is_some() && block_hit.is_some()) && self.start_eating(&info) {
if !on_block && (info.block.is_none() || block_hit.is_none()) && self.start_eating(&info) {
return Ok(true);
}
// BoatItem.use also performs its own fluid/block raycast.
@@ -4053,9 +4099,9 @@ impl Game<'_> {
self.tick_riding(keys)?;
}
let riding = self.riding.is_some();
let ghast_collisions = self.happy_ghast_player_collisions();
let entity_collisions = self.player_entity_collisions();
let spin_targets = self.spin_targets();
let glider = self.glider_worn();
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() {
@@ -4087,46 +4133,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;
player.glider = glider;
// 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 = (
@@ -4136,7 +4146,7 @@ impl Game<'_> {
);
if !riding {
MotionWorld {
entities: &ghast_collisions,
entities: &entity_collisions,
player,
scene: &mut self.world,
assets: &self.pack.assets,
@@ -6009,6 +6019,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 {
@@ -6024,19 +6065,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),
@@ -7402,30 +7431,17 @@ impl Game<'_> {
self.tick_player_model();
Ok(())
}
/// What the loaded Java shader pack changes in this dimension's vanilla
/// sky (`sunPathRotation`, `clouds`, `sun`, `moon`); `None` without one.
fn java_sky(&self) -> Option<(f32, horizon_shaderpack::properties::SkyDirectives)> {
self.shader_packs.java_sky(match self.dim {
Dim::Overworld => 0,
Dim::Nether => -1,
Dim::End => 1,
})
}
/// SkyRenderer.renderSunMoonAndStars: bodies on a frame rotated Y -90 then X by angle.
/// A Java pack's `sunPathRotation` tilts it about Z in between, and its
/// `sun = false` / `moon = false` leave those out, as Iris does. Returns
/// the sun and moon quads, and the stars' frame and brightness (the
/// renderer keeps their geometry, built once).
/// Returns the sun and moon quads, and the stars' frame and brightness
/// (the renderer keeps their geometry, built once).
fn celestial_geometry(
&self,
sky: &horizon_client::environment::Sky,
) -> (Vec<Vertex>, Option<(glam::Mat4, f32)>) {
let mut out = Vec::new();
let atlas = &self.gui.atlas;
let (tilt, keep) = self.java_sky().unwrap_or_default();
let frame = |angle: f32| {
glam::Mat4::from_rotation_y((-90.0f32).to_radians())
* glam::Mat4::from_rotation_z(tilt.to_radians())
* glam::Mat4::from_rotation_x(angle.to_radians())
};
let mut body = |name: &str, angle: f32, size: f32, color: u32| {
@@ -7447,14 +7463,12 @@ impl Game<'_> {
});
}
};
if keep.sun {
body(
"environment/celestial/sun",
sky.sun_angle,
30.0,
0xFFFF_FFFF,
);
}
body(
"environment/celestial/sun",
sky.sun_angle,
30.0,
0xFFFF_FFFF,
);
let phases = [
"full_moon",
"waning_gibbous",
@@ -7466,14 +7480,12 @@ impl Game<'_> {
"waxing_gibbous",
];
let phase = phases[(self.world.day_time / 24000).rem_euclid(8) as usize];
if keep.moon {
body(
&format!("environment/celestial/moon/{phase}"),
sky.moon_angle,
20.0,
0xFFFF_FFFF,
);
}
body(
&format!("environment/celestial/moon/{phase}"),
sky.moon_angle,
20.0,
0xFFFF_FFFF,
);
// SkyRenderer.renderStars: the static star buffer on the same frame.
let stars =
(sky.star_brightness > 0.0).then(|| (frame(sky.star_angle), sky.star_brightness));
@@ -9799,7 +9811,6 @@ impl Game<'_> {
}
return Ok(false);
}
self.poll_shader_pack();
let Some(size) = self
.renderer
.as_ref()
@@ -10012,18 +10023,6 @@ impl Game<'_> {
self.dragon_fireball_geometry(camera, alpha, &mut dragon);
entities.extend(dragon);
}
// A Java pack's `clouds` replaces the cloud option (`renderClouds`:
// "false", "fast", "true"), as Iris overrides `getCloudsType`.
let clouds_mode = {
use horizon_shaderpack::properties::CloudSetting;
match self.java_sky().and_then(|(_, sky)| sky.clouds) {
Some(CloudSetting::Off) => "false",
Some(CloudSetting::Fast) => "fast",
Some(CloudSetting::Fancy) => "true",
None => self.settings.clouds.as_str(),
}
.to_owned()
};
let (celestial, stars) = if title {
(Vec::new(), None)
} else if self.dim == Dim::Overworld
@@ -10067,7 +10066,6 @@ impl Game<'_> {
let frame = Frame {
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
shader: self.shader_inputs(camera, alpha as f32),
nether_lighting: self.dim == Dim::Nether,
camera,
yaw,
@@ -10115,7 +10113,7 @@ impl Game<'_> {
hud: &hud,
clouds: (!title
&& self.dim == Dim::Overworld
&& horizon_client::clouds::visible(&clouds_mode, sky.cloud_color[3]))
&& horizon_client::clouds::visible(&self.settings.clouds, sky.cloud_color[3]))
.then(|| self.gui.atlas.sprites.get("horizon/white"))
.flatten()
.map(|white| horizon_client::clouds::Input {
@@ -10125,7 +10123,7 @@ impl Game<'_> {
time: self.world.game_time,
partial_tick: alpha,
range: self.settings.cloud_range,
fast: clouds_mode == "fast",
fast: self.settings.clouds == "fast",
color: sky.cloud_color,
}),
entities: &entities,
@@ -10347,6 +10345,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.
@@ -10838,9 +10841,6 @@ impl ApplicationHandler for Game<'_> {
WindowEvent::CloseRequested => {
self.pending_save.as_mut().unwrap().quit = true;
}
WindowEvent::DroppedFile(path) => {
self.drop_shader_pack(&path);
}
WindowEvent::Focused(focused) => {
self.focused = focused;
if !focused {
@@ -11218,7 +11218,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);
@@ -11719,7 +11723,6 @@ impl<'a> Game<'a> {
focused: true,
modifiers: winit::keyboard::ModifiersState::empty(),
fullscreen: horizon_client::window::FullscreenState::new(false),
shader_packs: Default::default(),
mouse: MouseLook::default(),
agent_pointer: false,
smooth_camera: false,
@@ -11879,6 +11882,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(),
@@ -12156,6 +12160,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",
@@ -12650,7 +12681,6 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
night_vision: 0.0,
block_flicker: 0.0,
menu_blur: None,
shader: Default::default(),
}
}
@@ -59,6 +59,30 @@ impl Game<'_> {
}
/// Block commands; None when `words[0]` is not one of them.
pub(super) fn block_command(&mut self, words: &[&str]) -> Result<Option<Value>> {
let ours = matches!(
words.first(),
Some(&("setblock" | "fill" | "getblock" | "container"))
);
// Single player on its server thread: the world is the server's, so
// the command runs there, in the owner's level, and the client hears
// of it like any change.
if ours && let Some(server) = &self.integrated {
let owned: Vec<String> = words.iter().map(|w| (*w).to_owned()).collect();
return server
.call(move |g| {
let words: Vec<&str> = owned.iter().map(String::as_str).collect();
g.in_owner_level(|g| g.block_command(&words).map_err(|e| e.to_string()))
})
.ok_or("the integrated server stopped")?
.map_err(Into::into);
}
// Another server's world changes only through its own commands, which
// it checks as the player's; `getblock` and `container` read what this
// client has of it.
if self.remote.is_some() && matches!(words.first(), Some(&("setblock" | "fill"))) {
self.send_chat(&format!("/{}", words.join(" ")));
return Ok(Some(json!({ "ok": true, "sent": true })));
}
let int = |i: usize| -> Result<i32> {
Ok(words
.get(i)
+80 -11
View File
@@ -183,13 +183,18 @@ impl BoatModels {
}
impl Game<'_> {
fn fluid_height(&self, x: i32, y: i32, z: i32) -> Option<(f32, bool)> {
self.fluid_height_of(x, y, z, Some(Fluid::Water))
}
/// `FluidState.getHeight` and whether it is a source, for `fluid` or,
/// with `None`, any fluid (`ClipContext.Fluid.ANY`).
fn fluid_height_of(&self, x: i32, y: i32, z: i32, fluid: Option<Fluid>) -> Option<(f32, bool)> {
let f = self.res.facts().get(self.world.block(x, y, z));
if f.fluid != Fluid::Water {
if f.fluid == Fluid::None || fluid.is_some_and(|want| f.fluid != want) {
return None;
}
let above = self.res.facts().get(self.world.block(x, y + 1, z));
// FluidState.getHeight: 1 under the same fluid, else amount / 9.
let height = if above.fluid == Fluid::Water {
let height = if above.fluid == f.fluid {
1.0
} else {
f32::from(f.fluid_level) / 9.0
@@ -461,6 +466,59 @@ impl Game<'_> {
let b = self.boats[i].clone();
self.boats[i] = self.tick_boat(b, false);
}
// AbstractBoat.tick's applyEffectsFromBlocks, on the server for every
// boat, the client-driven ones too.
let mut i = 0;
while i < self.boats.len() {
let p = self.boats[i].position;
let skip = Some(self.boats[i].id) == self.riding
|| !ticking(p.x.floor() as i32, p.z.floor() as i32);
if skip || !self.boat_lava_tick(i) {
i += 1;
}
}
}
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
/// damage, so lava breaks a boat on its first tick inside, and the fire
/// `lavaIgnite` lights never gets to burn it. True when the boat broke.
fn boat_lava_tick(&mut self, i: usize) -> bool {
if !self.in_lava(self.boats[i].bounds()) {
return false;
}
for _ in 0..2 {
let p = self.boats[i].position;
let broke = self.hurt_boat(i, 4.0, false);
self.lava_hurt_sound([p.x, p.y, p.z]);
if broke {
return true;
}
}
false
}
/// `Entity.lavaHurt`'s sound once the hurt went through.
pub(super) fn lava_hurt_sound(&mut self, at: [f64; 3]) {
let pitch = 2.0 + self.world.random.next_float() * 0.4;
self.audio
.play("minecraft:entity.generic.burn", Some(at), 0.4, pitch);
}
/// Whether a vehicle's box, deflated by 1e-5, reaches into lava below its
/// surface (`Entity.collidedWithFluid`).
pub(super) fn in_lava(&self, bb: Aabb) -> bool {
let (lo, hi) = (bb.min_position(), bb.max_position());
let e = 1.0e-5;
for x in (lo.x + e).floor() as i32..=(hi.x - e).floor() as i32 {
for y in (lo.y + e).floor() as i32..=(hi.y - e).floor() as i32 {
for z in (lo.z + e).floor() as i32..=(hi.z - e).floor() as i32 {
if let Some((height, _)) = self.fluid_height_of(x, y, z, Some(Fluid::Lava))
&& lo.y + e < f64::from(y) + f64::from(height)
{
return true;
}
}
}
}
false
}
/// The player's tick while riding: steer, tick the boat, sit in it.
pub(super) fn tick_riding(&mut self, keys: Keys) -> horizon_client::Result<()> {
@@ -489,6 +547,9 @@ impl Game<'_> {
p.fall_distance = 0.0;
}
self.boats[i] = b;
if self.boat_lava_tick(i) {
self.riding = None;
}
Ok(())
}
/// `getDismountLocationForPassenger`: in front of the player if there is
@@ -534,7 +595,8 @@ impl Game<'_> {
};
let dir = view_vector(self.yaw, self.pitch);
let reach = self.reach();
// ClipContext.Fluid.ANY: the first water surface or block along the ray.
// ClipContext.Fluid.ANY: the first fluid surface (water or lava) or
// block along the ray.
let block = self.pick().map(|h| {
(
h.point,
@@ -544,7 +606,7 @@ impl Game<'_> {
.sqrt(),
)
});
let mut water = None;
let mut fluid = None;
let mut t = 0.0;
while t < reach {
let p: [f64; 3] = std::array::from_fn(|a| eye[a] + dir[a] * t);
@@ -553,15 +615,15 @@ impl Game<'_> {
p[1].floor() as i32,
p[2].floor() as i32,
);
if let Some((h, _)) = self.fluid_height(x, y, z)
if let Some((h, _)) = self.fluid_height_of(x, y, z, None)
&& p[1] <= f64::from(y) + f64::from(h)
{
water = Some(([p[0], f64::from(y) + f64::from(h), p[2]], t));
fluid = Some(([p[0], f64::from(y) + f64::from(h), p[2]], t));
break;
}
t += 0.05;
}
let hit = match (block, water) {
let hit = match (block, fluid) {
(Some(b), Some(w)) => {
if w.1 < b.1 {
w.0
@@ -682,15 +744,23 @@ impl Game<'_> {
return false;
}
self.vehicle_attack_sounds();
self.hurt_boat(i, damage, self.creative);
self.swing_arm();
true
}
/// `VehicleEntity.hurtServer`: wobble, add the damage and break past 40
/// (`creative`: a creative player's hit removes it without drops).
/// True when the boat was removed.
pub(super) fn hurt_boat(&mut self, i: usize, damage: f32, creative: bool) -> bool {
let b = &mut self.boats[i];
b.hurt_dir = -b.hurt_dir;
b.hurt_time = 10;
b.damage += damage * 10.0;
let destroyed = self.creative || b.damage > 40.0;
let destroyed = creative || b.damage > 40.0;
if destroyed {
let b = self.boats.remove(i);
let at = [b.position.x, b.position.y, b.position.z];
if !self.creative && self.ui.rules.bool("entity_drops") {
if !creative && self.ui.rules.bool("entity_drops") {
// ChestBoat.destroy: chestVehicleDestroyed spills the contents.
for stack in b.items.iter().flatten() {
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
@@ -709,8 +779,7 @@ impl Game<'_> {
);
}
}
self.swing_arm();
true
destroyed
}
/// AbstractBoatRenderer.
pub(super) fn boat_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
+2 -5
View File
@@ -1,6 +1,6 @@
//! Chat, the command registries and the per-world state behind commands
//! (game rules, the world clock, difficulty), bound to the game.
use super::{Dim, Game, Screen};
use super::{Game, Screen};
use horizon_client::{
chat::{Chat, ChatAction, ChatScreen, Commands, SYSTEM_INDICATOR},
command::Registries,
@@ -695,10 +695,7 @@ impl Game<'_> {
/// Advance the overworld clock (ServerClockManager.tick) when
/// `advance_time` allows it.
pub(super) fn advance_clock(&mut self) {
if !self.ui.rules.bool("advance_time")
|| self.ui.clock.paused
|| self.dim == Dim::Nether && false
{
if !self.ui.rules.bool("advance_time") || self.ui.clock.paused {
return;
}
let c = &mut self.ui.clock;
+1 -1
View File
@@ -128,7 +128,7 @@ impl Game<'_> {
if self.state_in_tag(avoid, ground) {
return false;
}
let entities = self.happy_ghast_player_collisions();
let entities = self.player_entity_collisions();
let Some(player) = self.player.as_mut() else {
return false;
};
+1 -1
View File
@@ -589,7 +589,6 @@ impl Game<'_> {
"credits" => Page::Credits,
"horizon" => Page::Horizon,
"agent" => Page::Agent,
"shader-packs" => Page::ShaderPacks,
other => return Err(format!("unknown options page {other}").into()),
};
if self.screen == Screen::Playing && self.title.is_none() {
@@ -988,6 +987,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.
+15 -6
View File
@@ -1007,6 +1007,11 @@ impl Game<'_> {
MobEvent::SetBlock { pos, state } => {
self.world.set(pos[0], pos[1], pos[2], state)?
}
MobEvent::SetDoorOpen { pos, open } => {
let pos = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.set_door_open(level, ticks, pos, open));
self.process_level_events()?;
}
MobEvent::SetBlockName { pos, name, effect } => {
if effect {
let old = self.world.block(pos[0], pos[1], pos[2]);
@@ -1491,12 +1496,16 @@ impl Game<'_> {
o.hurt
}
{
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
self.knock_back_player(f64::from(0.4_f32), xd, zd);
if let Some(p) = self.player.as_mut()
&& let Some(r) = p.hurt_log.last_mut()
{
r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]);
// LivingEntity.hurtServer's dealDefaultKnockback: not inside the
// hurt cooldown (a stronger hit there only adds its difference).
if o.knockback {
let (xd, zd) = (from[0] - player.position.x, from[2] - player.position.z);
self.knock_back_player(f64::from(0.4_f32), xd, zd);
if let Some(p) = self.player.as_mut()
&& let Some(r) = p.hurt_log.last_mut()
{
r.knockback = Some([p.velocity.x, p.velocity.y, p.velocity.z]);
}
}
self.hurt_armor(amount);
self.thorns(from);
+1 -2
View File
@@ -38,7 +38,7 @@ impl DebugHost for Host<'_, '_> {
// the menu as it always has (key_pressed).
let g = &mut *self.game;
if without_menu && g.screen == Screen::Playing {
g.pause_menu = !(g.has_singleplayer_server() && !g.is_published());
g.pause_menu = !g.has_singleplayer_server() || g.is_published();
g.screen = Screen::Paused;
g.capture(false);
g.mining = None;
@@ -593,7 +593,6 @@ impl Game<'_> {
DebugAction::ReloadResourcePacks => {
self.debug_translated("debug.reload_resourcepacks.message");
self.reload_packs();
self.load_shader_pack();
true
}
DebugAction::CopyLocation if player => {
@@ -121,11 +121,13 @@ impl Game<'_> {
let bonus = if knockback_attack { 0.5 } else { 0.0 };
(enchanted / 2.0 + bonus, knockback_attack)
}
/// `causeExtraKnockback`'s direction, `(sin yRot, -cos yRot)`: the target
/// flies where the attacker looks, not away from it.
/// `causeExtraKnockback`'s direction, `(sin yRot, -cos yRot)` from Mth's
/// sine table: the target flies where the attacker looks, not away from
/// it (straight along an axis when the attacker faces one).
pub(super) fn extra_knockback_direction(&self) -> (f64, f64) {
let yaw = self.yaw.to_radians();
(f64::from(yaw.sin()), -f64::from(yaw.cos()))
use horizon_core::geometry::trig;
let yaw = f64::from(self.yaw * trig::DEG_TO_RAD);
(f64::from(trig::sin(yaw)), -f64::from(trig::cos(yaw)))
}
/// The attacker's side of `causeExtraKnockback` as the attacking client
/// sees it: the server's player stops sprinting, and its flag reaches the
+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()
}
}
+1 -1
View File
@@ -200,7 +200,7 @@ impl Game<'_> {
}
Action::Copy => self.friends_copy(),
Action::Stop => self.friends_stop(),
// The system browser, as the Shader Packs page opens its folder.
// The system browser, as the folder buttons open their folders.
Action::Help => {
if let Some(url) = &view.help {
super::packs::open_folder(std::path::Path::new(url));
+10 -3
View File
@@ -10,9 +10,10 @@ use horizon_core::geometry::{Aabb, Vec3};
use horizon_world::registry::BlockStateId;
impl Game<'_> {
/// Client `EntityGetter.getEntityCollisions`: only solid happy ghasts,
/// excluding the player's own vehicle.
pub(super) fn happy_ghast_player_collisions(&self) -> Vec<Aabb> {
/// Client `EntityGetter.getEntityCollisions` for the player: the
/// entities that `canBeCollidedWith` it, solid happy ghasts and boats
/// (`AbstractBoat.canBeCollidedWith`), except the player's own vehicle.
pub(super) fn player_entity_collisions(&self) -> Vec<Aabb> {
let Some(p) = &self.player else {
return Vec::new();
};
@@ -24,6 +25,12 @@ impl Game<'_> {
&& happy_ghast::collides_with_player(m, p.position.y)
})
.map(|m| m.bounds())
.chain(
self.boats
.iter()
.filter(|b| Some(b.id) != self.riding)
.map(super::boats::Boat::bounds),
)
.collect()
}
/// `HappyGhast.mobInteract`. Returns whether the click was used.
+20 -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},
@@ -498,6 +496,22 @@ impl Game<'_> {
out
}
/// Run `f` with the owner's level in place (the server holds one per
/// dimension), then put back the level that was.
pub(super) fn in_owner_level<T>(&mut self, f: impl FnOnce(&mut Self) -> T) -> T {
let owner = self.serving.as_ref().map(|s| s.owner);
let dim = self.lan.as_ref().and_then(|h| {
let g = h.hub.guests.iter().find(|g| Some(g.uuid) == owner)?;
Some(super::Dim::from_id(&g.dimension))
});
let back = dim.and_then(|d| self.enter_command_level(d));
let out = f(self);
if let Some(back) = back {
self.enter_command_level(back);
}
out
}
/// The window's requests, between two ticks.
fn run_tasks(&mut self) {
let tasks: Vec<Task> = match &self.serving {
+2
View File
@@ -875,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();
}
@@ -1068,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
+73 -19
View File
@@ -82,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`).
@@ -345,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());
@@ -552,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);
@@ -591,6 +595,11 @@ impl Game<'_> {
// A teleport (commands, a pearl) moves the guest; a pearl's is
// sent even to where they stand (ServerPlayer.teleport).
let at = [m.position.x, m.position.y, m.position.z];
// A hit with extra knockback stopped the guest's sprint
// (Player.causeExtraKnockback's setSprinting(false)).
if !m.sprinting {
g.sprinting = false;
}
m.sprinting = false;
let teleported = std::mem::take(&mut m.teleported);
let moved: f64 = (0..3).map(|k| (at[k] - g.position[k]).powi(2)).sum();
@@ -1055,20 +1064,14 @@ impl Game<'_> {
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());
@@ -1084,10 +1087,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),
@@ -1234,6 +1234,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 = [
@@ -1316,8 +1327,11 @@ impl Game<'_> {
return;
}
if let Some(victim) = Self::pvp_victim(host, conn, id) {
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike()) {
self.pvp_land(host, victim, &strike);
if let Some(Some(strike)) = self.as_guest(host, conn, |g| g.pvp_strike())
&& self.pvp_land(host, victim, &strike)
&& strike.stops_sprint()
{
self.as_guest(host, conn, |g| g.stop_sprint_after_extra_knockback());
}
return;
}
@@ -1699,6 +1713,17 @@ impl Game<'_> {
g.use_delay -= 1;
}
});
// ServerPlayerGameMode.handleBlockBreakAction: a creative
// player's START breaks the block once (destroyAndAck) and
// leaves nothing being destroyed; its client sends no STOP.
// Kept, the dig broke whatever was put there next, every
// destroy delay, for as long as the player broke nothing else.
if digging.is_some()
&& host.hub.guests[gi].creative()
&& let Some(play) = host.play.get_mut(&conn)
{
play.digging = None;
}
}
// Player.tick: a different main-hand item resets the attack
// strength, and detectEquipmentUpdates gives the guest its modifiers.
@@ -1720,16 +1745,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 {
@@ -1898,7 +1930,7 @@ impl Game<'_> {
let (rules, motion) = (game.world.health_rules, game.motion);
let health = &motion.health;
let Some(player) = game.player.as_mut() else {
return Vec::new();
return (Vec::new(), None);
};
let mut w = MotionWorld {
entities: &[],
@@ -1974,22 +2006,44 @@ impl Game<'_> {
game.border_damage();
game.tick_mob_effects();
let Some(player) = game.player.as_mut() else {
return Vec::new();
return (Vec::new(), None);
};
let _ = player.peaceful_regeneration(rules);
let _ = player.food_tick(health, rules);
events.append(&mut player.events);
events
let _ = MotionWorld {
entities: &[],
player,
scene: &mut game.world,
assets: &game.pack.assets,
collision_binding: game.collision,
resources: game.motion,
}
.server_travel();
// A hurt since the last tick marked the velocity (Entity.markHurt):
// ServerEntity.sendChanges sends it as it is after this travel.
let marked = std::mem::take(&mut player.health.sync_velocity);
let sounds = events
.into_iter()
.filter_map(|e| match e {
MotionEvent::HurtSound(sound) => Some(sound),
_ => None,
})
.collect::<Vec<_>>()
.collect::<Vec<_>>();
(sounds, marked.then_some(player.velocity))
});
for sound in sounds.into_iter().flatten() {
let Some((sounds, marked)) = sounds else {
return;
};
for sound in sounds {
self.player_hurt_sound(sound, at);
}
if let (Some(v), Some(g)) = (marked, host.hub.guest(conn)) {
g.send(&C::SetEntityMotion(play::SetEntityMotion {
id: g.entity_id,
velocity: [v.x, v.y, v.z],
}));
}
}
/// ServerGamePacketListenerImpl.handlePlayerCommand START_FALL_FLYING:
+59 -5
View File
@@ -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() {
@@ -192,7 +213,9 @@ impl Game<'_> {
impl Game<'_> {
/// Hurt a guest: `f` runs as them, and the knockback it gave reaches
/// their client (`hurtMarked`: ClientboundSetEntityMotionPacket).
/// their client. A hurt marks the velocity (Entity.markHurt), which goes
/// out once the guest's server copy has travelled (`guest_server_tick`);
/// a push with no hurt goes out at once.
pub(super) fn guest_hit_by<R>(
&mut self,
host: &mut LanHost,
@@ -202,16 +225,47 @@ impl Game<'_> {
let (result, pushed) = self.as_guest(host, conn, |g| {
let before = g.player.as_ref().map(|p| p.velocity);
let result = f(g);
let after = g.player.as_ref().map(|p| p.velocity);
(result, after.filter(|v| Some(*v) != before))
let pushed = g
.player
.as_ref()
.filter(|p| !p.health.sync_velocity && Some(p.velocity) != before)
.map(|p| p.velocity);
(result, pushed)
})?;
if let (Some(v), Some(g)) = (pushed, host.hub.guest(conn)) {
self.send_guest_motion(host, conn, pushed);
Some(result)
}
/// A player's melee hit on a guest (Player.causeExtraKnockback on a
/// ServerPlayer): a hurt that marked the velocity sends it at once, and
/// the server's copy goes back to the velocity it had before the hit.
pub(super) fn guest_struck_by<R>(
&mut self,
host: &mut LanHost,
conn: u32,
f: impl FnOnce(&mut Self) -> R,
) -> Option<R> {
let (result, sent) = self.as_guest(host, conn, |g| {
let before = g.player.as_ref().map(|p| p.velocity);
let result = f(g);
let sent = g.player.as_mut().zip(before).and_then(|(p, before)| {
std::mem::take(&mut p.health.sync_velocity)
.then(|| std::mem::replace(&mut p.velocity, before))
});
(result, sent)
})?;
self.send_guest_motion(host, conn, sent);
Some(result)
}
/// ClientboundSetEntityMotionPacket to a guest about themselves.
pub(super) fn send_guest_motion(&self, host: &LanHost, conn: u32, velocity: Option<Vec3>) {
if let (Some(v), Some(g)) = (velocity, host.hub.guest(conn)) {
g.send(&C::SetEntityMotion(play::SetEntityMotion {
id: g.entity_id,
velocity: [v.x, v.y, v.z],
}));
}
Some(result)
}
}
+10 -2
View File
@@ -45,6 +45,14 @@ pub(super) struct Strike {
team: Option<horizon_client::scoreboard::Team>,
}
impl Strike {
/// Whether it has extra knockback, which stops the attacker's sprint
/// once it lands (Player.causeExtraKnockback).
pub(super) fn stops_sprint(&self) -> bool {
self.knockback.0 > 0.0
}
}
impl Game<'_> {
/// The guest on the host's crosshair and how far, within reach.
pub(super) fn guest_hit(&self) -> Option<(u32, f64)> {
@@ -74,7 +82,7 @@ impl Game<'_> {
};
if let Some(strike) = self.pvp_strike()
&& self.pvp_land(&mut host, Victim::Guest(conn), &strike)
&& strike.knockback.0 > 0.0
&& strike.stops_sprint()
{
self.stop_sprint_after_extra_knockback();
}
@@ -240,7 +248,7 @@ impl Game<'_> {
&& harm
&& match victim {
Victim::Host => land(self),
Victim::Guest(conn) => self.guest_hit_by(host, conn, land).unwrap_or(false),
Victim::Guest(conn) => self.guest_struck_by(host, conn, land).unwrap_or(false),
};
let sound = match (hurt, s.crit, s.scale > 0.9) {
(false, ..) => "minecraft:entity.player.attack.nodamage",
@@ -205,7 +205,9 @@ impl Game<'_> {
return;
};
let [x, y, z] = p.position;
if !(x.is_finite() && y.is_finite() && z.is_finite())
if !x.is_finite()
|| !y.is_finite()
|| !z.is_finite()
|| !p.yaw.is_finite()
|| !p.pitch.is_finite()
{
+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 {
+72 -32
View File
@@ -686,40 +686,53 @@ impl Game<'_> {
}
}
}
/// `AbstractMinecart.push(player)` when the player walks into a cart.
/// `AbstractMinecart.push` from each player walking into a cart
/// (`LivingEntity.pushEntities`): the window's player and every server
/// player (single player's own on its server thread, LAN guests). A
/// player in a vehicle pushes nothing here. The quarter push the player
/// gets back only moves the server's copy of it (`Entity.push` marks
/// `needsSync`, which `ServerEntity` sends to the other players only),
/// so no player feels it.
fn push_carts(&mut self) {
let player = self.player.as_ref().map(|p| p.position);
let riding = self.riding;
for i in 0..self.carts.len() {
if Some(self.carts[i].id) == riding {
continue;
if self.carts.is_empty() {
return;
}
// (feet, box)
let mut pushers: Vec<(Vec3, Aabb)> = Vec::new();
if self.riding.is_none()
&& let Some(p) = self.player.as_ref()
{
pushers.push((p.position, p.bounds()));
}
if let Some(host) = self.lan.as_ref() {
for g in host.hub.guests.iter().filter(|g| {
host.hub.here(g) && g.loaded && !g.dead && !g.spectator() && g.vehicle.is_none()
}) {
let [x, y, z] = g.position;
let height = if g.sneaking { 1.5 } else { 1.8 };
let bb = Aabb::new(
Vec3::new(x - 0.3, y, z - 0.3),
Vec3::new(x + 0.3, y + height, z + 0.3),
);
pushers.push((Vec3::new(x, y, z), bb));
}
if let Some(pp) = player {
let bb = self.carts[i].bounds();
let (lo, hi) = (bb.min_position(), bb.max_position());
let touching = pp.x + 0.3 > lo.x
&& pp.x - 0.3 < hi.x
&& pp.z + 0.3 > lo.z
&& pp.z - 0.3 < hi.z
&& pp.y + 1.8 > lo.y
&& pp.y < hi.y;
if touching && riding.is_none() {
let c = &mut self.carts[i];
let (mut xa, mut za) = (pp.x - c.position.x, pp.z - c.position.z);
let dd = xa * xa + za * za;
if dd >= 1.0e-4 {
let d = dd.sqrt();
let pow = (1.0 / d).min(1.0);
xa = xa / d * pow * f64::from(0.1f32) * 0.5;
za = za / d * pow * f64::from(0.1f32) * 0.5;
c.velocity.x -= xa;
c.velocity.z -= za;
if let Some(p) = self.player.as_mut() {
p.velocity.x += xa / 4.0;
p.velocity.z += za / 4.0;
}
}
}
for (feet, bb) in pushers {
for c in &mut self.carts {
if !bb.intersects(c.bounds()) {
continue;
}
let (mut xa, mut za) = (feet.x - c.position.x, feet.z - c.position.z);
let dd = xa * xa + za * za;
if dd < f64::from(1.0e-4f32) {
continue;
}
let d = dd.sqrt();
let pow = (1.0 / d).min(1.0);
xa = xa / d * pow * f64::from(0.1f32) * 0.5;
za = za / d * pow * f64::from(0.1f32) * 0.5;
c.velocity.x -= xa;
c.velocity.z -= za;
}
}
}
@@ -797,7 +810,7 @@ impl Game<'_> {
let ridden = self.guest_vehicles();
// By id: an exploding cart can destroy or prime others mid-loop.
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
for id in ids {
for &id in &ids {
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
continue;
};
@@ -823,6 +836,33 @@ impl Game<'_> {
self.carts.remove(i);
}
}
// On the server for every cart, the ridden ones too.
for id in ids {
if let Some(i) = self.carts.iter().position(|c| c.id == id) {
let p = self.carts[i].position;
if ticking(p.x.floor() as i32, p.z.floor() as i32) {
self.cart_lava_tick(id);
}
}
}
}
/// `lavaHurt` twice a tick in lava: from `LavaFluid.entityInside` in the
/// behaviour's `applyEffectsFromBlocks`, then from `AbstractMinecart.tick`'s
/// own `isInLava`. The second breaks the cart, or primes a TNT minecart
/// (`MinecartTNT.destroy` for a fire source). A cart has no `baseTick`,
/// so the fire `lavaIgnite` lights never burns it.
fn cart_lava_tick(&mut self, id: u32) {
for _ in 0..2 {
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
return;
};
if !self.in_lava(self.carts[i].bounds()) {
return;
}
let p = self.carts[i].position;
self.hurt_cart(i, 4.0, false, true);
self.lava_hurt_sound([p.x, p.y, p.z]);
}
}
/// `ClientPacketListener.postAddEntitySoundInstance` for the carts that
/// joined the level since the last tick, and `LocalPlayer.startRiding`'s
-16
View File
@@ -237,15 +237,7 @@ impl Game<'_> {
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
Event::OpenShaderPackFolder => {
let dir = horizon_client::shader_pack::folder();
let _ = std::fs::create_dir_all(&dir);
super::packs::open_folder(&dir);
}
Event::Credits => self.open_win_screen(false, super::win::Finish::Options),
Event::ReloadShaderPack => {
self.load_shader_pack();
}
}
}
Ok(())
@@ -294,14 +286,6 @@ impl Game<'_> {
font.reset_cache();
self.ui.chat.rescale(&self.gui.atlas);
}
// A new renderer (resource reload, first window) takes the loaded pack.
let stale = self
.renderer
.as_ref()
.is_some_and(|r| r.shader_pack_id() != self.shader_packs.id());
if stale && let Err(e) = self.apply_shader_pack() {
log::warn!("Horizon: shader pack: {e}");
}
}
/// `Window.updateFullscreenState`: a fullscreen change made by the window
/// system is adopted by the option (`Minecraft.fullscreenStateChanged`).
+14 -15
View File
@@ -544,7 +544,7 @@ impl Game<'_> {
/// (`LivingEntity.forceAddEffect`): the effect replaces any of its kind.
/// Other entities' effects are not shown.
fn remote_effect(&mut self, p: &play::UpdateMobEffect) {
if !self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
if self.remote.as_ref().is_none_or(|r| r.entity_id != p.id) {
return;
}
let Some(id) = self.remote_effect_id(p.effect) else {
@@ -1990,6 +1990,15 @@ impl Game<'_> {
}
self.sleeping = *bed;
}
// Entity.DATA_SHARED_FLAGS_ID's sprinting bit: the
// server stops the sprint after a hit's extra
// knockback (the flag only, as assignValues sets
// it: the speed modifier and wasSprinting stay).
(0, Value::Byte(flags)) => {
if let Some(pl) = self.player.as_mut() {
pl.sprinting = flags & 0x08 != 0;
}
}
// Entity.DATA_AIR_SUPPLY_ID and
// Player.DATA_PLAYER_ABSORPTION_ID: the bubbles
// and golden hearts the server's player has.
@@ -3072,7 +3081,7 @@ impl Game<'_> {
self.sleep_timer = super::tick_sleep_timer(self.sleep_timer, self.sleeping.is_some());
// Gui.tick: the titles, the action bar and the held item's name.
self.tick_hud();
let ghast_collisions = self.happy_ghast_player_collisions();
let entity_collisions = self.player_entity_collisions();
let pushed = self.remote_pushes();
let Some(player) = self.player.as_mut() else {
return Ok(());
@@ -3139,19 +3148,10 @@ 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;
let glider = self.glider_worn();
self.sync_player_body();
let Some(player) = self.player.as_mut() else {
return Ok(());
};
player.glider = glider;
let was_gliding = player.fall_flying;
let dead = self.screen == Screen::Dead;
// LivingEntity.isImmobile: a sleeper lies still in the bed.
@@ -3165,7 +3165,7 @@ impl Game<'_> {
} else if !dead && !riding {
player.velocity = player.velocity.add_vector(pushed);
MotionWorld {
entities: &ghast_collisions,
entities: &entity_collisions,
player,
scene: &mut self.world,
assets: &self.pack.assets,
@@ -4318,8 +4318,7 @@ impl Game<'_> {
/// Keys with a multiplayer meaning; returns true when consumed.
pub(super) fn remote_key(&mut self, key: KeyCode) -> Result<bool> {
if self.screen != Screen::Playing {
if key == KeyCode::KeyE && self.screen == Screen::Inventory
|| key == KeyCode::Escape && self.screen == Screen::Inventory
if (key == KeyCode::KeyE || key == KeyCode::Escape) && self.screen == Screen::Inventory
{
self.remote_close_container();
self.screen = Screen::Playing;
@@ -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
}
}
@@ -104,45 +104,6 @@ impl Game<'_> {
},
)
}
/// What a shader pack's `Frame` uniform reads of the game this frame.
pub(super) fn shader_inputs(
&self,
camera: glam::DVec3,
partial_tick: f32,
) -> horizon_client::gfx::ShaderInputs {
let (rain, thunder) = if self.dim == Dim::Overworld {
self.world.weather.levels(self.clock.fraction() as f32)
} else {
(0.0, 0.0)
};
let at = [camera.x, camera.y, camera.z];
let day = self.world.day_time;
let has_sky_light = self.dim == Dim::Overworld;
// CommonUniforms.getEyeBrightness: the camera entity's eye block.
let eye = self.eye_position().unwrap_or(at).map(|v| v.floor() as i32);
let light = self.world.light(eye[0], eye[1], eye[2]);
let eye_light = [light & 15, if has_sky_light { light >> 4 } else { 0 }];
horizon_client::gfx::ShaderInputs {
rain,
thunder,
submerged: match fog_type(&self.world, self.res.facts(), at, self.forward()) {
FogType::Atmospheric => 0,
FogType::Water => 1,
FogType::Lava => 2,
FogType::PowderSnow => 3,
},
dimension: match self.dim {
Dim::Overworld => 0,
Dim::Nether => -1,
Dim::End => 1,
},
has_sky_light,
moon_phase: day.div_euclid(24_000).rem_euclid(8) as u8,
day_fraction: (day.rem_euclid(24_000) as f32 + partial_tick) / 24_000.0,
partial_tick,
eye_light,
}
}
/// `SkyRenderer.shouldRenderDarkDisc`: eye below the horizon (y 63) and not under water.
pub(super) fn dark_disc(&self, camera: glam::DVec3) -> bool {
camera.y < 63.0
+1
View File
@@ -1054,6 +1054,7 @@ impl Game<'_> {
let r = &mut self.world.random;
let bonus = r.triangle(0.0, 0.11485000000000001);
mob.attrs.follow_range += mob.kind.follow_range * bonus;
mob.update_max_visited_nodes();
let _left_handed = r.next_float() < 0.05;
if !self.warden_fits(&mob) {
continue;
@@ -1,131 +0,0 @@
//! The selected Horizon shader pack (`shaderPack` in `horizon-graphics.txt`,
//! docs/shader-packs.md): loaded from `shaderpacks/`, given to the renderer,
//! and loaded again when the selection changes, on F3 + T, or when its files
//! change (checked twice a second). A pack that fails leaves the last good
//! one (or the vanilla picture) on and says why in chat.
use super::Game;
use horizon_client::shader_pack::{self, ShaderPack, Stamp};
use horizon_client::text::Text;
use horizon_shaderpack::properties::SkyDirectives;
use std::{
path::PathBuf,
time::{Duration, Instant},
};
#[derive(Default)]
pub(super) struct ShaderPacks {
/// The pack the renderer runs.
pack: Option<ShaderPack>,
/// The selection and its files at the last load, success or not.
selected: String,
stamp: Stamp,
checked: Option<Instant>,
}
impl ShaderPacks {
/// The loaded pack's folder name.
pub(super) fn id(&self) -> Option<&str> {
self.pack.as_ref().map(|p| p.id.as_str())
}
/// What a loaded Java pack changes in the vanilla sky of `dimension`
/// (0 overworld, -1 the Nether, 1 the End): its `sunPathRotation`, and
/// whether it keeps the clouds, sun and moon (Iris's sky mixins).
pub(super) fn java_sky(&self, dimension: i8) -> Option<(f32, SkyDirectives)> {
let java = self.pack.as_ref()?.java.as_ref()?;
let folder = &java.folders[java.folder_for(dimension)?];
Some((folder.directives.sun_path_rotation, java.sky))
}
}
pub(super) fn dir() -> PathBuf {
shader_pack::folder()
}
impl Game<'_> {
/// Load the selected pack and give it to the renderer.
pub(super) fn load_shader_pack(&mut self) {
let id = self.settings.horizon.shader_pack.clone();
let dir = dir();
let state = &mut self.shader_packs;
state.selected = id.clone();
state.checked = Some(Instant::now());
let pack = if id.is_empty() {
state.stamp = Stamp::default();
None
} else {
state.stamp = Stamp::of(&dir.join(&id));
// Java packs are told which GPU they run on.
let (vendor, renderer) = self.renderer.as_ref().map_or(("Unknown", ""), |r| {
let name = r.info.name.as_str();
(
horizon_client::java_pack::vendor_name(r.info.vendor, name),
name,
)
});
let values = shader_pack::load_values(&dir, &id);
match ShaderPack::load_any(&dir.join(&id), &values, vendor, renderer) {
Ok(pack) => Some(pack),
Err(e) => return self.shader_pack_error(&id, &e),
}
};
if let Some(r) = self.renderer.as_mut()
&& let Err(e) = r.set_shader_pack(pack.as_ref(), &self.pack.render.sprite_layers)
{
return self.shader_pack_error(&id, &e.to_string());
}
self.shader_packs.pack = pack;
self.ui.options.shader_pack_error = None;
}
/// 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(),
&self.pack.render.sprite_layers,
),
None => Ok(()),
}
}
/// Every frame: a new selection or edited pack files load it again.
pub(super) fn poll_shader_pack(&mut self) {
let state = &mut self.shader_packs;
if self.settings.horizon.shader_pack != state.selected {
return self.load_shader_pack();
}
if state.selected.is_empty()
|| state
.checked
.is_some_and(|t| t.elapsed() < Duration::from_millis(500))
{
return;
}
state.checked = Some(Instant::now());
if Stamp::of(&dir().join(&state.selected)) != state.stamp {
self.load_shader_pack();
}
}
/// A file dropped on the window: a shader pack while the Shader Packs
/// page shows (`OptionsUi::drop_shader_pack`).
pub(super) fn drop_shader_pack(&mut self, path: &std::path::Path) {
match self.ui.options.drop_shader_pack(&mut self.settings, path) {
Some(Ok(())) => {
self.settings.save(&Self::options_path());
self.load_shader_pack();
}
Some(Err(e)) => {
log::warn!("Horizon: shader pack {}: {e}", path.display());
self.system_message(Text::literal(format!("Shader pack: {e}")));
}
None => {}
}
}
fn shader_pack_error(&mut self, id: &str, error: &str) {
log::warn!("Horizon: shader pack {id}: {error}");
// The Shader Packs page says why on the pack's row: the first line,
// since translator errors go on with a drawn source excerpt.
let first = error.lines().map(str::trim).find(|l| !l.is_empty());
self.ui.options.shader_pack_error =
Some((id.to_owned(), first.unwrap_or(error).to_owned()));
self.system_message(Text::literal(format!("Shader pack {id}: {error}")));
}
}
@@ -367,11 +367,6 @@ impl Loading {
let _ = std::fs::create_dir_all(&dir);
packs::open_folder(&dir);
}
Event::OpenShaderPackFolder => {
let dir = horizon_client::shader_pack::folder();
let _ = std::fs::create_dir_all(&dir);
packs::open_folder(&dir);
}
Event::Credits => {
if let Some(gui) = &self.gui {
let name = self.options.as_ref().map_or_else(
@@ -387,9 +382,6 @@ impl Loading {
self.menu.win = Some((screen, Instant::now()));
}
}
Event::ReloadShaderPack => {
self.save_menu_settings();
}
// The startup menu runs before the game opens a control socket.
Event::ButtonClick | Event::CopyAgentDetails => {}
}
@@ -455,17 +447,6 @@ impl Loading {
}
self.apply_menu_settings();
}
WindowEvent::DroppedFile(path) => {
match self
.menu
.options
.drop_shader_pack(&mut self.settings, &path)
{
Some(Ok(())) => self.save_menu_settings(),
Some(Err(e)) => log::warn!("Horizon: shader pack {}: {e}", path.display()),
None => {}
}
}
WindowEvent::RedrawRequested => self.draw(),
WindowEvent::ModifiersChanged(modifiers) => self.menu.modifiers = modifiers.state(),
WindowEvent::Focused(false) => {
+36 -99
View File
@@ -5,34 +5,13 @@ use super::Game;
use horizon_client::{
gossip::{self, GossipType},
items::ItemStack,
mobs::{Mob, Species, spawning, villager},
mobs::{Mob, Species, spawning, villager, villager_brain},
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_core::geometry::Vec3;
fn block_of(p: Vec3) -> [i32; 3] {
[p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32]
}
/// `SpawnUtil.Strategy.LEGACY_IRON_GOLEM`: what a golem will not land on.
fn golem_floor_ok(name: &str) -> bool {
!matches!(
name,
"minecraft:cobweb"
| "minecraft:cactus"
| "minecraft:glass_pane"
| "minecraft:conduit"
| "minecraft:ice"
| "minecraft:tnt"
| "minecraft:glowstone"
| "minecraft:beacon"
| "minecraft:sea_lantern"
| "minecraft:frosted_ice"
| "minecraft:tinted_glass"
| "minecraft:glass"
) && !name.ends_with("_stained_glass_pane")
&& !name.ends_with("_stained_glass")
&& !name.ends_with("leaves")
}
impl Game<'_> {
fn villager_index(&self, id: u32) -> Option<usize> {
self.mobs
@@ -76,88 +55,46 @@ impl Game<'_> {
return;
};
let now = self.world.game_time;
if !villager::ext(&self.mobs[i]).is_some_and(|e| e.wants_golem(now)) {
let Some(near) = villager::golem_summoners(&self.mobs, i, now, needed) else {
return;
}
let at = self.mobs[i].position;
let nearby: Vec<usize> = self
.mobs
.iter()
.enumerate()
.filter(|(_, m)| {
m.species() == Species::Villager
&& m.alive()
&& (m.position.x - at.x).abs() <= 10.0
&& (m.position.y - at.y).abs() <= 10.0
&& (m.position.z - at.z).abs() <= 10.0
})
.map(|(j, _)| j)
.collect();
let wanting = nearby
.iter()
.filter(|j| villager::ext(&self.mobs[**j]).is_some_and(|e| e.wants_golem(now)))
.take(5)
.count();
if wanting < needed {
return;
}
if self.try_spawn_iron_golem(block_of(at)) {
for j in nearby {
villager::ext_mut(&mut self.mobs[j]).golem_until = now + 599;
};
if self.try_spawn_iron_golem(block_of(self.mobs[i].position)) {
for j in near {
villager_brain::golem_detected(&mut self.mobs[j], now);
}
}
}
/// `SpawnUtil.trySpawnMob(IRON_GOLEM, ..., 10 attempts, 8 across, 6 up
/// and down, LEGACY_IRON_GOLEM)`.
/// `SpawnUtil.trySpawnMob(IRON_GOLEM, ...)`: a golem near `start`, with
/// the ambient sound `trySpawnMob` plays.
fn try_spawn_iron_golem(&mut self, start: [i32; 3]) -> bool {
for _ in 0..10 {
let r = &mut self.world.random;
let dx = r.next_int_bound(17).unwrap_or(8) - 8;
let dz = r.next_int_bound(17).unwrap_or(8) - 8;
let (x, z) = (start[0] + dx, start[2] + dz);
let mut y = start[1] + 6;
let mut above = self.world.block(x, y, z);
let mut found = None;
for _ in 0..=12 {
y -= 1;
let here = self.world.block(x, y, z);
let f = self.res.facts().get(here);
let above_f = self.res.facts().get(above);
let air_or_liquid = self.res.facts().is_air(above)
|| above_f.fluid != horizon_client::blocks::Fluid::None;
let solid =
self.res.mob_data().state(here).flags & horizon_client::mobs::data::SOLID != 0
|| f.name.as_ref() == "minecraft:powder_snow";
if golem_floor_ok(&f.name) && air_or_liquid && solid {
found = Some(y + 1);
break;
}
above = here;
}
let Some(y) = found else { continue };
// IronGolem.checkSpawnRules: a floor and three free blocks.
let free = |g: &Self, yy: i32| {
g.res.mob_data().state(g.world.block(x, yy, z)).flags
& horizon_client::mobs::data::VALID_EMPTY_SPAWN
!= 0
};
if !(0..4).all(|k| free(self, y + k)) {
continue;
}
let at = [f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5];
if let Some(id) = self.spawn_mob_for("minecraft:iron_golem", at, false, true)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
let sound = m.kind.sounds[0];
if !sound.is_empty() {
let at = m.center();
self.audio
.play(&format!("minecraft:{sound}"), Some(at), 1.0, 1.0);
}
return true;
}
let Some(kind) = self.res.mob_types().get("minecraft:iron_golem").cloned() else {
return false;
};
let id = self.next_entity + 1;
let mut random = self.world.random.clone();
let golem = villager::summon_golem(
&self.mob_world_base(&[]),
start,
&mut random,
|random, at, yaw| {
let mut m = Mob::new(id, kind.clone(), at, yaw);
villager::finalize_summoned_golem(&mut m, random);
m
},
);
self.world.random = random;
let Some(golem) = golem else {
return false;
};
self.next_entity = id;
let sound = golem.kind.sounds[0];
if !sound.is_empty() {
let at = golem.center();
self.audio
.play(&format!("minecraft:{sound}"), Some(at), 1.0, 1.0);
}
false
self.mobs.push(golem);
true
}
/// `VillagerMakeLove.tryToGiveBirth`: both eat, a free bed within 48
File diff suppressed because it is too large. Load diff
-6
View File
@@ -51,9 +51,6 @@ 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 {
@@ -208,7 +205,6 @@ impl SpriteAnimator {
jobs,
pipeline,
size,
changed: Vec::new(),
}
}
@@ -220,7 +216,6 @@ impl SpriteAnimator {
uploads: &mut Uploads,
tick: u64,
) {
self.changed.clear();
if self.collect(tick) == 0 {
return;
}
@@ -256,7 +251,6 @@ impl SpriteAnimator {
continue;
}
sprite.shown = Some(blend);
self.changed.push(sprite.animation.layer);
let job = [
sprite.layer,
sprite.frames[blend.frame],
+1 -8
View File
@@ -65,11 +65,8 @@ 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 {
let usage = wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::STORAGE;
Self::with_usage(device, stride, capacity, usage)
Self::with_usage(device, stride, capacity, wgpu::BufferUsages::VERTEX)
}
/// Sorted translucent indices; `storage` when compute passes read them.
pub fn indices(device: &wgpu::Device, capacity: u64, storage: bool) -> Self {
@@ -148,10 +145,6 @@ 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)
}
+46 -122
View File
@@ -108,14 +108,6 @@ 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.
@@ -126,8 +118,6 @@ 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,
@@ -271,76 +261,55 @@ impl Cull {
bind_group_layouts: &[Some(&layout), depth_layout.as_ref()],
..Default::default()
});
// `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,
})
// 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 color_target = [Some(wgpu::ColorTargetState {
format: color,
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})];
let (fragment_entry, targets, depth_compare) = match depth_test {
DepthTest::Early => (
"fs_box_early",
&color_target[..],
wgpu::CompareFunction::GreaterEqual,
),
DepthTest::InShader => ("fs_box", &[][..], wgpu::CompareFunction::Always),
};
let gl_pipeline = pipeline("Section culling, OpenGL layout", true);
let pipeline = pipeline("Section culling", false);
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,
});
Self {
pipeline,
gl_pipeline,
layout,
group: None,
depth_test,
@@ -586,51 +555,6 @@ 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,10 +52,6 @@ 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;
@@ -90,15 +86,6 @@ fn box_vertex(v: u32, 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) {
@@ -124,12 +111,3 @@ 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
},
+2 -13
View File
@@ -640,36 +640,26 @@ 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` binds what else a page's draws need (a
/// Java pack's terrain reads the page's vertices).
/// page's vertex buffer.
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; `page` as in
/// [`Self::draw`].
/// Draws phase 2's calls, as `flatten_phase2` left them.
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]
@@ -677,7 +667,6 @@ 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
@@ -1,663 +0,0 @@
//! 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,
}
/// 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);
}
}
@@ -1,552 +0,0 @@
//! A Horizon shader pack on the GPU (docs/shader-packs.md): each stage
//! (`shadow`, `gbuffer`, `deferred`, `composite`, `final`) is a full-screen
//! pass that reads the world's colour, depth, opaque depth, G-buffer normals
//! and materials, shadow map, noise texture and the previous stage's output,
//! and writes its own target; the last target goes to the screen through
//! `world_post` (copy or FXAA) under the hand and HUD. Only exists while a
//! pack is selected.
use crate::shader_pack::{Rgba, ShaderPack, VERTEX_ENTRY};
use horizon_shaderpack::uniforms::SmoothFloat;
/// The `Frame` uniform of the prelude (`shader_pack_prelude.wgsl`).
#[repr(C)]
#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
pub struct FrameUniform {
pub view: [[f32; 4]; 4],
pub projection: [[f32; 4]; 4],
pub inverse_view: [[f32; 4]; 4],
pub inverse_projection: [[f32; 4]; 4],
pub camera_position: [f32; 4],
pub resolution: [f32; 4],
pub time: [f32; 4],
pub sun: [f32; 4],
pub moon: [f32; 4],
pub sky_color: [f32; 4],
pub fog_color: [f32; 4],
pub fog: [f32; 4],
pub weather: [f32; 4],
pub camera_state: [f32; 4],
pub view_params: [f32; 4],
pub dimension: [f32; 4],
}
/// The API 2 `FrameExt` uniform of the prelude (`shader_pack_prelude.wgsl`).
#[repr(C)]
#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
pub struct FrameExtUniform {
pub previous_view: [[f32; 4]; 4],
pub previous_projection: [[f32; 4]; 4],
pub shadow_view: [[f32; 4]; 4],
pub shadow_projection: [[f32; 4]; 4],
pub shadow_inverse_view: [[f32; 4]; 4],
pub shadow_inverse_projection: [[f32; 4]; 4],
pub previous_camera: [f32; 4],
pub shadow_params: [f32; 4],
pub environment: [f32; 4],
}
/// Iris's `noisetex` without a `texture.noise`: 256² random RGB, opaque.
/// Horizon packs get the same one unless `[textures] noise` replaces it.
pub(super) fn noise() -> Rgba {
const SIZE: u32 = 256;
let mut seed = 0x2545_f491_u32;
let pixels = (0..SIZE * SIZE * 4)
.map(|i| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
if i % 4 == 3 { 255 } else { (seed >> 24) as u8 }
})
.collect();
Rgba {
width: SIZE,
height: SIZE,
pixels,
}
}
/// `image` as a sampled RGBA8 texture.
pub(super) fn rgba_view(
device: &wgpu::Device,
queue: &wgpu::Queue,
label: &str,
image: &Rgba,
) -> wgpu::TextureView {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: image.width,
height: image.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
texture.as_image_copy(),
&image.pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(image.width * 4),
rows_per_image: None,
},
texture.size(),
);
texture.create_view(&Default::default())
}
struct Pass {
pipeline: wgpu::RenderPipeline,
scale: f32,
target_format: wgpu::TextureFormat,
uniform: wgpu::Buffer,
uniform_ext: wgpu::Buffer,
}
/// A stage's output and the bindings that read the world into it.
struct Target {
size: [u32; 2],
view: wgpu::TextureView,
group: wgpu::BindGroup,
}
pub struct PackStages {
pub id: String,
pub needs_shadow: bool,
/// The stages read the G-buffer (`build_gbuffer`) or the depth before
/// translucent geometry (`capture_opaque_depth`).
pub needs_gbuffer: bool,
pub needs_opaque_depth: bool,
pub shadow_res: u32,
pub shadow_dist: f32,
layout: wgpu::BindGroupLayout,
linear: wgpu::Sampler,
nearest: wgpu::Sampler,
repeat: wgpu::Sampler,
shadow_sampler: wgpu::Sampler,
noise_view: wgpu::TextureView,
passes: Vec<Pass>,
/// Built for this world target size and `world_post` generation.
targets: Vec<Target>,
/// `world_post`'s bindings of the last stage's output.
output: Option<wgpu::BindGroup>,
world_size: [u32; 2],
post_generation: u64,
started: std::time::Instant,
last: Option<std::time::Instant>,
frames: u32,
previous: Option<([[f32; 4]; 4], [[f32; 4]; 4], [f32; 3])>,
wetness: SmoothFloat,
}
fn stage_format(name: Option<&str>, default: wgpu::TextureFormat) -> wgpu::TextureFormat {
match name.map(str::to_lowercase).as_deref() {
Some("rgba16float" | "rgba16f") => wgpu::TextureFormat::Rgba16Float,
Some("rgba8unorm" | "rgba8" | "rgba") => wgpu::TextureFormat::Rgba8Unorm,
Some("rgb10a2unorm" | "rgb10_a2") => wgpu::TextureFormat::Rgb10a2Unorm,
_ => default,
}
}
impl PackStages {
/// The pipelines of a loaded (already validated) pack, or wgpu's error.
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
pack: &ShaderPack,
) -> Result<Self, String> {
let texture = |binding, sample_type| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
};
let float = wgpu::TextureSampleType::Float { filterable: true };
let sampler = |binding, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(ty),
count: None,
};
let uniform_entry = |binding| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Shader pack stage"),
entries: &[
texture(0, float),
texture(1, wgpu::TextureSampleType::Depth),
texture(2, float),
sampler(3, wgpu::SamplerBindingType::Filtering),
sampler(4, wgpu::SamplerBindingType::NonFiltering),
uniform_entry(5),
uniform_entry(6),
texture(7, float),
texture(8, float),
texture(9, wgpu::TextureSampleType::Depth),
texture(10, wgpu::TextureSampleType::Depth),
sampler(11, wgpu::SamplerBindingType::Comparison),
texture(12, float),
sampler(13, wgpu::SamplerBindingType::Filtering),
],
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Shader pack stage"),
bind_group_layouts: &[Some(&layout)],
..Default::default()
});
let constants = pack.constants();
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let stage_count = pack.stages.len();
let passes = pack
.stages
.iter()
.enumerate()
.map(|(idx, stage)| {
let is_last = idx + 1 == stage_count;
let target_format = if is_last {
format
} else {
stage_format(stage.format.as_deref(), format)
};
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.file),
source: wgpu::ShaderSource::Wgsl(stage.source.as_str().into()),
});
// Only the overrides a stage declares may be passed to it.
let constants: Vec<(&str, f64)> = constants
.iter()
.copied()
.filter(|(key, _)| declares_override(&stage.source, key))
.collect();
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(&stage.file),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some(VERTEX_ENTRY),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(&stage.entry),
compilation_options: wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format: target_format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
Pass {
pipeline,
scale: stage.scale,
target_format,
uniform: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack frame"),
size: std::mem::size_of::<FrameUniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
uniform_ext: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Shader pack frame ext"),
size: std::mem::size_of::<FrameExtUniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
}
})
.collect();
if let Some(error) = pollster::block_on(scope.pop()) {
return Err(error.to_string());
}
let make_sampler = |filter, address, compare| {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Shader pack"),
address_mode_u: address,
address_mode_v: address,
address_mode_w: address,
mag_filter: filter,
min_filter: filter,
compare,
..Default::default()
})
};
let noise_view = match &pack.custom_noise {
Some(image) => rgba_view(device, queue, "Shader pack noise", image),
None => rgba_view(device, queue, "Shader pack noise", &noise()),
};
let (shadow_res, shadow_dist) = pack.shadow_config();
Ok(Self {
id: pack.id.clone(),
needs_shadow: pack.needs_shadow(),
needs_gbuffer: pack.needs_gbuffer(),
needs_opaque_depth: pack.needs_opaque_depth(),
shadow_res,
shadow_dist,
layout,
linear: make_sampler(
wgpu::FilterMode::Linear,
wgpu::AddressMode::ClampToEdge,
None,
),
nearest: make_sampler(
wgpu::FilterMode::Nearest,
wgpu::AddressMode::ClampToEdge,
None,
),
repeat: make_sampler(wgpu::FilterMode::Linear, wgpu::AddressMode::Repeat, None),
shadow_sampler: make_sampler(
wgpu::FilterMode::Linear,
wgpu::AddressMode::ClampToEdge,
Some(wgpu::CompareFunction::GreaterEqual),
),
noise_view,
passes,
targets: Vec::new(),
output: None,
world_size: [0, 0],
post_generation: u64::MAX,
started: std::time::Instant::now(),
last: None,
frames: 0,
previous: None,
wetness: SmoothFloat::default(),
})
}
/// Run every stage over the world's `color` and `depth` (`size`) and
/// return `post`'s bindings of the last stage's output.
#[allow(clippy::too_many_arguments)]
pub fn run(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
color: &wgpu::TextureView,
depth: &wgpu::TextureView,
size: [u32; 2],
post: &super::world_post::Post,
mut uniform: FrameUniform,
eye_light: [u8; 2],
) -> &wgpu::BindGroup {
if self.world_size != size
|| self.post_generation != post.generation()
|| self.targets.len() != self.passes.len()
{
self.world_size = size;
self.post_generation = post.generation();
self.targets.clear();
for pass in &self.passes {
let target_size = size.map(|v| ((v as f32 * pass.scale).round() as u32).max(1));
let view = device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Shader pack stage"),
size: wgpu::Extent3d {
width: target_size[0],
height: target_size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: pass.target_format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default());
let previous = self.targets.last().map_or(color, |t: &Target| &t.view);
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Shader pack stage"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(color),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 5,
resource: pass.uniform.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 6,
resource: pass.uniform_ext.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(post.normal()),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(post.material()),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(post.opaque_depth()),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(post.shadow_depth()),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::Sampler(&self.shadow_sampler),
},
wgpu::BindGroupEntry {
binding: 12,
resource: wgpu::BindingResource::TextureView(&self.noise_view),
},
wgpu::BindGroupEntry {
binding: 13,
resource: wgpu::BindingResource::Sampler(&self.repeat),
},
],
});
self.targets.push(Target {
size: target_size,
view,
group,
});
}
let last = &self.targets.last().expect("a pack has a stage").view;
self.output = Some(post.bind(device, last));
}
let now = std::time::Instant::now();
let dt = self.last.map_or(0.0, |t| (now - t).as_secs_f32());
self.last = Some(now);
let wet = self.wetness.update(uniform.weather[0], 60.0, 120.0, dt);
let cam = [
uniform.camera_position[0],
uniform.camera_position[1],
uniform.camera_position[2],
];
let (prev_view, prev_proj, prev_cam) =
self.previous
.unwrap_or((uniform.view, uniform.projection, cam));
self.previous = Some((uniform.view, uniform.projection, cam));
let shadow_view = post.shadow_view;
let shadow_proj = post.shadow_proj;
let ext = FrameExtUniform {
previous_view: prev_view,
previous_projection: prev_proj,
shadow_view: shadow_view.to_cols_array_2d(),
shadow_projection: shadow_proj.to_cols_array_2d(),
shadow_inverse_view: shadow_view.inverse().to_cols_array_2d(),
shadow_inverse_projection: shadow_proj.inverse().to_cols_array_2d(),
previous_camera: [prev_cam[0], prev_cam[1], prev_cam[2], dt],
shadow_params: [
post.shadow_resolution as f32,
post.shadow_distance,
0.0,
if post.shadow_active { 1.0 } else { 0.0 },
],
environment: [
f32::from(eye_light[0]) * 16.0,
f32::from(eye_light[1]) * 16.0,
wet,
0.0,
],
};
uniform.time[0] = self.started.elapsed().as_secs_f32();
uniform.time[1] = self.frames as f32;
self.frames = self.frames.wrapping_add(1);
for (pass, target) in self.passes.iter().zip(&self.targets) {
let [w, h] = target.size.map(|v| v as f32);
uniform.resolution = [w, h, 1.0 / w, 1.0 / h];
queue.write_buffer(&pass.uniform, 0, bytemuck::bytes_of(&uniform));
queue.write_buffer(&pass.uniform_ext, 0, bytemuck::bytes_of(&ext));
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &target.view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut render = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Shader pack stage"),
color_attachments: &attachments,
..Default::default()
});
render.set_pipeline(&pass.pipeline);
render.set_bind_group(0, &target.group, &[]);
render.draw(0..3, 0..1);
}
self.output.as_ref().expect("bound with the targets")
}
}
/// `override <key>` appears in the source (a declaration, not just a use).
fn declares_override(source: &str, key: &str) -> bool {
source.lines().any(|line| {
line.trim_start()
.strip_prefix("override")
.and_then(|rest| rest.trim_start().strip_prefix(key))
.is_some_and(|rest| rest.trim_start().starts_with([':', '=']))
})
}
#[cfg(test)]
mod tests {
#[test]
fn the_frame_uniform_matches_the_prelude() {
// 4 matrices and 12 vectors, as `struct Frame` in the prelude.
assert_eq!(std::mem::size_of::<super::FrameUniform>(), 4 * 64 + 12 * 16);
let prelude = crate::shader_pack::PRELUDE;
let body = prelude.split("struct Frame {").nth(1).unwrap();
let body = &body[..body.find('}').unwrap()];
assert_eq!(body.matches("mat4x4<f32>").count(), 4);
assert_eq!(body.matches("vec4<f32>").count(), 12);
}
#[test]
fn only_declared_overrides_are_passed() {
let source =
"override saturation: f32 = 1.0;\noverride vignette = true;\nlet x = saturation_2;";
assert!(super::declares_override(source, "saturation"));
assert!(super::declares_override(source, "vignette"));
assert!(!super::declares_override(source, "saturation_2"));
assert!(!super::declares_override(source, "haze"));
}
}
+12 -558
View File
@@ -1,163 +1,25 @@
//! Resolution scale, FXAA, and shader-pack G-buffer/shadow targets (Horizon
//! settings): the world renders into its own target at the scaled size, then
//! one full-screen pass copies or anti-aliases it onto the screen, under the
//! hand and HUD. For a shader pack that reads them, `Post` also keeps:
//! - `opaque_depth`: the depth before translucent terrain (water, stained
//! glass, ice) draws, a Java pack's `depthtex1`/`depthtex2`;
//! - `normal` and `material`, an api 2 pack's `scene_normal` and
//! `scene_material`: surface normals and material properties
//! reconstructed from depth and scene colour;
//! - `shadow_depth`, an api 2 pack's: the sun/moon shadow map and its
//! light-space `shadow_view` and `shadow_proj` matrices.
//!
//! With none of them on, the world renders straight to the screen as before.
use super::pack_stages::FrameUniform;
use glam::{Mat4, Vec3};
const GBUFFER_WGSL: &str = r#"
struct GbufferParams {
inverse_view: mat4x4<f32>,
inverse_projection: mat4x4<f32>,
resolution: vec4<f32>,
// x: eye block light (0..1), y: eye sky light (0..1), z: wetness (0..1), w: sun elevation
env: vec4<f32>,
}
@group(0) @binding(0) var world_color: texture_2d<f32>;
@group(0) @binding(1) var world_depth: texture_depth_2d;
@group(0) @binding(2) var opaque_depth: texture_depth_2d;
@group(0) @binding(3) var nearest_sampler: sampler;
@group(0) @binding(4) var<uniform> params: GbufferParams;
struct VsOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex
fn vs_gbuffer(@builtin(vertex_index) i: u32) -> VsOut {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: VsOut;
out.clip = vec4<f32>(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2<f32>(p.x, 1.0 - p.y);
return out;
}
struct GbufferOut {
@location(0) normal: vec4<f32>,
@location(1) material: vec4<f32>,
}
fn world_pos(uv: vec2<f32>, d: f32) -> vec3<f32> {
let v = params.inverse_projection * vec4<f32>(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, d, 1.0);
let view = v.xyz / max(v.w, 1e-6);
return (params.inverse_view * vec4<f32>(view, 1.0)).xyz;
}
@fragment
fn fs_gbuffer(in: VsOut) -> GbufferOut {
let d_all = textureSampleLevel(world_depth, nearest_sampler, in.uv, 0);
let d_opq = textureSampleLevel(opaque_depth, nearest_sampler, in.uv, 0);
let color = textureSampleLevel(world_color, nearest_sampler, in.uv, 0.0);
var out: GbufferOut;
if d_all <= 0.0 && d_opq <= 0.0 {
out.normal = vec4<f32>(0.5, 1.0, 0.5, 0.0);
out.material = vec4<f32>(0.0, params.env.y, 0.0, 1.0);
return out;
}
let d = select(d_all, d_opq, d_opq > 0.0);
let step_uv = params.resolution.zw;
let p0 = world_pos(in.uv, d);
let uv_r = clamp(in.uv + vec2<f32>(step_uv.x, 0.0), vec2<f32>(0.0), vec2<f32>(1.0));
let uv_l = clamp(in.uv - vec2<f32>(step_uv.x, 0.0), vec2<f32>(0.0), vec2<f32>(1.0));
let uv_d = clamp(in.uv + vec2<f32>(0.0, step_uv.y), vec2<f32>(0.0), vec2<f32>(1.0));
let uv_u = clamp(in.uv - vec2<f32>(0.0, step_uv.y), vec2<f32>(0.0), vec2<f32>(1.0));
let dr = textureSampleLevel(opaque_depth, nearest_sampler, uv_r, 0);
let dl = textureSampleLevel(opaque_depth, nearest_sampler, uv_l, 0);
let dd = textureSampleLevel(opaque_depth, nearest_sampler, uv_d, 0);
let du = textureSampleLevel(opaque_depth, nearest_sampler, uv_u, 0);
let dx = select(world_pos(uv_r, dr) - p0, p0 - world_pos(uv_l, dl), abs(dl - d) < abs(dr - d));
let dy = select(world_pos(uv_d, dd) - p0, p0 - world_pos(uv_u, du), abs(du - d) < abs(dd - d));
let raw_n = cross(dy, dx);
let n_len = length(raw_n);
let n = select(vec3<f32>(0.0, 1.0, 0.0), raw_n / max(n_len, 1e-6), n_len > 1e-6);
// Reversed depth: closer translucent geometry has larger depth than opaque geometry.
let is_translucent = select(0.0, 1.0, d_all > d_opq + 1e-5);
let luma = dot(color.rgb, vec3<f32>(0.2126, 0.7152, 0.0722));
let block_light = clamp(max(params.env.x, smoothstep(0.65, 0.98, luma)), 0.0, 1.0);
let roughness = select(clamp(0.85 - params.env.z * 0.45, 0.1, 1.0), 0.05, is_translucent > 0.5);
out.normal = vec4<f32>(n * 0.5 + 0.5, 1.0);
out.material = vec4<f32>(block_light, params.env.y, is_translucent, roughness);
return out;
}
"#;
#[repr(C)]
#[derive(Clone, Copy, Default, bytemuck::Pod, bytemuck::Zeroable)]
struct GbufferUniform {
inverse_view: [[f32; 4]; 4],
inverse_projection: [[f32; 4]; 4],
resolution: [f32; 4],
env: [f32; 4],
}
/// What the frame's shader pack reads beyond the world's colour and depth:
/// `Post` keeps these targets only while one does.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Extras {
pub opaque_depth: bool,
pub gbuffer: bool,
}
//! Resolution scale and FXAA (Horizon settings): the world renders into its
//! own target at the scaled size, then one full-screen pass copies or
//! anti-aliases it onto the screen, under the hand and HUD. With neither on,
//! the world renders straight to the screen.
struct WorldTarget {
size: [u32; 2],
extras: Extras,
color: wgpu::TextureView,
depth_tex: wgpu::Texture,
depth: wgpu::TextureView,
/// With `extras.opaque_depth` or `extras.gbuffer` (which reads it).
opaque_depth: Option<(wgpu::Texture, wgpu::TextureView)>,
gbuffer: Option<Gbuffer>,
group: wgpu::BindGroup,
opaque_captured: bool,
}
struct Gbuffer {
normal: wgpu::TextureView,
material: wgpu::TextureView,
group: wgpu::BindGroup,
}
struct ShadowTarget {
resolution: u32,
depth: wgpu::TextureView,
}
pub struct Post {
layout: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
nearest: wgpu::Sampler,
copy: wgpu::RenderPipeline,
fxaa: wgpu::RenderPipeline,
gbuffer_layout: wgpu::BindGroupLayout,
gbuffer_pipeline: wgpu::RenderPipeline,
gbuffer_uniform: wgpu::Buffer,
format: wgpu::TextureFormat,
/// World resolution relative to the window (0.5..=2.0).
pub scale: f32,
pub antialias: bool,
target: Option<WorldTarget>,
shadow: Option<ShadowTarget>,
/// Bumped whenever a target is made: bindings of the old ones are stale.
generation: u64,
fallback_shadow: wgpu::TextureView,
fallback_color: wgpu::TextureView,
pub shadow_view: Mat4,
pub shadow_proj: Mat4,
pub shadow_distance: f32,
pub shadow_resolution: u32,
pub shadow_active: bool,
}
impl Post {
@@ -225,192 +87,31 @@ impl Post {
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let nearest = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Horizon post nearest"),
mag_filter: wgpu::FilterMode::Nearest,
min_filter: wgpu::FilterMode::Nearest,
..Default::default()
});
let gbuffer_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon gbuffer"),
source: wgpu::ShaderSource::Wgsl(GBUFFER_WGSL.into()),
});
let gbuffer_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Horizon gbuffer layout"),
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 3,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 4,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let gbuffer_pipe_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Horizon gbuffer"),
bind_group_layouts: &[Some(&gbuffer_layout)],
..Default::default()
});
let gbuffer_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Horizon gbuffer"),
layout: Some(&gbuffer_pipe_layout),
vertex: wgpu::VertexState {
module: &gbuffer_shader,
entry_point: Some("vs_gbuffer"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &gbuffer_shader,
entry_point: Some("fs_gbuffer"),
compilation_options: Default::default(),
targets: &[
Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
}),
Some(wgpu::ColorTargetState {
format: wgpu::TextureFormat::Rgba8Unorm,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
}),
],
}),
multiview_mask: None,
cache: None,
});
let gbuffer_uniform = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Horizon gbuffer uniform"),
size: std::mem::size_of::<GbufferUniform>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let fallback_shadow = device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon fallback shadow"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default());
let fallback_color = device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon fallback gbuffer"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default());
Self {
copy: pipeline("fs_copy"),
fxaa: pipeline("fs_fxaa"),
layout,
sampler,
nearest,
gbuffer_layout,
gbuffer_pipeline,
gbuffer_uniform,
format,
scale: 1.0,
antialias: false,
target: None,
shadow: None,
generation: 0,
fallback_shadow,
fallback_color,
shadow_view: Mat4::IDENTITY,
shadow_proj: Mat4::IDENTITY,
shadow_distance: 128.0,
shadow_resolution: 2048,
shadow_active: false,
}
}
/// The world's colour and depth targets this frame, or `None` to draw
/// it straight to the screen. `pack`: a shader pack runs this frame,
/// reading `extras`.
/// it straight to the screen.
pub fn prepare(
&mut self,
device: &wgpu::Device,
window: [u32; 2],
pack: bool,
extras: Extras,
) -> Option<(wgpu::TextureView, wgpu::TextureView)> {
if self.scale == 1.0 && !self.antialias && !pack {
if self.scale == 1.0 && !self.antialias {
self.target = None;
self.shadow_active = false;
return None;
}
let size = window.map(|v| ((v as f32 * self.scale).round() as u32).clamp(1, 8192));
if self
.target
.as_ref()
.is_none_or(|t| t.size != size || t.extras != extras)
{
self.generation += 1;
if self.target.as_ref().is_none_or(|t| t.size != size) {
let extent = wgpu::Extent3d {
width: size[0],
height: size[1],
@@ -434,273 +135,26 @@ impl Post {
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
)
.create_view(&Default::default());
let depth_tex = make_tex(
// TEXTURE_BINDING: occlusion culling reads it.
let depth = make_tex(
"Scaled world depth",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
);
let depth = depth_tex.create_view(&Default::default());
let opaque_depth = (extras.opaque_depth || extras.gbuffer).then(|| {
let tex = make_tex(
"Opaque world depth",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
);
let view = tex.create_view(&Default::default());
(tex, view)
});
let gbuffer = opaque_depth
.as_ref()
.filter(|_| extras.gbuffer)
.map(|(_, opaque)| {
let color_target = |label| {
make_tex(
label,
wgpu::TextureFormat::Rgba8Unorm,
wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
)
.create_view(&Default::default())
};
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Horizon gbuffer"),
layout: &self.gbuffer_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&color),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(&depth),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(opaque),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 4,
resource: self.gbuffer_uniform.as_entire_binding(),
},
],
});
Gbuffer {
normal: color_target("World G-buffer normal"),
material: color_target("World G-buffer material"),
group,
}
});
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
)
.create_view(&Default::default());
let group = self.bind(device, &color);
self.target = Some(WorldTarget {
size,
extras,
color,
depth_tex,
depth,
opaque_depth,
gbuffer,
group,
opaque_captured: false,
});
}
if let Some(t) = &mut self.target {
t.opaque_captured = false;
}
self.target
.as_ref()
.map(|t| (t.color.clone(), t.depth.clone()))
}
/// Prepare the directional sun/moon shadow map target and compute its
/// camera-relative orthographic `shadow_view` and `shadow_proj` matrices.
pub fn prepare_shadow(
&mut self,
device: &wgpu::Device,
resolution: u32,
distance: f32,
sun_dir: Vec3,
moon_dir: Vec3,
) -> &wgpu::TextureView {
let resolution = resolution.clamp(256, 4096);
let distance = distance.clamp(16.0, 512.0);
if self
.shadow
.as_ref()
.is_none_or(|s| s.resolution != resolution)
{
let depth = device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Horizon shadow map"),
size: wgpu::Extent3d {
width: resolution,
height: resolution,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Depth32Float,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default());
self.shadow = Some(ShadowTarget { resolution, depth });
self.generation += 1;
}
let light_dir = if sun_dir.y >= -0.05 {
sun_dir.normalize_or_zero()
} else {
moon_dir.normalize_or_zero()
};
let dir = if light_dir.length_squared() > 1e-4 {
light_dir
} else {
Vec3::Y
};
let up = if dir.y.abs() > 0.99 { Vec3::Z } else { Vec3::Y };
self.shadow_view = Mat4::look_at_rh(dir * distance, Vec3::ZERO, up);
// Reversed-depth orthographic projection for the world's GreaterEqual
// pipelines (near maps to 1.0, far maps to 0.0), and standard 0..1
// projection for shader sampling.
self.shadow_proj = Mat4::orthographic_rh(
-distance,
distance,
-distance,
distance,
distance * 2.5,
0.05,
);
self.shadow_distance = distance;
self.shadow_resolution = resolution;
self.shadow_active = true;
&self.shadow.as_ref().expect("created above").depth
}
/// Snapshot `world_depth` into `opaque_depth` (`depthtex1`/`depthtex2`)
/// right before translucent water and weather draw.
pub fn capture_opaque_depth(&mut self, encoder: &mut wgpu::CommandEncoder) {
let Some(t) = &mut self.target else { return };
let Some((opaque, _)) = &t.opaque_depth else {
return;
};
encoder.copy_texture_to_texture(
t.depth_tex.as_image_copy(),
opaque.as_image_copy(),
t.depth_tex.size(),
);
t.opaque_captured = true;
}
/// Reconstruct the G-buffer `normal` and `material` textures after the
/// world pass finishes (with `Extras::gbuffer`).
pub fn build_gbuffer(
&mut self,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
frame: &FrameUniform,
eye_light: [u8; 2],
wetness: f32,
) {
if !self.target.as_ref().is_some_and(|t| t.opaque_captured) {
self.capture_opaque_depth(encoder);
}
let Some(t) = &self.target else { return };
let Some(gbuffer) = &t.gbuffer else { return };
let [w, h] = t.size.map(|v| v.max(1) as f32);
let uniform = GbufferUniform {
inverse_view: frame.inverse_view,
inverse_projection: frame.inverse_projection,
resolution: [w, h, 1.0 / w, 1.0 / h],
env: [
f32::from(eye_light[0]) / 15.0,
f32::from(eye_light[1]) / 15.0,
wetness,
frame.sun[1],
],
};
queue.write_buffer(&self.gbuffer_uniform, 0, bytemuck::bytes_of(&uniform));
let attachments = [
Some(wgpu::RenderPassColorAttachment {
view: &gbuffer.normal,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
}),
Some(wgpu::RenderPassColorAttachment {
view: &gbuffer.material,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
}),
];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Horizon gbuffer"),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.gbuffer_pipeline);
pass.set_bind_group(0, &gbuffer.group, &[]);
pass.draw(0..3, 0..1);
}
/// The world target's size, once `prepare` made one.
pub fn size(&self) -> Option<[u32; 2]> {
self.target.as_ref().map(|t| t.size)
}
/// Changes whenever `prepare` or `prepare_shadow` makes new targets:
/// bindings made from the views below must be made again.
pub fn generation(&self) -> u64 {
self.generation
}
/// The depth before translucent geometry (`capture_opaque_depth`), or
/// the world's depth when nothing reads it.
pub fn opaque_depth(&self) -> &wgpu::TextureView {
match &self.target {
Some(WorldTarget {
opaque_depth: Some((_, view)),
..
}) => view,
Some(t) => &t.depth,
None => &self.fallback_shadow,
}
}
/// Reconstructed world-space normals (`scene_normal`).
pub fn normal(&self) -> &wgpu::TextureView {
self.gbuffer().map_or(&self.fallback_color, |g| &g.normal)
}
/// Reconstructed material and light (`scene_material`).
pub fn material(&self) -> &wgpu::TextureView {
self.gbuffer().map_or(&self.fallback_color, |g| &g.material)
}
fn gbuffer(&self) -> Option<&Gbuffer> {
self.target.as_ref()?.gbuffer.as_ref()
}
/// The sun/moon shadow map (`shadow_depth` of api 2 packs).
pub fn shadow_depth(&self) -> &wgpu::TextureView {
self.shadow
.as_ref()
.map_or(&self.fallback_shadow, |s| &s.depth)
}
/// Bindings that copy (or anti-alias) `view` onto the screen in `finish_with`.
pub fn bind(&self, device: &wgpu::Device, view: &wgpu::TextureView) -> wgpu::BindGroup {
device.create_bind_group(&wgpu::BindGroupDescriptor {
+7 -9
View File
@@ -252,9 +252,6 @@ pub struct Graphics {
/// or behind other windows, and half the worker threads. Off by default;
/// it changes when frames are drawn, never what they show or the ticks.
pub battery: bool,
/// The Horizon shader pack in `shaderpacks/` (its folder name); empty:
/// none, the vanilla picture (docs/shader-packs.md).
pub shader_pack: String,
/// The graphics API (Windows only; see `GraphicsApi`).
pub api: GraphicsApi,
}
@@ -280,7 +277,6 @@ impl Default for Graphics {
resolution_scale: 100,
fxaa: false,
battery: false,
shader_pack: String::new(),
api: GraphicsApi::Auto,
}
}
@@ -332,7 +328,6 @@ impl Graphics {
"resolutionScale" => g.resolution_scale = number(g.resolution_scale).clamp(50, 200),
"fxaa" => g.fxaa = flag(g.fxaa),
"batteryMode" => g.battery = flag(g.battery),
"shaderPack" => g.shader_pack = value.to_string(),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"overlay" => {
g.overlay = match value {
@@ -348,7 +343,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\nshaderPack:{}\ngraphicsApi:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\ncullLeaves:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\nbatteryMode:{}\ngraphicsApi:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -375,7 +370,6 @@ impl Graphics {
self.resolution_scale,
bool_text(self.fxaa),
bool_text(self.battery),
self.shader_pack,
self.api.name(),
)
}
@@ -469,13 +463,17 @@ mod tests {
resolution_scale: 75,
fxaa: true,
battery: true,
shader_pack: "horizon-sample".into(),
api: GraphicsApi::Dx12,
};
assert_eq!(Graphics::parse(&g.serialize()), g);
assert_eq!(Graphics::parse(""), Graphics::default());
// The old menus' Simple / Advanced choice is ignored.
// The old menus' Simple / Advanced choice is ignored, and so is the
// shader pack older versions saved.
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
assert_eq!(
Graphics::parse("shaderPack:Pack.zip\n"),
Graphics::default()
);
assert_eq!(
Graphics::parse("workers:abc\nsky:maybe\ncullLeaves:maybe\nentityCulling:maybe\n"),
Graphics::default()
+1 -56
View File
@@ -39,7 +39,6 @@ pub enum H {
ResolutionScale,
Antialiasing,
BatteryMode,
ShaderPack,
GraphicsApi,
}
@@ -112,18 +111,13 @@ pub fn rows(level: Level) -> Vec<Row> {
"Waits for the display's refresh: no tearing, a little latency.",
);
let gui = v(Opt::GuiScale, None, "Size of menus and the HUD.");
let shader_pack = h(
H::ShaderPack,
High,
"Redraws the world with a pack from the shaderpacks folder. Off is vanilla.",
);
let preset = h(
H::Preset,
None,
"Sets the options below together. Changing one makes it Custom.",
);
let mut rows = match level {
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui, shader_pack],
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui],
Level::Advanced => vec![
Row::Header("Rendering"),
render,
@@ -251,7 +245,6 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"Lowers the frame rate when minimised, or idle too.",
),
shader_pack,
Row::Header("Preferences"),
v(Opt::ViewBobbing, None, "Sways the view while walking."),
v(Opt::ChunkFade, None, "Fades newly loaded chunks in."),
@@ -381,10 +374,6 @@ impl H {
H::Antialiasing => labelled("Anti-aliasing", if g.fxaa { "FXAA" } else { "Off" }),
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
H::ShaderPack => match g.shader_pack.as_str() {
"" => labelled("Shader Pack", "Off"),
id => labelled("Shader Pack", id),
},
H::Overlay => labelled(
"Performance Overlay",
match g.overlay {
@@ -527,7 +516,6 @@ impl Item {
| H::PresentMode
| H::Workers
| H::BatteryMode
| H::ShaderPack
| H::GraphicsApi
) | Item::Vanilla(
Opt::GuiScale
@@ -557,8 +545,6 @@ pub enum Action {
Changed,
OpenAdvanced,
OpenSimple,
/// The Shader Pack row: the Shader Packs page (where packs are picked).
OpenShaderPacks,
Done,
}
@@ -784,9 +770,6 @@ impl GraphicsUi {
}
return match wd.target {
Target::Button(action) => vec![Action::ButtonClick, action],
Target::Item(Item::Horizon(H::ShaderPack)) => {
vec![Action::ButtonClick, Action::OpenShaderPacks]
}
Target::Item(item) => {
if item.slider(s).is_some() {
self.dragging = Some(item);
@@ -994,44 +977,6 @@ mod tests {
);
}
#[test]
fn the_shader_pack_row_is_off_by_default_and_outside_presets() {
let mut s = Settings::default();
let row = Item::Horizon(H::ShaderPack);
assert_eq!(row.label(&s).plain(), "Shader Pack: Off");
assert!(
rows(Level::Advanced)
.iter()
.any(|r| matches!(r, Row::Setting(x) if x.item == row))
);
s.horizon.shader_pack = "horizon-sample".into();
assert_eq!(row.label(&s).plain(), "Shader Pack: horizon-sample");
// The row opens the Shader Packs page, where packs are picked.
let mut ui = GraphicsUi::default();
let find = |ui: &mut GraphicsUi| {
let (widgets, ..) = ui.layout(Level::Advanced, 854.0, 480.0);
widgets
.into_iter()
.find(|w| matches!(w.target, Target::Item(i) if i == row))
.unwrap()
};
ui.scroll = find(&mut ui).y - GraphicsUi::bounds(480.0).0 - 4.0;
let w = find(&mut ui);
assert_eq!(
ui.click(
Level::Advanced,
&mut s,
(w.x + 1.0, w.y + 1.0),
(854.0, 480.0),
[854, 480],
false,
false
),
[Action::ButtonClick, Action::OpenShaderPacks]
);
assert_eq!(s.horizon.shader_pack, "horizon-sample");
}
#[test]
fn presets_set_vanilla_and_horizon_options() {
let mut s = Settings::default();
+41 -1
View File
@@ -298,6 +298,7 @@ impl PlayerMotion {
blocking,
blocked: 0.0,
block_sound: false,
knocked: false,
totem: totem_ready,
totem_fired: false,
};
@@ -313,6 +314,7 @@ impl PlayerMotion {
hurt,
blocked: environment.blocked,
sound: environment.block_sound,
knockback: environment.knocked,
};
if environment.totem_fired {
h.totem_used = true;
@@ -373,6 +375,8 @@ pub(crate) struct Environment<'a> {
pub(crate) blocking: Option<Blocking>,
pub(crate) blocked: f32,
pub(crate) block_sound: bool,
/// `dealDefaultKnockback` ran: a fresh hit, not fully blocked.
pub(crate) knocked: bool,
/// LivingEntity.checkTotemDeathProtection: a totem is held / was used.
pub(crate) totem: bool,
pub(crate) totem_fired: bool,
@@ -435,6 +439,9 @@ pub struct BlockOutcome {
pub blocked: f32,
/// `BlocksAttacks.onBlocked` ran (the block sound plays).
pub sound: bool,
/// The hit knocks the player back (`dealDefaultKnockback`): not one in
/// the hurt cooldown, nor one the shield took whole.
pub knockback: bool,
}
struct MatchedSets;
impl horizon_world::blocking_rules::TypeMatchWorld for MatchedSets {
@@ -667,9 +674,11 @@ impl HurtWorld for Environment<'_> {
*self.sync_velocity = true;
Ok(())
}
/// The caller applies it: these sources carry no entity or position.
fn knockback(&mut self, _: &mut State, _: f32, _: bool) -> io::Result<()> {
self.knocked = true;
Ok(())
} // no entity or position on these sources
}
fn death_protection(&mut self, _: &mut State) -> io::Result<bool> {
if !self.totem
|| self
@@ -761,6 +770,37 @@ impl ServerPlayerHurtWorld for Environment<'_> {
#[cfg(test)]
mod tests {
#[test]
fn a_hit_in_the_hurt_cooldown_knocks_nobody_back() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let resources = crate::motion::MotionResources::load(&root, &assets).unwrap();
let mut player =
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
.unwrap();
let (health, rules) = (&resources.health, super::HealthRules::default());
let id = health.damage.id("minecraft:mob_attack").unwrap();
let mut hit = |amount| {
player
.hurt_blocking(health, rules, id, amount, None)
.unwrap()
};
let first = hit(1.0);
assert!(first.hurt && first.knockback);
// A stronger hit inside the cooldown hurts by the difference only
// (LivingEntity.hurtServer), with no dealDefaultKnockback.
let second = hit(2.0);
assert!(second.hurt && !second.knockback);
// A weaker one doesn't hurt at all.
let third = hit(1.5);
assert!(!third.hurt && !third.knockback);
}
#[test]
fn hurt_sounds_follow_damage_effects() {
// DamageEffects: HURT, THORNS, DROWNING, BURNING, POKING, FREEZING.
+1
View File
@@ -156,6 +156,7 @@ impl FoodWorld for TickWorld<'_> {
blocking: None,
blocked: 0.0,
block_sound: false,
knocked: false,
totem: false,
totem_fired: false,
};
+23 -15
View File
@@ -271,23 +271,15 @@ fn read_levels(t: Option<&Tag>) -> Levels {
return Vec::new();
};
// Pre-1.21.5 worlds wrap the map in {levels: {...}}.
if let Some(Tag::Compound(inner)) = map.get(&JavaString::from("levels")) {
return inner
.iter()
.filter_map(|(k, v)| {
Some((
Arc::from(k.to_string_lossy().as_str()),
int(v)?.clamp(1, 255) as u32,
))
})
.collect();
}
let map = match map.get(&JavaString::from("levels")) {
Some(Tag::Compound(inner)) => inner,
_ => map,
};
// The keys are enchantment Identifiers: `knockback` is `minecraft:knockback`.
map.iter()
.filter_map(|(k, v)| {
Some((
Arc::from(k.to_string_lossy().as_str()),
int(v)?.clamp(1, 255) as u32,
))
let id = horizon_world::datafix::dynamic::namespaced(&k.to_string_lossy());
Some((Arc::from(id), int(v)?.clamp(1, 255) as u32))
})
.collect()
}
@@ -863,6 +855,22 @@ mod tests {
assert_eq!(back.custom_name(), Some("Blade"));
}
#[test]
fn enchantment_ids_without_a_namespace_are_minecraft_ones() {
let levels = Tag::compound([
("knockback", Tag::Int(2)),
("minecraft:sharpness", Tag::Int(1)),
]);
let components = Tag::compound([("minecraft:enchantments", levels)]);
let (_, extra) = decode_components(Some(&components));
let stack = ItemStack {
extra,
..ItemStack::new("minecraft:wooden_sword", 1)
};
assert_eq!(stack.enchantment_level("minecraft:knockback"), 2);
assert_eq!(stack.enchantment_level("minecraft:sharpness"), 1);
}
#[test]
fn custom_name_reads_the_extra_spans() {
let name = Tag::compound([
+13 -13
View File
@@ -1051,7 +1051,7 @@ impl LootContext<'_> {
continue;
}
// LootPool: rolls.getInt (Mth.randomBetweenInclusive for uniform rolls).
let rolls = ctx.int_number(&pool["rolls"], random).max(0) as u32;
let rolls = LootContext::int_number(&pool["rolls"], random).max(0) as u32;
for _ in 0..rolls {
let mut produced = Vec::new();
ctx.choose(
@@ -1085,7 +1085,7 @@ impl LootContext<'_> {
fn enchantment_level(&self, id: &str) -> u32 {
self.held.map_or(0, |h| h.enchantment_level(id))
}
fn number<R: RandomSource>(&self, v: &Value, random: &mut R) -> f32 {
fn number<R: RandomSource>(v: &Value, random: &mut R) -> f32 {
match v {
Value::Number(n) => n.as_f64().unwrap_or(0.0) as f32,
Value::Object(o) => match o
@@ -1097,8 +1097,8 @@ impl LootContext<'_> {
o.get("value").and_then(Value::as_f64).unwrap_or(0.0) as f32
}
"minecraft:uniform" => {
let min = self.number(o.get("min").unwrap_or(&Value::Null), random);
let max = self.number(o.get("max").unwrap_or(&Value::Null), random);
let min = Self::number(o.get("min").unwrap_or(&Value::Null), random);
let max = Self::number(o.get("max").unwrap_or(&Value::Null), random);
// UniformGenerator.getFloat / getInt (Mth.nextInt) differ; use int for counts.
if max <= min {
min
@@ -1107,8 +1107,8 @@ impl LootContext<'_> {
}
}
"minecraft:binomial" => {
let n = self.number(o.get("n").unwrap_or(&Value::Null), random) as u32;
let p = self.number(o.get("p").unwrap_or(&Value::Null), random);
let n = Self::number(o.get("n").unwrap_or(&Value::Null), random) as u32;
let p = Self::number(o.get("p").unwrap_or(&Value::Null), random);
(0..n).filter(|_| random.next_float() < p).count() as f32
}
_ => 1.0,
@@ -1116,22 +1116,22 @@ impl LootContext<'_> {
_ => 1.0,
}
}
fn int_number<R: RandomSource>(&self, v: &Value, random: &mut R) -> i32 {
fn int_number<R: RandomSource>(v: &Value, random: &mut R) -> i32 {
if let Value::Object(o) = v
&& o.get("type")
.and_then(Value::as_str)
.unwrap_or("minecraft:uniform")
== "minecraft:uniform"
{
let min = self.int_number(o.get("min").unwrap_or(&Value::Null), random);
let max = self.int_number(o.get("max").unwrap_or(&Value::Null), random);
let min = Self::int_number(o.get("min").unwrap_or(&Value::Null), random);
let max = Self::int_number(o.get("max").unwrap_or(&Value::Null), random);
return if max <= min {
min
} else {
min + random.next_int_bound(max - min + 1).unwrap_or(0)
};
}
self.number(v, random).round() as i32
Self::number(v, random).round() as i32
}
fn condition<R: RandomSource>(&self, c: &Value, random: &mut R) -> bool {
let (kind, c) = match c {
@@ -1157,7 +1157,7 @@ impl LootContext<'_> {
.into_iter()
.flatten()
.all(|t| self.condition(t, random)),
"minecraft:random_chance" => random.next_float() < self.number(&c["chance"], random),
"minecraft:random_chance" => random.next_float() < Self::number(&c["chance"], random),
"minecraft:table_bonus" => {
// BonusLevelTableCondition: chances[min(level, len - 1)].
let level =
@@ -1378,7 +1378,7 @@ impl LootContext<'_> {
}
match f["type"].as_str().or(f["function"].as_str()).unwrap_or("") {
"minecraft:set_count" => {
let n = self.int_number(&f["count"], random);
let n = Self::int_number(&f["count"], random);
let base = if f["add"].as_bool() == Some(true) {
stack.count as i32
} else {
@@ -1421,7 +1421,7 @@ impl LootContext<'_> {
// Attacker's level x count, rounded (looting).
let level = self.enchantment_level(f["enchantment"].as_str().unwrap_or(""));
if level > 0 && self.killed_by_player && self.block.is_empty() {
let add = level as f32 * self.number(&f["count"], random);
let add = level as f32 * Self::number(&f["count"], random);
stack.count = (stack.count as i32 + add.round() as i32).max(0) as u32;
if let Some(limit) = f["limit"].as_i64().filter(|l| *l > 0) {
stack.count = stack.count.min(limit as u32);
-432
View File
@@ -1,432 +0,0 @@
//! Java (OptiFine/Iris) shader packs in `shaderpacks/` (docs/shader-packs.md):
//! 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. 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, GeometryProgram, Pass, Program, TargetSettings},
preprocess::Environment,
properties::{Blend, SkyDirectives},
uniforms::CustomUniforms,
};
use std::{
collections::{BTreeMap, BTreeSet},
path::Path,
};
/// The passes of one program folder (`world0`, or `""` for the pack's root).
pub struct JavaFolder {
pub folder: String,
pub targets: Vec<TargetSettings>,
/// `begin*`, `prepare*`, `deferred*`, `composite*`, in order.
pub passes: Vec<Program>,
/// How many of `passes` run before the world (`begin*`, `prepare*`): the
/// world's colour and depth enter `world_target` and `depthtex*` after them.
pub world_at: usize,
/// The colour target the world goes into, where the pack's terrain
/// program draws (MakeUp: colortex1).
pub world_target: u32,
/// The targets whose alternate side the world is written to.
pub flipped_after_prepare: BTreeSet<u32>,
pub final_pass: Option<Program>,
/// The targets whose alternate side holds the frame's last write.
pub flipped_at_end: BTreeSet<u32>,
/// `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 key of `texture.noise` (`noisetex`) in [`JavaPasses::custom_textures`].
pub const NOISE_KEY: &str = "noise";
/// The key of `texture.<stage>.<sampler>` in [`JavaPasses::custom_textures`]
/// for a pass of `program` (`world0/composite2`): Iris's texture stages, where
/// `final` reads the `composite` ones.
pub fn texture_key(program: &str, sampler: &str) -> String {
let name = program.rsplit('/').next().unwrap_or(program);
let stage = match name.trim_end_matches(|c: char| c.is_ascii_digit()) {
"final" => "composite",
stage => stage,
};
format!("{stage}.{sampler}")
}
/// A loaded Java pack.
pub struct JavaPasses {
pub folders: Vec<JavaFolder>,
/// `uniform.*` / `variable.*` from `shaders.properties`.
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>,
}
impl JavaPasses {
/// The folder Iris uses in `dimension` (0 overworld, -1 the Nether, 1 the
/// End): `world<N>` when the pack has one, else `world0`, else the root.
pub fn folder_for(&self, dimension: i8) -> Option<usize> {
let find = |name: &str| self.folders.iter().position(|f| f.folder == name);
find(&format!("world{dimension}"))
.or_else(|| find("world0"))
.or_else(|| find(""))
}
}
/// A folder holding `shaders/`, or a zip.
pub fn is_java_pack(path: &Path) -> bool {
path.join("shaders").is_dir()
|| path.is_file()
&& path
.extension()
.is_some_and(|e| e.eq_ignore_ascii_case("zip"))
}
/// The `MC_GL_VENDOR_*` name of a wgpu adapter (`AdapterInfo::vendor`, a
/// PCI id; Apple's GPUs report 0).
pub fn vendor_name(vendor: u32, adapter: &str) -> &'static str {
match vendor {
0x10de => "NVIDIA Corporation",
0x1002 | 0x1022 => "AMD",
0x8086 => "Intel",
_ if adapter.starts_with("Apple") => "Apple",
_ => "Unknown",
}
}
/// Open the pack at `path` and translate its full-screen passes with the
/// player's `values`; `vendor` and `renderer` name the GPU to the pack.
pub fn load(
path: &Path,
values: &BTreeMap<String, String>,
vendor: &str,
renderer: &str,
) -> Result<JavaPasses, String> {
let pack = JavaPack::open(path)?;
let values = pack.values(values);
let env = Environment::iris(vendor, renderer, 4);
let properties = pack.properties(&values, &env)?;
let mut names: Vec<&str> = ["world0", "world-1", "world1"]
.into_iter()
.filter(|d| {
pack.programs
.iter()
.any(|p| p.starts_with(&format!("{d}/")))
})
.collect();
if pack.programs.iter().any(|p| !p.contains('/')) {
names.push("");
}
let mut folders = Vec::new();
for name in names {
let plan = pack.passes(name, &values, &env, &properties)?;
let compile = |pass| {
let program = pack.compile(pass, &plan.targets, &values, &env, &properties)?;
if let Some((image, _)) = program.fragment.interface.images.first() {
return Err(format!(
"{}: images ({image}) aren't supported yet",
program.pass.program
));
}
Ok::<_, String>(program)
};
let early = plan.begin.iter().chain(&plan.prepare);
let passes = early
.clone()
.chain(&plan.deferred)
.chain(&plan.composite)
.map(compile)
.collect::<Result<Vec<_>, _>>()?;
let final_pass = plan.final_pass.as_ref().map(compile).transpose()?;
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 = |pass: &Option<horizon_shaderpack::pipeline::Pass>, alpha_test| {
let pass = pass.as_ref()?;
let glues = [false, true].map(|flat| crate::gfx::java_terrain::glue(flat, alpha_test));
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
}
}
};
// 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));
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);
// 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,
world_at: early.count(),
world_target: plan.world_target,
passes,
flipped_after_prepare: plan.flipped_after_prepare,
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,
});
}
if folders.is_empty() {
return Err("no deferred, composite or final programs".into());
}
let mut custom_textures = BTreeMap::new();
for tex in properties.custom_textures() {
let key = match tex.stage {
Some(stage) => format!("{stage}.{}", tex.sampler),
None if tex.sampler == NOISE_KEY => NOISE_KEY.to_owned(),
None => continue,
};
// Only PNGs for now: resource locations and raw `.dat` textures are
// skipped and the sampler reads what it would without them.
let image = pack
.files
.read_bytes(&tex.path)
.ok_or_else(|| "not in the pack".to_owned())
.and_then(|bytes| crate::shader_pack::decode_png(&bytes));
match image {
Ok(image) => {
custom_textures.insert(key, image);
}
Err(e) => log::warn!("Horizon: shader pack texture {}: {e}", tex.path),
}
}
let uniforms = properties.custom_uniforms();
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]),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn custom_textures_belong_to_iris_s_stages() {
assert_eq!(texture_key("world0/composite2", "gaux1"), "composite.gaux1");
assert_eq!(texture_key("final", "colortex4"), "composite.colortex4");
assert_eq!(
texture_key("world-1/deferred10", "noisetex"),
"deferred.noisetex"
);
assert_eq!(texture_key("prepare", "depthtex2"), "prepare.depthtex2");
}
#[test]
fn loads_the_full_screen_passes_of_a_java_pack() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../horizon-shaderpack/tests/fixtures/passes");
assert!(is_java_pack(&dir));
let pack = load(&dir, &BTreeMap::new(), "Apple", "Apple M4 Pro").unwrap();
let [folder] = &pack.folders[..] else {
panic!("one folder, the root");
};
assert_eq!(folder.folder, "");
assert_eq!(
folder
.passes
.iter()
.map(|p| p.pass.program.as_str())
.collect::<Vec<_>>(),
["deferred", "composite", "composite1"]
);
assert_eq!(folder.world_at, 0);
assert!(folder.final_pass.is_some());
// The overworld, the Nether and the End all use the root.
assert_eq!([-1, 0, 1].map(|d| pack.folder_for(d)), [Some(0); 3]);
assert_eq!(
vendor_name(0x10de, "NVIDIA GeForce RTX 4080"),
"NVIDIA Corporation"
);
assert_eq!(vendor_name(0, "Apple M4 Pro"), "Apple");
}
/// wgpu is given the WGSL text: it must parse again.
fn reparse_error(pack: &JavaPasses) -> Option<String> {
let passes = pack
.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));
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!(
"{}.{ext}: {}",
p.pass.program,
e.emit_to_string(&t.wgsl)
))
})
}
/// The packs in `HORIZON_TEST_SHADERPACKS` (folders or zips) load as the
/// game loads them, with their default options: the compatibility table
/// of docs/shader-packs.md. Skipped when the variable isn't set.
#[test]
fn real_packs_load() {
let Some(dir) = std::env::var_os("HORIZON_TEST_SHADERPACKS") else {
return;
};
let mut entries: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| is_java_pack(p))
.collect();
entries.sort();
let mut failures = Vec::new();
for path in entries {
let name = path.file_name().unwrap().to_string_lossy().into_owned();
match load(&path, &BTreeMap::new(), "Apple", "Apple M4 Pro") {
Ok(pack) if let Some(e) = reparse_error(&pack) => {
println!("{name}: {e}");
failures.push(name);
}
Ok(pack) => {
let folders: Vec<String> = pack
.folders
.iter()
.map(|f| {
let n = f.passes.len() + usize::from(f.final_pass.is_some());
let geometry: Vec<&str> = f
.terrain
.iter()
.chain(&f.water)
.map(|g| g.program.pass.program.as_str())
.collect();
format!("{} {n} passes, terrain {geometry:?}", f.folder)
})
.collect();
println!("{name}: loads ({})", folders.join(", "));
}
Err(e) => {
println!("{name}: {e}");
failures.push(name);
}
}
}
assert!(failures.is_empty(), "{failures:?}");
}
}
+4
View File
@@ -206,6 +206,9 @@ pub struct Guest {
/// (Entity.DATA_AIR_SUPPLY_ID, Player.DATA_PLAYER_ABSORPTION_ID); their
/// defaults until either changes.
pub sent_air: (i32, f32),
/// The shared flags (Entity.DATA_SHARED_FLAGS_ID) the player's client was
/// last sent about itself.
pub sent_shared: i8,
/// Where the player last died (Player.lastDeathLocation), sent with the respawn.
pub last_death: Option<[i32; 3]>,
/// The last action sequence to acknowledge at the next tick (-1: none).
@@ -336,6 +339,7 @@ impl Guest {
sent_health: None,
sent_xp: None,
sent_air: (300, 0.0),
sent_shared: 0,
last_death: None,
ack_up_to: std::cell::Cell::new(-1),
sent_selected: 0,
+29 -10
View File
@@ -1477,16 +1477,7 @@ impl Hub {
attributes: Vec<play::AttributeSnapshot>,
) -> EntityState {
let (sneaking, sprinting, gliding, sleeping) = flags;
let mut shared = 0i8;
if sneaking {
shared |= 0x02;
}
if sprinting {
shared |= 0x08;
}
if gliding {
shared |= -128;
}
let shared = shared_flags(sneaking, sprinting, gliding);
let pose = if sleeping {
2
} else if gliding {
@@ -1764,6 +1755,18 @@ impl Hub {
}));
}
}
// ServerEntity.sendDirtyEntityData: the shared flags go to the player
// too, so the sprint a hit stops (Player.causeExtraKnockback) stops
// on its client.
let g = &mut self.guests[i];
let shared = shared_flags(g.sneaking, g.sprinting, g.fall_flying);
if shared != g.sent_shared {
g.sent_shared = shared;
g.send(&C::SetEntityData(play::SetEntityData {
id: g.entity_id,
values: vec![(0, serializer::BYTE, Value::Byte(shared))],
}));
}
// Sent when the health or the food level changes, or the saturation
// reaches or leaves zero; a dead player's health of 0 too.
let g = &mut self.guests[i];
@@ -2480,6 +2483,7 @@ impl Hub {
g.sent_health = None;
g.sent_xp = None;
g.sent_air = (300, 0.0);
g.sent_shared = 0;
let (cx, cz) = g.chunk();
g.send(&C::SetChunkCacheCenter(play::SetChunkCacheCenter {
x: cx,
@@ -2519,6 +2523,21 @@ fn ticking_state(rate: f32, frozen: bool) -> C {
raw(ids::TICKING_STATE, w.data)
}
/// Entity.DATA_SHARED_FLAGS_ID of a player: crouching, sprinting, gliding.
fn shared_flags(sneaking: bool, sprinting: bool, gliding: bool) -> i8 {
let mut shared = 0i8;
if sneaking {
shared |= 0x02;
}
if sprinting {
shared |= 0x08;
}
if gliding {
shared |= -128;
}
shared
}
#[cfg(test)]
mod tests {
use super::*;
-2
View File
@@ -95,7 +95,6 @@ pub mod item_data;
pub mod item_render;
pub mod item_tooltip;
pub mod items;
pub mod java_pack;
pub mod java_text;
pub mod key_names;
pub mod keybinds;
@@ -159,7 +158,6 @@ pub mod screen_layout;
pub mod sculk;
pub use horizon_server_files as server;
pub mod settings;
pub mod shader_pack;
pub mod shadows;
pub mod signs;
pub mod skins;
+8
View File
@@ -987,6 +987,12 @@ pub enum MobEvent {
pos: [i32; 3],
state: horizon_world::registry::BlockStateId,
},
/// `DoorBlock.setOpen` by a mob (`InteractWithDoor`): the door opens or
/// closes, both halves, with its sound and game event.
SetDoorOpen {
pos: [i32; 3],
open: bool,
},
/// Destroy a block, dropping nothing (sheep eating grass becomes dirt via SetBlock).
BreakBlock {
pos: [i32; 3],
@@ -1602,6 +1608,8 @@ impl Mob {
| Species::Fox
| Species::Panda
);
// The PathNavigation constructor: the base follow range.
mob.ai.nav.max_visited_nodes = (mob.kind.follow_range * 16.0).floor() as i32;
ai::register(&mut mob);
mob
}
+4 -4
View File
@@ -405,7 +405,7 @@ pub fn tick(
}
// Mob.tickHeadTurn -> BodyRotationControl.clientTick (a frozen
// creaking's waits).
if m.alive() && !(super::creaking::is_creaking(m) && !super::creaking::can_move(m)) {
if m.alive() && (!super::creaking::is_creaking(m) || super::creaking::can_move(m)) {
m.body_rotation_tick();
}
events.append(&mut m.outbox);
@@ -752,8 +752,8 @@ impl Mob {
pub fn pushable(&self) -> bool {
self.alive()
&& !matches!(self.species(), Species::Bat | Species::Shulker)
&& !(self.kind.flags.climber && self.climbing)
&& !(self.species() == Species::Creaking && !super::creaking::can_move(self))
&& (!self.kind.flags.climber || !self.climbing)
&& (self.species() != Species::Creaking || super::creaking::can_move(self))
}
}
/// LivingEntity.onClimbable for the pusher (Spider.onClimbable: climbing a wall).
@@ -780,7 +780,7 @@ fn push_entities(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
let self_pushable = m.alive()
&& m.species() != Species::Shulker
&& !on_climbable(m, w)
&& !(m.species() == Species::Creaking && !super::creaking::can_move(m));
&& (m.species() != Species::Creaking || super::creaking::can_move(m));
let mut targets: Vec<(Who, Vec3)> = Vec::new();
if let Some(p) = &w.player
// Entity.push: passengers of the same vehicle never push each other.
+1 -1
View File
@@ -26,7 +26,7 @@ pub fn register(m: &mut Mob) {
};
m.ai.nav.kind = NavKind::Flying;
// FlyingPathNavigation.setRequiredPathLength(48).
m.ai.nav.required_path_length = 48.0;
m.set_required_path_length(48.0);
let g = &mut m.ai.goals;
g.add(0, Panic::new(2.5));
g.add(1, Fetch);
+2 -2
View File
@@ -138,7 +138,7 @@ pub fn register(m: &mut Mob) {
};
m.ai.nav.kind = NavKind::Flying;
m.ai.nav.can_open_doors = false;
m.ai.nav.required_path_length = 48.0;
m.set_required_path_length(48.0);
m.set_malus(PathType::Fire, -1.0);
m.set_malus(PathType::Water, -1.0);
m.set_malus(PathType::WaterBorder, 16.0);
@@ -1046,7 +1046,7 @@ impl Goal for GrowCrop {
}
fn can_use(&mut self, m: &mut Mob, cx: &mut Cx) -> bool {
// The 0.3 roll comes only after the crop count: it draws randomness.
let usable = !get(m).is_some_and(|b| b.crops_grown >= 10)
let usable = get(m).is_none_or(|b| b.crops_grown < 10)
&& m.random.next_float() >= 0.3
&& has_nectar(m)
&& hive_valid(m, cx.w);
@@ -26,7 +26,7 @@ pub const DROPPING_NO_ITEM: u8 = 4;
pub const WEATHER: [&str; 4] = ["unaffected", "exposed", "weathered", "oxidized"];
pub fn register(m: &mut Mob) {
m.ai.nav.required_path_length = 48.0;
m.set_required_path_length(48.0);
m.ai.nav.can_open_doors = true;
m.persistent = true;
// nextWeatheringTick starts unset (-1); waxed is -2.
@@ -464,6 +464,7 @@ pub fn finalize_spawn(m: &mut Mob, lr: &mut LegacyRandom) {
let bonus = lr.triangle(0.0, 0.114_850_000_000_000_01);
// ADD_MULTIPLIED_BASE on the base value.
m.attrs.follow_range += m.kind.follow_range * bonus;
m.update_max_visited_nodes();
let _left_handed = lr.next_float() < 0.05;
}
fn wrap_degrees(angle: f32) -> f32 {
+4 -1
View File
@@ -559,7 +559,10 @@ fn refresh(m: &mut Mob, a: Attr) {
match a {
Attr::MaxHealth => m.attrs.max_health = value(m, a) as f32,
Attr::MovementSpeed => m.attrs.speed = base_with_modifiers(m, a),
Attr::FollowRange => m.attrs.follow_range = value(m, a),
Attr::FollowRange => {
m.attrs.follow_range = value(m, a);
m.update_max_visited_nodes();
}
Attr::AttackDamage => m.attrs.attack_damage = value(m, a) as f32,
Attr::KnockbackResistance => m.attrs.knockback_resistance = value(m, a),
Attr::SpawnReinforcements => m.attrs.spawn_reinforcements = value(m, a),
@@ -39,6 +39,10 @@ fn setup(m: &mut Mob, baby: bool) {
m.ai.look.wanted = [0.0; 3];
m.ai.look.cooldown = 0;
m.extra.counter2 = 0;
// The new navigation's PathNavigation constructor.
m.ai.nav.required_path_length = 16.0;
m.ai.nav.max_visited_nodes =
(super::finalize::base_value(m, super::finalize::Attr::FollowRange) * 16.0).floor() as i32;
if baby {
m.ai.look.kind = LookKind::Default;
m.ai.mov.kind = MoveKind::Flying {
@@ -48,7 +52,7 @@ fn setup(m: &mut Mob, baby: bool) {
// BabyFlyingPathNavigation.
m.ai.nav.kind = NavKind::Flying;
m.ai.nav.can_float = true;
m.ai.nav.required_path_length = 48.0;
m.set_required_path_length(48.0);
return;
}
if was == 1 {
+3 -3
View File
@@ -167,9 +167,9 @@ impl MobWorld<'_> {
let Ok(block) = self.collision.collision_block(state) else {
continue;
};
if !block.large_collision_shape
&& !block.moving_piston
&& !(column && meets(Axis::Y, y))
if !(block.large_collision_shape
|| block.moving_piston
|| (column && meets(Axis::Y, y)))
{
continue;
}
+1 -1
View File
@@ -50,7 +50,7 @@ pub fn is_illager(s: &Seen) -> bool {
)
}
pub fn register(m: &mut Mob) {
m.ai.nav.required_path_length = 40.0;
m.set_required_path_length(40.0);
let tempt = m.kind.tempt.clone();
let trader = is_trader(m);
if trader {
@@ -677,7 +677,7 @@ impl B {
.is_some_and(|l| l.contains(&Who::Player));
!reachable
};
if !(can_attack(m, cx.w, target) && !tired && alive && !stop_when) {
if !can_attack(m, cx.w, target) || tired || !alive || stop_when {
set_attack_target(m, None);
}
*running = true;
+17 -2
View File
@@ -37,7 +37,11 @@ pub struct Navigation {
reach_range: i32,
pub max_visited_multiplier: f32,
pub stuck: bool,
/// Set through [`Mob::set_required_path_length`].
pub required_path_length: f32,
/// `PathFinder.maxVisitedNodes`: the base follow range times 16 when the
/// navigation is made, then [`Mob::update_max_visited_nodes`].
pub max_visited_nodes: i32,
pub can_float: bool,
pub can_open_doors: bool,
pub can_pass_doors: bool,
@@ -68,6 +72,7 @@ impl Default for Navigation {
max_visited_multiplier: 1.0,
stuck: false,
required_path_length: 16.0,
max_visited_nodes: 256,
can_float: false,
can_open_doors: false,
can_pass_doors: true,
@@ -147,6 +152,16 @@ impl Mob {
fn max_path_length(&self) -> f32 {
(self.attrs.follow_range as f32).max(self.ai.nav.required_path_length)
}
/// `PathNavigation.updatePathfinderMaxVisitedNodes`: after a follow range
/// change and a new required path length.
pub fn update_max_visited_nodes(&mut self) {
self.ai.nav.max_visited_nodes = (self.max_path_length() * 16.0).floor() as i32;
}
/// `PathNavigation.setRequiredPathLength`.
pub fn set_required_path_length(&mut self, length: f32) {
self.ai.nav.required_path_length = length;
self.update_max_visited_nodes();
}
fn can_update_path(&self) -> bool {
match self.ai.nav.kind {
NavKind::Ground | NavKind::WallClimber => self.on_ground || self.is_in_liquid(),
@@ -237,8 +252,8 @@ impl Mob {
return Some(path.clone());
}
let pm = self.path_mob();
let max_visited = ((self.attrs.follow_range * 16.0).floor() as f32
* self.ai.nav.max_visited_multiplier) as i32;
let max_visited =
(self.ai.nav.max_visited_nodes as f32 * self.ai.nav.max_visited_multiplier) as i32;
let finder = Finder::new(w, &pm, self.ai.nav.evaluator());
let path = finder.find(targets, max_len, reach, max_visited);
if let Some(p) = &path {
+1 -1
View File
@@ -426,7 +426,7 @@ impl Goal for MoveThroughVillage {
self.visited.remove(0);
}
let Some(s) = state(m) else { return false };
if !(s.active_raid && !s.raid_over) || m.ai.target.is_some() {
if !s.active_raid || s.raid_over || m.ai.target.is_some() {
return false;
}
let Some(homes) = s.homes.clone() else {
+139 -1
View File
@@ -46,7 +46,7 @@ pub fn register(m: &mut Mob) {
// Villager's constructor: its brain does everything, it has no goals.
m.ai.nav.can_open_doors = true;
m.ai.nav.can_float = true;
m.ai.nav.required_path_length = 48.0;
m.set_required_path_length(48.0);
}
/// `LivingEntity.addParticlesAroundSelf`: five particles about the body.
@@ -614,6 +614,144 @@ pub fn meet(
gossiped: ready,
}
}
/// `Villager.spawnGolemIfNeeded` up to the spawn: villager `i` wants a golem
/// and so do at least `needed` of the villagers whose boxes meet its own grown
/// by 10 blocks. Returns all of those villagers: once the golem stands, they
/// remember it ([`super::villager_brain::golem_detected`]).
pub fn golem_summoners(mobs: &[Mob], i: usize, now: i64, needed: usize) -> Option<Vec<usize>> {
if !ext(&mobs[i]).is_some_and(|e| e.wants_golem(now)) {
return None;
}
let area = mobs[i]
.bounds()
.inflate(horizon_core::geometry::Vec3::new(10.0, 10.0, 10.0));
let near: Vec<usize> = mobs
.iter()
.enumerate()
.filter(|(_, m)| {
m.species() == Species::Villager && !m.removed && m.bounds().intersects(area)
})
.map(|(j, _)| j)
.collect();
let wanting = near
.iter()
.filter(|&&j| ext(&mobs[j]).is_some_and(|e| e.wants_golem(now)))
.take(5)
.count();
(wanting >= needed).then_some(near)
}
/// `SpawnUtil.Strategy.LEGACY_IRON_GOLEM`'s refused floors.
fn golem_floor_ok(name: &str) -> bool {
!matches!(
name,
"minecraft:cobweb"
| "minecraft:cactus"
| "minecraft:glass_pane"
| "minecraft:conduit"
| "minecraft:ice"
| "minecraft:tnt"
| "minecraft:glowstone"
| "minecraft:beacon"
| "minecraft:sea_lantern"
| "minecraft:frosted_ice"
| "minecraft:tinted_glass"
| "minecraft:glass"
) && !name.ends_with("_stained_glass_pane")
&& !name.ends_with("_stained_glass")
&& !name.ends_with("leaves")
}
/// One attempt of `SpawnUtil.trySpawnMob(IRON_GOLEM, ..., 8 across, 6 up and
/// down, LEGACY_IRON_GOLEM)`: a column drawn near `start`, searched down from
/// 6 above for a solid floor (or powder snow) under air or a liquid block.
/// Returns the block the golem would stand in.
fn golem_attempt<L: super::spawn_rules::SpawnLevel>(
l: &L,
start: [i32; 3],
random: &mut impl RandomSource,
) -> Option<[i32; 3]> {
let dx = random.next_int_bound(17).unwrap_or(8) - 8;
let dz = random.next_int_bound(17).unwrap_or(8) - 8;
let (x, z) = (start[0] + dx, start[2] + dz);
let name = |y: i32| -> &str { &l.facts().get(l.state(x, y, z)).name };
let mut y = start[1] + 6;
let mut above = l.state(x, y, z);
for _ in 0..=12 {
y -= 1;
let here = l.state(x, y, z);
let above_name = &*l.facts().get(above).name;
// BlockState.liquid(): a water or lava block (not a waterlogged one).
let open = l.facts().is_air(above)
|| above_name == "minecraft:water"
|| above_name == "minecraft:lava";
let solid = l.mob_data().state(here).flags & super::data::SOLID != 0
|| name(y) == "minecraft:powder_snow";
if golem_floor_ok(name(y)) && open && solid {
return Some([x, y + 1, z]);
}
above = here;
}
None
}
/// `IronGolem.checkSpawnObstruction`: a floor with a full top face, its feet
/// free whatever fluid is there, and the two blocks above free.
fn golem_fits<L: super::spawn_rules::SpawnLevel>(l: &L, [x, y, z]: [i32; 3]) -> bool {
use super::spawn_rules::is_valid_empty_spawn;
// entityCanStandOn: Block.isFaceFull(collision shape, UP).
let floor = l.mob_data().state(l.state(x, y - 1, z));
let stands = floor.flags & super::data::STURDY_TOP != 0 && floor.top >= 1.0;
// isValidEmptySpawnBlock with Fluids.EMPTY: open water is free too.
let feet = is_valid_empty_spawn(Species::IronGolem, l, x, y, z)
|| &*l.facts().get(l.state(x, y, z)).name == "minecraft:water";
stands
&& feet
&& is_valid_empty_spawn(Species::IronGolem, l, x, y + 1, z)
&& is_valid_empty_spawn(Species::IronGolem, l, x, y + 2, z)
}
/// `SpawnUtil.trySpawnMob(IRON_GOLEM, MOB_SUMMONED, level, start, 10, 8, 6,
/// LEGACY_IRON_GOLEM, false)`: up to ten columns tried. In each spot found
/// `create` makes the golem (`EntityType.create`: the yaw drawn here, then its
/// `finalizeSpawn` draws, see [`finalize_summoned_golem`]); it is kept only
/// when `IronGolem.checkSpawnObstruction` passes, and discarded otherwise.
pub fn summon_golem<L: super::spawn_rules::SpawnLevel, R: RandomSource, T>(
l: &L,
start: [i32; 3],
random: &mut R,
mut create: impl FnMut(&mut R, horizon_core::geometry::Vec3, f32) -> T,
) -> Option<T> {
for _ in 0..10 {
let Some([x, y, z]) = golem_attempt(l, start, random) else {
continue;
};
// Mth.wrapDegrees(nextFloat() * 360).
let mut yaw = random.next_float() * 360.0;
if yaw >= 180.0 {
yaw -= 360.0;
}
let at =
horizon_core::geometry::Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5);
let golem = create(random, at, yaw);
if golem_fits(l, [x, y, z]) {
return Some(golem);
}
}
None
}
/// `Mob.finalizeSpawn` for a summoned iron golem, from the level's random:
/// the follow range spawn bonus and left-handedness.
pub fn finalize_summoned_golem(m: &mut Mob, random: &mut impl RandomSource) {
use super::finalize::{Attr, Op, add_modifier, has_modifier};
if !has_modifier(m, Attr::FollowRange, "minecraft:random_spawn_bonus") {
let bonus = random.triangle(0.0, 0.114_850_000_000_000_01);
add_modifier(
m,
Attr::FollowRange,
"minecraft:random_spawn_bonus",
bonus,
Op::AddMultipliedBase,
);
}
m.extra.left_handed = random.next_float() < 0.05;
}
/// `PoiManager.take(HOME, ..., 48)`: the nearest bed head not `claimed`
/// (`free_head`: a bed head that is not occupied).
pub fn take_vacant_bed(
@@ -779,6 +779,14 @@ fn sense_golem(m: &mut Mob, w: &MobWorld) {
}
}
/// `GolemSensor.golemDetected`: a golem was summoned near this villager;
/// for 599 ticks it wants no other.
pub fn golem_detected(m: &mut Mob, now: i64) {
mem(m).golem_detected_recently.set_ttl(true, 599);
// What `sync` derives from the memory.
super::villager::ext_mut(m).golem_until = now + 599 + 1;
}
// ---- Behaviours ---------------------------------------------------------
/// The POI types villagers use.
@@ -2252,6 +2260,7 @@ impl B {
}
t.begin(cx.lr, cx.now);
if now > *next_ok {
close_doors(m, cx, None, None);
if super::villager::start_sleeping(m, home, w) {
mem(m).last_slept.set(now);
}
@@ -2768,8 +2777,8 @@ impl B {
let on_or_over = jumpable(here) || jumpable([here[0], here[1] - 1, here[2]]);
let still = m.is_baby()
&& bed.is_some_and(jumpable)
&& !(!on_or_over && *time_left <= 0)
&& !(on_or_over && *jumps <= 0);
&& (on_or_over || *time_left > 0)
&& (!on_or_over || *jumps > 0);
if !still {
self.do_stop(m, cx);
return;
@@ -3162,7 +3171,7 @@ fn follow_trading_condition(m: &Mob, w: &MobWorld) -> bool {
m.extra.flag2
&& m.alive()
&& !m.fluids.in_water
&& !(m.hurt_time > 0)
&& m.hurt_time <= 0
&& w.player
.as_ref()
.is_some_and(|p| m.distance_sqr(p.position) <= 16.0)
@@ -3492,7 +3501,74 @@ fn walk_to_block_memory(
}
/// `InteractWithDoor`: open the doors on the path, close the ones passed.
fn interact_with_doors(m: &mut Mob, cx: &mut Bx, from: PathNode, to: PathNode) {
let _ = (m, cx, from, to);
let (from, to) = ([from.x, from.y, from.z], [to.x, to.y, to.z]);
// The door being left is remembered even when it is open already; the
// one ahead only when this villager opens it.
for (p, always) in [(from, true), (to, false)] {
let Some(open) = door_open(cx.w, p) else {
continue;
};
if !open {
cx.events.push(MobEvent::SetDoorOpen { pos: p, open: true });
}
if always || !open {
remember_door(m, p);
}
}
close_doors(m, cx, Some(from), Some(to));
}
/// Whether the block at `p` is a door mobs open (`MOB_INTERACTABLE_DOORS`), and
/// then whether it is open.
fn door_open(w: &MobWorld, p: [i32; 3]) -> Option<bool> {
let name = w.block_name(p[0], p[1], p[2]);
(name.ends_with("_door") && w.has_tag("minecraft:mob_interactable_doors", name))
.then(|| w.prop(p[0], p[1], p[2], "open") == Some("true"))
}
fn remember_door(m: &mut Mob, p: [i32; 3]) {
let doors = &mut mem(m).doors_to_close;
match doors.get_mut() {
Some(list) if list.contains(&p) => {}
Some(list) => list.push(p),
None => doors.set(vec![p]),
}
}
/// `InteractWithDoor.closeDoorsThatIHaveOpenedOrPassedThrough`: the remembered
/// doors other than the two of the step being taken are forgotten, and closed
/// when still open, near (3 blocks) and no other villager within 2 blocks of
/// one is passing through it.
fn close_doors(m: &mut Mob, cx: &mut Bx, from: Option<[i32; 3]>, to: Option<[i32; 3]>) {
let Some(doors) = mem(m).doors_to_close.get().cloned() else {
return;
};
let near = mem(m).mobs.value().unwrap_or_default();
let mut kept = Vec::new();
for door in doors {
if from == Some(door) || to == Some(door) {
kept.push(door);
continue;
}
if !closer_to_center(door, m.position, 3.0) || door_open(cx.w, door) != Some(true) {
continue;
}
let passing = near.iter().any(|&who| {
let Who::Mob(id) = who else { return false };
cx.w.seen(id).is_some_and(|s| {
s.species == Species::Villager
&& closer_to_center(door, s.position, 2.0)
&& s.villager
.as_ref()
.and_then(|v| v.path_nodes)
.is_some_and(|(prev, next)| prev.is_some_and(|p| p == door || next == door))
})
});
if !passing {
cx.events.push(MobEvent::SetDoorOpen {
pos: door,
open: false,
});
}
}
mem(m).doors_to_close.set(kept);
}
/// The scan order of POI candidates (before the nearest-first sort).
fn pois_scan_order(_m: &Mob, found: &[(Poi, [i32; 3])]) -> Vec<usize> {
+1 -1
View File
@@ -457,7 +457,7 @@ pub fn can_receive_vibration(m: &Mob, _now: i64, ctx: &Context) -> bool {
&& m.alive()
&& !st.brain.mem.vibration_cooldown.present()
&& !digging_or_emerging(m)
&& !(ctx.living && !ctx.targetable)
&& (!ctx.living || ctx.targetable)
}
/// `VibrationSystem.Listener.handleGameEvent` for the warden: a free
/// listener within 16 blocks of its eyes schedules what it can hear.
+128 -39
View File
@@ -296,6 +296,10 @@ pub struct PlayerMotion {
pub freeze_immune: bool,
/// `Entity.oldPosition`: where this tick started.
pub old_position: Vec3,
/// The server's copy of a player another client moves (a LAN guest), while
/// it travels on the server: `isLocalInstanceAuthoritative` is false, so
/// its moves check no fall damage.
pub server_copy: bool,
/// `Entity.applyEffectsFromBlocks` runs after travel (always in the game;
/// differential fixtures turn it off where their reference does).
pub block_effects: bool,
@@ -460,6 +464,7 @@ impl PlayerMotion {
leather_boots: false,
freeze_immune: false,
old_position: position,
server_copy: false,
block_effects: true,
powder_snow_entered: Vec::new(),
hurt_log: Vec::new(),
@@ -467,6 +472,16 @@ impl PlayerMotion {
border: None,
})
}
/// LivingEntity.aiStep: speeds too small to matter stop.
fn drop_tiny_velocity(&mut self) {
let v = &mut self.velocity;
if v.x * v.x + v.z * v.z < 9.0e-6 {
(v.x, v.z) = (0.0, 0.0);
}
if v.y.abs() < 0.003 {
v.y = 0.0;
}
}
/// `Entity.getTicksRequiredToFreeze`.
pub const TICKS_REQUIRED_TO_FREEZE: i32 = 140;
/// `Entity.getPercentFrozen`.
@@ -920,15 +935,7 @@ impl MotionWorld<'_, '_, '_> {
if self.player.no_jump_delay > 0 {
self.player.no_jump_delay -= 1;
}
let mut velocity = self.player.velocity;
if velocity.x * velocity.x + velocity.z * velocity.z < 9.0e-6 {
velocity.x = 0.0;
velocity.z = 0.0;
}
if velocity.y.abs() < 0.003 {
velocity.y = 0.0;
}
self.player.velocity = velocity;
self.player.drop_tiny_velocity();
let mut input = match input {
TickInput::Living(v) => Vec3::new(
f64::from(v[0] * 0.98),
@@ -947,9 +954,38 @@ impl MotionWorld<'_, '_, '_> {
}
self.jump_phase()?;
let before_travel = self.player.bounds();
// Player.travel: swim steering, then LivingEntity.travel (fluids
// first, then gliding, then air), keeping 0.6 of the vertical speed
// while flying.
self.player_travel(input)?;
if self.player.block_effects {
self.apply_effects_from_blocks()?;
}
// LivingEntity.aiStep "freezing" (server).
if !self.player.in_powder_snow || !self.player.can_freeze() {
self.player.ticks_frozen = (self.player.ticks_frozen - 2).max(0);
}
self.player.remove_frost()?;
let on = self.support(movement_support::LEGACY_SUPPORT_OFFSET)?;
let on_air = self.assets.registry.state(self.block(on)).is_none_or(|s| {
matches!(
s.name.as_str(),
"minecraft:air" | "minecraft:cave_air" | "minecraft:void_air"
)
});
self.player.try_add_frost(on_air)?;
if self.player.health.tick_count % 40 == 0
&& self.player.is_fully_frozen()
&& self.player.can_freeze()
{
self.freeze_damage()?;
}
if self.player.auto_spin_attack_ticks > 0 {
self.player.auto_spin_attack_ticks -= 1;
self.check_auto_spin_attack(before_travel);
}
Ok(())
}
/// Player.travel: swim steering, then LivingEntity.travel (fluids first,
/// then gliding, then air), keeping 0.6 of the vertical speed while flying.
fn player_travel(&mut self, input: Vec3) -> Result<()> {
if self.player.swimming {
let look_y =
horizon_world::view_direction::view_vector(self.player.pitch, self.player.yaw).y;
@@ -979,34 +1015,29 @@ impl MotionWorld<'_, '_, '_> {
if let Some(y) = flying_y {
self.player.velocity.y = y * 0.6;
}
if self.player.block_effects {
self.apply_effects_from_blocks()?;
}
// LivingEntity.aiStep "freezing" (server).
if !self.player.in_powder_snow || !self.player.can_freeze() {
self.player.ticks_frozen = (self.player.ticks_frozen - 2).max(0);
}
self.player.remove_frost()?;
let on = self.support(movement_support::LEGACY_SUPPORT_OFFSET)?;
let on_air = self.assets.registry.state(self.block(on)).is_none_or(|s| {
matches!(
s.name.as_str(),
"minecraft:air" | "minecraft:cave_air" | "minecraft:void_air"
)
});
self.player.try_add_frost(on_air)?;
if self.player.health.tick_count % 40 == 0
&& self.player.is_fully_frozen()
&& self.player.can_freeze()
{
self.freeze_damage()?;
}
if self.player.auto_spin_attack_ticks > 0 {
self.player.auto_spin_attack_ticks -= 1;
self.check_auto_spin_attack(before_travel);
}
Ok(())
}
/// ServerPlayer's own travel: the server simulates a player too
/// (`MoveSimulationType.AUTHORITATIVE_SIDE_AND_SERVER`), with no input
/// (it never sets xxa/zza). Only the delta movement it leaves stays: it
/// is what a hurt sends the player (`ServerEntity.sendChanges`), and
/// what the next knockback starts from; the client's next move places
/// the player.
pub fn server_travel(&mut self) -> Result<()> {
let p = &mut *self.player;
let (position, ground, collision) = (p.position, p.ground, p.collision);
let (history, fall_distance, steps) = (p.history.clone(), p.fall_distance, p.steps);
let events = p.events.len();
p.drop_tiny_velocity();
p.server_copy = true;
let travelled = self.player_travel(Vec3::ZERO);
let p = &mut *self.player;
p.server_copy = false;
(p.position, p.ground, p.collision) = (position, ground, collision);
(p.history, p.fall_distance, p.steps) = (history, fall_distance, steps);
p.events.truncate(events);
travelled
}
/// `LivingEntity.checkAutoSpinAttack`: the first living entity in the
/// box swept this tick takes the hit and ends the spin, bouncing the
/// player back at a fifth of its speed; a wall ends it too.
@@ -1678,7 +1709,7 @@ impl EntityMoveWorld for MotionWorld<'_, '_, '_> {
&mut self.player.history
}
fn locally_authoritative(&mut self) -> Result<bool> {
Ok(true)
Ok(!self.player.server_copy)
}
fn set_on_ground_with_movement(
&mut self,
@@ -2327,6 +2358,64 @@ mod tests {
assert_eq!(player.position.y, 4.0);
}
#[test]
fn server_travel_moves_only_the_velocity() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let mut scene = World::test_scene(&assets, &render, 1).unwrap();
let at = Vec3::new(20.5, 4.0, 20.5);
let mut player = PlayerMotion::new(&resources, at).unwrap();
let mut travel = |player: &mut PlayerMotion| {
MotionWorld {
entities: &[],
player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
}
.server_travel()
.unwrap()
};
// A knockback on the ground (vanilla's server sends 0.21840 0.27516
// after a zombie's hit): ground friction, then gravity and drag.
player.ground.on_ground = true;
player.velocity = Vec3::new(-0.4, 0.3608, 0.0);
travel(&mut player);
assert!(
(player.velocity.x + 0.2184).abs() < 1e-6,
"{:?}",
player.velocity
);
assert!(
(player.velocity.y - 0.275184).abs() < 1e-6,
"{:?}",
player.velocity
);
assert_eq!(player.position, at);
assert!(player.ground.on_ground);
// Falling onto the ground hurts no one on the server's copy, and leaves
// the fall to the client's moves.
let health = player.health.health();
player.position = Vec3::new(20.5, 4.5, 20.5);
player.ground.on_ground = false;
player.fall_distance = 10.0;
player.velocity = Vec3::new(0.0, -1.0, 0.0);
travel(&mut player);
assert_eq!(player.velocity.y, -0.0784000015258789);
assert_eq!(player.health.health(), health);
assert_eq!(player.fall_distance, 10.0);
assert!(player.events.is_empty());
}
#[test]
fn spectator_scroll_speed_controls_horizontal_and_vertical_flight() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
+7 -587
View File
@@ -10,7 +10,6 @@ use crate::{
keybinds::{self, CATEGORIES, KeyBinds, MAPPINGS},
lang::{self, LanguageInfo},
settings::{SOUND_SOURCES, Settings},
shader_pack::PackConfig,
text::{self, Style, Text},
ui,
};
@@ -863,10 +862,6 @@ pub enum Page {
Font,
Packs,
Mods,
/// The shader packs in `shaderpacks/`: pick one, open the folder.
ShaderPacks,
/// Interactive options and profiles for the active shader pack.
ShaderPackOptions,
/// Horizon's graphics settings (`graphics_ui`).
Graphics,
GraphicsAdvanced,
@@ -898,17 +893,9 @@ enum Act {
ResetPadKeys,
ToggleMod(usize),
OpenModFolder,
/// Pick shader pack `i` of `OptionsUi::shader_packs` (again: off).
ToggleShaderPack(usize),
OpenShaderPackFolder,
/// Credits & Attribution's Credits button (WinScreen without the poem).
Credits,
CopyAgentDetails,
ShaderPackProfile,
ShaderPackSubscreen(String),
ShaderPackBackScreen,
ShaderPackOption(usize),
ResetShaderPackOptions,
}
enum WKind {
Button {
@@ -921,11 +908,6 @@ enum WKind {
value: f32,
opt: Opt,
},
ShaderOptionSlider {
label: Text,
value: f32,
index: usize,
},
Text {
text: Text,
color: u32,
@@ -961,15 +943,10 @@ pub enum Event {
Packs(Vec<String>),
OpenPackFolder,
OpenModFolder,
/// Create `shaderpacks/` if needed and open it.
OpenShaderPackFolder,
/// Roll the credits (`CreditsAndAttributionScreen.openCreditsScreen`).
Credits,
/// Copy how an agent connects (the AI Agent page).
CopyAgentDetails,
/// The shader pack settings page closed with changes: they are saved,
/// the pack is built again with them.
ReloadShaderPack,
}
/// `SliderableValueSet.applyValueImmediately`: these apply 600 ms after
/// the last change (`OptionInstanceSliderButton.delayedApplyAt`).
@@ -1021,19 +998,8 @@ pub struct OptionsUi {
/// Scroll the selected language into the middle on the next layout.
center_language: bool,
pub graphics: crate::graphics_ui::GraphicsUi,
/// `shaderpacks/` and its packs, read while the Shader Packs page shows.
pub shader_packs: crate::shader_pack::Folder,
/// The pack that failed to load last and why (set by the game).
pub shader_pack_error: Option<(String, String)>,
/// The AI Agent page's status lines (set by the game while it shows).
pub agent_status: Vec<String>,
/// Loaded interactive configuration for the currently selected shader pack.
pub shader_pack_config: Option<PackConfig>,
/// The shader pack option slider being dragged.
pub(crate) dragging_shader_option: Option<usize>,
/// `shader_pack_config` changed since the settings page opened: leaving
/// it saves the values and reloads the pack.
shader_pack_edited: bool,
}
impl Default for OptionsUi {
fn default() -> Self {
@@ -1062,12 +1028,7 @@ impl Default for OptionsUi {
in_world: false,
center_language: false,
graphics: Default::default(),
shader_packs: Default::default(),
shader_pack_error: None,
agent_status: Vec::new(),
shader_pack_config: None,
dragging_shader_option: None,
shader_pack_edited: false,
}
}
}
@@ -1114,14 +1075,11 @@ fn option_widget(s: &Settings, opt: Opt, x: f32, y: f32, w: f32) -> Widget {
/// What a button opening one of Horizon's pages says on hover.
fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Some(match page {
Page::Horizon => {
"Horizon's own settings: graphics, shader packs, controllers, extras and mods."
}
Page::Horizon => "Horizon's own settings: graphics, controllers, extras and mods.",
Page::Graphics => "Graphics, simply: each setting says what it costs.",
Page::GraphicsAdvanced => {
"Every graphics setting, Horizon's included, with what each costs."
}
Page::ShaderPacks => "Packs that redraw the world. Off is the vanilla look.",
Page::Controller => "Controllers: their buttons, sticks, vibration and menu navigation.",
Page::Extras => {
"Optional additions, all off by default: the game stays vanilla until you turn one on."
@@ -1196,105 +1154,6 @@ impl OptionsUi {
self.graphics.reset();
}
}
/// Pick shader pack `i` of the folder, or turn it off when it is the
/// one running.
pub(crate) fn toggle_shader_pack(&mut self, s: &mut Settings, i: usize) {
let Some((id, Ok(_))) = self.shader_packs.packs.get(i) else {
return;
};
let g = &mut s.horizon;
g.shader_pack = if g.shader_pack == *id {
String::new()
} else {
id.clone()
};
self.sync_shader_pack_config(s);
}
/// Ensure `shader_pack_config` matches `s.horizon.shader_pack`.
pub fn sync_shader_pack_config(&mut self, s: &Settings) {
let id = s.horizon.shader_pack.as_str();
if id.is_empty() {
self.shader_pack_config = None;
return;
}
if self
.shader_pack_config
.as_ref()
.is_some_and(|cfg| cfg.id == id)
{
return;
}
// A pack whose options can't be read has none, rather than being
// read again every frame.
let config = PackConfig::load(&self.shader_pack_dir(), id).unwrap_or_else(|e| {
log::warn!("Horizon: shader pack {id} options: {e}");
PackConfig {
id: id.to_owned(),
..Default::default()
}
});
self.shader_pack_config = Some(config);
self.shader_pack_edited = false;
}
/// The picked pack has options or profiles to show.
fn shader_pack_has_settings(&self, s: &Settings) -> bool {
self.shader_pack_config.as_ref().is_some_and(|c| {
!s.horizon.shader_pack.is_empty()
&& c.id == s.horizon.shader_pack
&& (!c.options.is_empty() || !c.profiles.is_empty())
})
}
fn shader_pack_dir(&self) -> std::path::PathBuf {
if self.shader_packs.dir.as_os_str().is_empty() {
crate::shader_pack::folder()
} else {
self.shader_packs.dir.clone()
}
}
/// A file or folder dropped on the window: on the Shader Packs page (as
/// Iris's `ShaderPackScreen.onFilesDrop`), a pack copied into
/// `shaderpacks/` and picked. `None` elsewhere; the caller saves the
/// settings and loads the pack on `Some(Ok)`.
pub fn drop_shader_pack(
&mut self,
s: &mut Settings,
path: &std::path::Path,
) -> Option<Result<(), String>> {
if self.page() != Page::ShaderPacks {
return None;
}
let dir = self.shader_pack_dir();
Some(crate::shader_pack::import_pack(path, &dir).map(|id| {
self.shader_packs = Default::default();
self.shader_packs.poll(dir);
s.horizon.shader_pack = id;
self.sync_shader_pack_config(s);
}))
}
/// Change the shader pack's options, applied when its page closes.
fn edit_shader_pack(&mut self, edit: impl FnOnce(&mut PackConfig)) {
if let Some(cfg) = &mut self.shader_pack_config {
edit(cfg);
self.shader_pack_edited = true;
}
}
/// Save the active shader pack options to `shaderpacks/<id>.txt`.
fn save_shader_pack_config(&self) {
if let Some(cfg) = &self.shader_pack_config
&& let Err(e) = cfg.save(&self.shader_pack_dir())
{
log::warn!("Horizon: shader pack {} options: {e}", cfg.id);
}
}
pub(crate) fn shader_pack_failed(&self, id: &str) -> bool {
self.shader_pack_error
.as_ref()
.is_some_and(|(e, _)| e == id)
}
/// Whether the Shader Packs page shows.
fn shader_packs_shown(&self) -> bool {
self.page() == Page::ShaderPacks
}
/// The graphics screen level of `page`, if it is one.
fn graphics_level(page: Page) -> Option<crate::graphics_ui::Level> {
match page {
@@ -1317,8 +1176,6 @@ impl OptionsUi {
Page::Font => "options.font.title",
Page::Packs => "resourcePack.title",
Page::Mods => "Mods",
Page::ShaderPacks => "Shader Packs",
Page::ShaderPackOptions => "Shader Pack Settings",
Page::Graphics | Page::GraphicsAdvanced => "options.videoTitle",
Page::Controller => "Controller Settings",
Page::PadBinds => "Controller Binds",
@@ -1434,10 +1291,7 @@ impl OptionsUi {
open("Graphics...", Page::Graphics),
Some(open("Advanced Graphics...", Page::GraphicsAdvanced)),
),
Row::Buttons(
open("Shader Packs...", Page::ShaderPacks),
Some(open("Controller Settings...", Page::Controller)),
),
Row::Buttons(open("Controller Settings...", Page::Controller), None),
Row::Buttons(
open("Extras...", Page::Extras),
Some(open("Mods...", Page::Mods)),
@@ -1485,16 +1339,6 @@ impl OptionsUi {
Act::Push(Page::GraphicsAdvanced),
)),
)];
rows.push(Row::Buttons(
button(
0.0,
0.0,
310.0,
Text::literal("Shader Packs..."),
Act::Push(Page::ShaderPacks),
),
None,
));
rows.push(Row::Header("options.video.display.header"));
rows.extend(pairs(&[
Opt::MaxFps,
@@ -1667,113 +1511,6 @@ impl OptionsUi {
})
.collect()
}
Page::ShaderPacks => {
let folder = &self.shader_packs;
let mut rows = vec![Row::Note(folder.dir.display().to_string())];
if folder.packs.is_empty() {
rows.push(Row::Header(
"No shader packs: put a pack's folder or .zip in this folder",
));
}
for (i, (id, info)) in folder.packs.iter().enumerate() {
let on = s.horizon.shader_pack == *id;
let mut label = Text::literal(format!("{id}: ")).append(on_off(on));
if info.is_err() || self.shader_pack_failed(id) {
label = label.append(" (Failed)");
}
let widget = button(0.0, 0.0, 310.0, label, Act::ToggleShaderPack(i));
rows.push(Row::Buttons(
if info.is_ok() {
widget
} else {
inactive(widget)
},
None,
));
}
rows
}
Page::ShaderPackOptions => {
let Some(cfg) = &self.shader_pack_config else {
return vec![Row::Header("No configurable options for this shader pack")];
};
let screen = cfg.current_screen();
let mut rows = vec![Row::Note(format!("{} — {}", cfg.id, screen.label))];
if cfg.active_screen.is_some() {
rows.push(Row::Buttons(
button(
0.0,
0.0,
310.0,
Text::literal("Back to Main Screen"),
Act::ShaderPackBackScreen,
),
None,
));
}
let mut widgets = Vec::new();
for item in &screen.items {
match item {
crate::shader_pack::ScreenItem::Empty => {}
crate::shader_pack::ScreenItem::Profile => {
let label =
Text::literal(format!("Profile: {}", cfg.active_profile_label()));
widgets.push(button(0.0, 0.0, 150.0, label, Act::ShaderPackProfile));
}
crate::shader_pack::ScreenItem::Subscreen(sub, title) => {
let label = Text::literal(format!("{title}..."));
widgets.push(button(
0.0,
0.0,
150.0,
label,
Act::ShaderPackSubscreen(sub.clone()),
));
}
crate::shader_pack::ScreenItem::Option(idx) => {
if let Some(opt) = cfg.options.get(*idx) {
let label = Text::literal(format!(
"{}: {}",
opt.label,
opt.display_value()
));
if let Some(value) = opt.slider_pos() {
widgets.push(Widget {
x: 0.0,
y: 0.0,
w: 150.0,
h: 20.0,
kind: WKind::ShaderOptionSlider {
label,
value,
index: *idx,
},
list: false,
});
} else {
widgets.push(button(
0.0,
0.0,
150.0,
label,
Act::ShaderPackOption(*idx),
));
}
}
}
}
}
let mut iter = widgets.into_iter();
while let Some(mut first) = iter.next() {
if let Some(second) = iter.next() {
rows.push(Row::Buttons(first, Some(second)));
} else {
first.w = 310.0;
rows.push(Row::Buttons(first, None));
}
}
rows
}
_ => {
let _ = s;
Vec::new()
@@ -1784,9 +1521,6 @@ impl OptionsUi {
fn layout(&mut self, s: &Settings, w: f32, h: f32) -> (Vec<Widget>, f32, f32, f32) {
let mut out = Vec::new();
let page = self.page();
if matches!(page, Page::ShaderPacks | Page::ShaderPackOptions) {
self.sync_shader_pack_config(s);
}
let cx = (w / 2.0).floor();
if matches!(page, Page::Video | Page::Controls | Page::KeyBinds) {
self.settings_search.x = cx - 100.0;
@@ -2176,10 +1910,6 @@ impl OptionsUi {
if matches!(page, Page::Video | Page::Controls) {
top = 38.0;
}
if page == Page::ShaderPacks {
// Two footer rows, as Iris's ShaderPackScreen.
bottom = h - 57.0;
}
out.push(Widget {
x: 0.0,
y: if matches!(page, Page::Video | Page::Controls) {
@@ -2266,42 +1996,13 @@ impl OptionsUi {
}
content = y + self.scroll - top + 2.0;
out[list_start..].iter_mut().for_each(|w| w.list = true);
let folder = match page {
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
Page::ShaderPackOptions => {
Some(("Reset to Defaults", Act::ResetShaderPackOptions))
}
_ => None,
};
if page == Page::ShaderPacks {
// Iris: the folder and the pack's settings above Done.
out.push(button(
cx - 154.0,
h - 51.0,
150.0,
Text::literal("Open Shader Pack Folder"),
Act::OpenShaderPackFolder,
));
let settings = button(
cx + 4.0,
h - 51.0,
150.0,
Text::literal("Shader Pack Settings..."),
Act::Push(Page::ShaderPackOptions),
);
out.push(if self.shader_pack_has_settings(s) {
settings
} else {
inactive(settings)
});
out.push(done(h - 27.0));
} else if let Some((label, act)) = folder {
if page == Page::Mods {
out.push(button(
cx - 154.0,
h - 27.0,
150.0,
Text::literal(label),
act,
Text::literal("Open Mod Folder"),
Act::OpenModFolder,
));
out.push(button(cx + 4.0, h - 27.0, 150.0, tr("gui.done"), Act::Done));
} else {
@@ -2331,9 +2032,6 @@ impl OptionsUi {
atlas: &HudAtlas,
cursor: (f32, f32),
) {
if self.shader_packs_shown() {
self.shader_packs.poll(crate::shader_pack::folder());
}
let (w, h) = (batch.width, batch.height);
if let Some(error) = &self.resource_error {
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, self.in_world);
@@ -2462,16 +2160,6 @@ impl OptionsUi {
170.0,
));
}
if let WKind::Button {
act: Act::ShaderPackOption(i),
..
} = &wd.kind
&& let Some(cfg) = &self.shader_pack_config
&& let Some(opt) = cfg.options.get(*i)
&& !opt.description.is_empty()
{
tooltip = Some(atlas.font.split(&Text::literal(&opt.description), 170.0));
}
}
match &wd.kind {
WKind::Button { label, active, act } => {
@@ -2512,31 +2200,6 @@ impl OptionsUi {
true,
);
}
WKind::ShaderOptionSlider {
label,
value,
index,
} => {
let dragging = self.dragging_shader_option == Some(*index);
if hover
&& let Some(cfg) = &self.shader_pack_config
&& let Some(opt) = cfg.options.get(*index)
&& !opt.description.is_empty()
{
tooltip = Some(atlas.font.split(&Text::literal(&opt.description), 170.0));
}
ui::slider(
batch,
atlas,
wd.x,
wd.y,
wd.w,
label,
*value,
hover || dragging,
true,
);
}
WKind::Text {
text,
color,
@@ -2669,7 +2332,6 @@ impl OptionsUi {
Action::ButtonClick => events.push(Event::ButtonClick),
Action::Changed => events.push(Event::Changed),
Action::OpenAdvanced => self.push(Page::GraphicsAdvanced),
Action::OpenShaderPacks => self.push(Page::ShaderPacks),
Action::OpenSimple => {
if self.stack.len() >= 2
&& self.stack[self.stack.len() - 2] == Page::Graphics
@@ -2751,19 +2413,6 @@ impl OptionsUi {
set_slider(opt, s, v);
events.push(Event::Changed);
}
if let Some(idx) = self.dragging_shader_option
&& let Some(wd) = widgets.iter().find(|wd| {
matches!(&wd.kind, WKind::ShaderOptionSlider { index, .. } if *index == idx)
})
{
let v = ui::slider_value(wd.x, wd.w, x);
self.edit_shader_pack(|cfg| {
if let Some(opt) = cfg.options.get_mut(idx) {
opt.set_slider_pos(v);
}
});
events.push(Event::Changed);
}
return events;
}
// Controller Binds: a click cancels the wait.
@@ -2844,41 +2493,8 @@ impl OptionsUi {
events.push(Event::Changed);
}
Act::OpenModFolder => events.push(Event::OpenModFolder),
Act::ToggleShaderPack(i) => {
self.toggle_shader_pack(s, i);
events.push(Event::Changed);
}
Act::OpenShaderPackFolder => events.push(Event::OpenShaderPackFolder),
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::ShaderPackProfile => {
self.edit_shader_pack(|cfg| cfg.cycle_profile(false));
events.push(Event::Changed);
}
Act::ShaderPackSubscreen(sub) => {
if let Some(cfg) = &mut self.shader_pack_config {
cfg.active_screen = Some(sub);
}
self.scroll = 0.0;
}
Act::ShaderPackBackScreen => {
if let Some(cfg) = &mut self.shader_pack_config {
cfg.active_screen = None;
}
self.scroll = 0.0;
}
Act::ShaderPackOption(i) => {
self.edit_shader_pack(|cfg| {
if let Some(opt) = cfg.options.get_mut(i) {
opt.cycle(false);
}
});
events.push(Event::Changed);
}
Act::ResetShaderPackOptions => {
self.edit_shader_pack(PackConfig::reset_all);
events.push(Event::Changed);
}
Act::None => {}
}
}
@@ -2889,16 +2505,6 @@ impl OptionsUi {
set_slider(opt, s, v);
events.push(Event::Changed);
}
WKind::ShaderOptionSlider { index, .. } => {
self.dragging_shader_option = Some(index);
let v = ui::slider_value(wd.x, wd.w, x);
self.edit_shader_pack(|cfg| {
if let Some(opt) = cfg.options.get_mut(index) {
opt.set_slider_pos(v);
}
});
events.push(Event::Changed);
}
WKind::LanguageRow { code, .. } => {
if self.language_choice.as_deref() == Some(code.as_str()) {
events.extend(self.back(s));
@@ -2927,7 +2533,7 @@ impl OptionsUi {
}
// AbstractSliderButton suppresses the press sound and plays once on
// release, even if the pointer has left the widget or its value stayed.
let slider = self.dragging.take().is_some() || self.dragging_shader_option.take().is_some();
let slider = self.dragging.take().is_some();
let graphics_slider = self.graphics.release();
if slider || graphics_slider {
vec![Event::ButtonClick]
@@ -2983,14 +2589,6 @@ impl OptionsUi {
/// Escape or Done: leave the current page, applying page results.
pub fn back(&mut self, s: &mut Settings) -> Vec<Event> {
let mut events = Vec::new();
if self.page() == Page::ShaderPackOptions
&& let Some(cfg) = &mut self.shader_pack_config
&& cfg.active_screen.is_some()
{
cfg.active_screen = None;
self.scroll = 0.0;
return events;
}
let page = self.stack.pop().unwrap_or(Page::Main);
self.scroll = 0.0;
self.scrollbar_drag_y = None;
@@ -3007,12 +2605,6 @@ impl OptionsUi {
events.push(Event::Language(code));
}
}
// Iris's ShaderPackScreen applies its edits on Done, so the
// pack is rebuilt once, not on every click or slider step.
Page::ShaderPackOptions if std::mem::take(&mut self.shader_pack_edited) => {
self.save_shader_pack_config();
events.push(Event::ReloadShaderPack);
}
Page::Packs => {
// PackSelectionScreen.onClose: PackSelectionModel.commit.
if let Some(p) = self.pack_screen.take()
@@ -3337,177 +2929,6 @@ mod tests {
assert!(ui.resource_error.is_none());
}
/// The Shader Packs page (Video Settings > Shader Packs..., or the
/// Shader Pack row of Advanced graphics): the folder's path, Open Shader
/// Pack Folder, and a switch per pack (#941, #925).
#[test]
fn classic_shader_packs_page_shows_the_folder_and_picks_a_pack() {
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
let size = (480.0, 240.0);
let dir = std::env::temp_dir().join(format!("horizon-shaderpacks-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let find = |ui: &mut OptionsUi, s: &Settings, act: &dyn Fn(&Act) -> bool| {
ui.layout(s, size.0, size.1)
.0
.into_iter()
.find(|w| matches!(&w.kind, WKind::Button { act: a, active: true, .. } if act(a)))
};
ui.show(Page::Video);
let open = find(&mut ui, &s, &|a| *a == Act::Push(Page::ShaderPacks)).unwrap();
ui.click(
&mut s,
&atlas,
open.x + 1.0,
open.y + 1.0,
size,
[480, 240],
false,
false,
);
assert_eq!(ui.page(), Page::ShaderPacks);
ui.shader_packs.poll(dir.clone());
let empty = ui.layout(&s, size.0, size.1).0;
let path = dir.display().to_string();
for line in [path.as_str(), "No shader packs"] {
assert!(
empty.iter().any(
|w| matches!(&w.kind, WKind::Text { text, .. } if text.plain().contains(line))
),
"{line}"
);
}
let folder = find(&mut ui, &s, &|a| *a == Act::OpenShaderPackFolder).unwrap();
assert_eq!(
ui.click(
&mut s,
&atlas,
folder.x + 1.0,
folder.y + 1.0,
size,
[480, 240],
false,
false
),
vec![Event::ButtonClick, Event::OpenShaderPackFolder]
);
std::fs::write(dir.join("Pack.zip"), b"").unwrap();
ui.shader_packs = Default::default();
ui.shader_packs.poll(dir.clone());
let pack = find(&mut ui, &s, &|a| *a == Act::ToggleShaderPack(0)).unwrap();
let events = ui.click(
&mut s,
&atlas,
pack.x + 1.0,
pack.y + 1.0,
size,
[480, 240],
false,
false,
);
assert!(events.contains(&Event::Changed));
assert_eq!(s.horizon.shader_pack, "Pack.zip");
ui.shader_pack_error = Some(("Pack.zip".into(), "broken".into()));
let label = |ui: &mut OptionsUi, s: &Settings| match find(ui, s, &|a| {
*a == Act::ToggleShaderPack(0)
})
.unwrap()
.kind
{
WKind::Button { label, .. } => label.plain(),
_ => unreachable!(),
};
assert!(
label(&mut ui, &s).ends_with("(Failed)"),
"{}",
label(&mut ui, &s)
);
ui.click(
&mut s,
&atlas,
pack.x + 1.0,
pack.y + 1.0,
size,
[480, 240],
false,
false,
);
assert_eq!(s.horizon.shader_pack, "", "a second click turns it off");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_dropped_pack_is_picked_and_its_settings_apply_on_done() {
let atlas = atlas();
let mut s = Settings::default();
let mut ui = OptionsUi::default();
let size = (480.0, 240.0);
let dir =
std::env::temp_dir().join(format!("horizon-shaderpack-drop-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
ui.shader_packs.poll(dir.clone());
let sample = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../examples/shaderpacks/horizon-sample");
assert_eq!(
ui.drop_shader_pack(&mut s, &sample),
None,
"only on its page"
);
ui.show(Page::ShaderPacks);
assert_eq!(ui.drop_shader_pack(&mut s, &sample), Some(Ok(())));
assert_eq!(s.horizon.shader_pack, "horizon-sample");
assert!(dir.join("horizon-sample/pack.toml").is_file());
let again = ui.drop_shader_pack(&mut s, &sample).unwrap().unwrap_err();
assert!(again.contains("already"), "{again}");
let click = |ui: &mut OptionsUi, s: &mut Settings, act: &dyn Fn(&Act) -> bool| {
let w = ui
.layout(s, size.0, size.1)
.0
.into_iter()
.find(|w| matches!(&w.kind, WKind::Button { act: a, active: true, .. } if act(a)))
.unwrap();
ui.click(
s,
&atlas,
w.x + 1.0,
w.y + 1.0,
size,
[480, 240],
false,
false,
)
};
click(&mut ui, &mut s, &|a| {
*a == Act::Push(Page::ShaderPackOptions)
});
assert_eq!(ui.page(), Page::ShaderPackOptions);
for _ in 0..3 {
let events = click(&mut ui, &mut s, &|a| matches!(a, Act::ShaderPackOption(_)));
assert!(!events.contains(&Event::ReloadShaderPack), "{events:?}");
}
let txt = dir.join("horizon-sample.txt");
assert!(!txt.exists(), "nothing is saved before Done");
let events = click(&mut ui, &mut s, &|a| *a == Act::Done);
assert!(events.contains(&Event::ReloadShaderPack), "{events:?}");
assert_eq!(ui.page(), Page::ShaderPacks);
assert!(txt.is_file(), "Done saves the values");
click(&mut ui, &mut s, &|a| {
*a == Act::Push(Page::ShaderPackOptions)
});
let events = click(&mut ui, &mut s, &|a| *a == Act::Done);
assert!(
!events.contains(&Event::ReloadShaderPack),
"no change: no reload"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn classic_mods_page_toggles_mods_and_keeps_dev_and_invalid_entries_inactive() {
let atlas = atlas();
@@ -4618,7 +4039,6 @@ mod tests {
[
Page::Graphics,
Page::GraphicsAdvanced,
Page::ShaderPacks,
Page::Controller,
Page::Extras,
Page::Mods,
@@ -4652,7 +4072,7 @@ mod tests {
// Vanilla's own pages keep the Horizon pages they had.
ui.stack = vec![Page::Main, Page::Video];
let video = pushes(&mut ui);
for page in [Page::Graphics, Page::GraphicsAdvanced, Page::ShaderPacks] {
for page in [Page::Graphics, Page::GraphicsAdvanced] {
assert!(video.contains(&page), "{page:?}");
}
ui.stack = vec![Page::Main, Page::Controls];
+1 -1
View File
@@ -3233,7 +3233,7 @@ impl ParticleEngine {
} => {
p.base_tick(level);
let (fluid, source) = level.fluid_source(ParticleLevel::block_at(p.position));
if !p.removed && !(fluid == Fluid::Water && source) {
if !(p.removed || (fluid == Fluid::Water && source)) {
p.removed = true;
}
if !p.removed && p.position[1] >= y_end {
File diff suppressed because it is too large. Load diff
@@ -1,71 +0,0 @@
// Horizon shader pack prelude, api 1 (docs/shader-packs.md): placed before
// every stage's source. Bindings, the Frame uniform, the full-screen vertex
// shader and helpers that hide the renderer's reversed depth.
struct Frame {
view: mat4x4<f32>,
projection: mat4x4<f32>,
inverse_view: mat4x4<f32>,
inverse_projection: mat4x4<f32>,
camera_position: vec4<f32>,
resolution: vec4<f32>,
time: vec4<f32>,
sun: vec4<f32>,
moon: vec4<f32>,
sky_color: vec4<f32>,
fog_color: vec4<f32>,
fog: vec4<f32>,
weather: vec4<f32>,
camera_state: vec4<f32>,
view_params: vec4<f32>,
dimension: vec4<f32>,
}
@group(0) @binding(0) var scene_color: texture_2d<f32>;
@group(0) @binding(1) var scene_depth: texture_depth_2d;
@group(0) @binding(2) var previous: texture_2d<f32>;
@group(0) @binding(3) var linear_sampler: sampler;
@group(0) @binding(4) var nearest_sampler: sampler;
@group(0) @binding(5) var<uniform> frame: Frame;
struct StageIn {
@builtin(position) position: vec4<f32>,
@location(0) uv: vec2<f32>,
}
@vertex fn horizon_stage_vertex(@builtin(vertex_index) i: u32) -> StageIn {
let p = vec2<f32>(f32((i << 1u) & 2u), f32(i & 2u));
var out: StageIn;
out.position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
out.uv = vec2(p.x, 1.0 - p.y);
return out;
}
// The world's depth at `uv`: 1 at the near plane, 0 for the sky.
fn depth_at(uv: vec2<f32>) -> f32 {
return textureSampleLevel(scene_depth, nearest_sampler, uv, 0);
}
fn is_sky(depth: f32) -> bool {
return depth <= 0.0;
}
// Blocks from the eye along the view axis.
fn linear_depth(depth: f32) -> f32 {
let near = frame.view_params.x;
let far = frame.view_params.y;
return near * far / ((far - near) * depth + near);
}
fn view_position(uv: vec2<f32>, depth: f32) -> vec3<f32> {
let v = frame.inverse_projection * vec4(uv.x * 2.0 - 1.0, 1.0 - uv.y * 2.0, depth, 1.0);
return v.xyz / v.w;
}
// World position relative to the eye (add frame.camera_position.xyz for the
// absolute one).
fn world_offset(uv: vec2<f32>, depth: f32) -> vec3<f32> {
return (frame.inverse_view * vec4(view_position(uv, depth), 1.0)).xyz;
}
fn luminance(c: vec3<f32>) -> f32 {
return dot(c, vec3(0.2126, 0.7152, 0.0722));
}
fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {
return select(pow((c + 0.055) / 1.055, vec3(2.4)), c / 12.92, c <= vec3(0.04045));
}
fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
return select(1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, c * 12.92, c <= vec3(0.0031308));
}
@@ -1,108 +0,0 @@
// Horizon shader pack prelude, api 2 additions (docs/shader-packs.md): placed
// after the api 1 prelude before the stages of an `api = 2` pack, so api 1
// packs keep every name they may have declared themselves.
struct FrameExt {
previous_view: mat4x4<f32>,
previous_projection: mat4x4<f32>,
shadow_view: mat4x4<f32>,
shadow_projection: mat4x4<f32>,
shadow_inverse_view: mat4x4<f32>,
shadow_inverse_projection: mat4x4<f32>,
// xyz: previous camera world position, w: frame delta time (seconds)
previous_camera: vec4<f32>,
// x: shadow map resolution, y: shadow distance (blocks), z: 0,
// w: 1 while the shadow map is drawn this frame, else 0
shadow_params: vec4<f32>,
// x: eye block light (0..240), y: eye sky light (0..240), z: smoothed
// rain (0..1), w: 0
environment: vec4<f32>,
}
@group(0) @binding(6) var<uniform> frame_ext: FrameExt;
@group(0) @binding(7) var scene_normal: texture_2d<f32>;
@group(0) @binding(8) var scene_material: texture_2d<f32>;
@group(0) @binding(9) var opaque_depth: texture_depth_2d;
@group(0) @binding(10) var shadow_depth: texture_depth_2d;
@group(0) @binding(11) var shadow_sampler: sampler_comparison;
@group(0) @binding(12) var noise_texture: texture_2d<f32>;
@group(0) @binding(13) var repeat_sampler: sampler;
// The opaque world's depth at `uv` (before translucent water and stained glass).
fn opaque_depth_at(uv: vec2<f32>) -> f32 {
return textureSampleLevel(opaque_depth, nearest_sampler, uv, 0);
}
// World-space surface normal at `uv` from the G-buffer (or reconstructed from depth).
fn world_normal_at(uv: vec2<f32>) -> vec3<f32> {
let encoded = textureSampleLevel(scene_normal, nearest_sampler, uv, 0.0);
if encoded.w > 0.001 {
return normalize(encoded.xyz * 2.0 - 1.0);
}
let step_uv = frame.resolution.zw;
let d0 = depth_at(uv);
let p0 = world_offset(uv, d0);
let px = world_offset(uv + vec2(step_uv.x, 0.0), depth_at(uv + vec2(step_uv.x, 0.0))) - p0;
let py = world_offset(uv + vec2(0.0, step_uv.y), depth_at(uv + vec2(0.0, step_uv.y))) - p0;
let n = cross(py, px);
let len = length(n);
if len > 1e-6 {
return n / len;
}
return vec3(0.0, 1.0, 0.0);
}
// G-buffer material data at `uv`: r = block light (0..1), g = sky light (0..1),
// b = water/translucent mask (0..1), a = roughness (0..1).
fn material_at(uv: vec2<f32>) -> vec4<f32> {
return textureSampleLevel(scene_material, nearest_sampler, uv, 0.0);
}
// Reproject a camera-relative world position into previous-frame UV and clip depth.
fn reproject_uv(world_rel: vec3<f32>) -> vec3<f32> {
let camera_delta = frame.camera_position.xyz - frame_ext.previous_camera.xyz;
let prev_rel = world_rel + camera_delta;
let clip = frame_ext.previous_projection * (frame_ext.previous_view * vec4(prev_rel, 1.0));
if clip.w <= 1e-5 {
return vec3(-1.0);
}
let ndc = clip.xyz / clip.w;
return vec3(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5, ndc.z);
}
// Sample the directional sun/moon shadow map with 5-tap PCF and normal offset bias.
// Returns 1.0 in full light and 0.0 in full shadow.
fn sample_shadow(world_rel: vec3<f32>, world_normal: vec3<f32>) -> f32 {
if frame_ext.shadow_params.w <= 0.001 {
return 1.0;
}
let dist = length(world_rel);
let max_dist = max(frame_ext.shadow_params.y, 16.0);
if dist >= max_dist {
return 1.0;
}
let light_dir = normalize(select(frame.moon.xyz, frame.sun.xyz, frame.sun.y >= -0.05));
let ndotl = max(dot(world_normal, light_dir), 0.0);
let bias_pos = world_rel + world_normal * (0.06 + 0.12 * (1.0 - ndotl));
let light_clip = frame_ext.shadow_projection * (frame_ext.shadow_view * vec4(bias_pos, 1.0));
let ndc = light_clip.xyz / max(light_clip.w, 1e-5);
let shadow_uv = vec2(ndc.x * 0.5 + 0.5, 0.5 - ndc.y * 0.5);
if any(shadow_uv < vec2(0.002)) || any(shadow_uv > vec2(0.998)) || ndc.z <= 0.0 || ndc.z >= 1.0 {
return 1.0;
}
let texel = 1.0 / max(frame_ext.shadow_params.x, 512.0);
let ref_z = clamp(ndc.z + 0.0008, 0.0, 1.0);
var sum = textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv, ref_z);
sum += textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv + vec2(texel, 0.0), ref_z);
sum += textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv - vec2(texel, 0.0), ref_z);
sum += textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv + vec2(0.0, texel), ref_z);
sum += textureSampleCompareLevel(shadow_depth, shadow_sampler, shadow_uv - vec2(0.0, texel), ref_z);
let fade = smoothstep(max_dist, max_dist * 0.82, dist);
return mix(1.0, sum * 0.2, fade * frame_ext.shadow_params.w);
}
// ACES fitted filmic tonemapping curve (linear HDR -> linear 0..1).
fn aces_tonemap(x: vec3<f32>) -> vec3<f32> {
let a = 2.51;
let b = 0.03;
let c = 2.43;
let d = 0.59;
let e = 0.14;
return clamp((x * (a * x + b)) / (x * (c * x + d) + e), vec3(0.0), vec3(1.0));
}
+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, BUILT_IN_PACKS};
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 {
+81 -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,31 @@ 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,
)
}
/// The built-in packs `packs` copies, under the prepared resources' `assets`.
pub 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 +258,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 +295,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 +533,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 +572,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 +580,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");
+5
View File
@@ -17,5 +17,10 @@ sha1 = "0.10"
# Secure chat: SHA256withRSA message signatures.
sha2 = "0.10"
# Its own name, not `probe`: Cargo copies every crate's examples into one target/<profile>/examples.
[[example]]
name = "protocol_probe"
path = "examples/probe.rs"
[lints]
workspace = true
+1 -1
View File
@@ -2,7 +2,7 @@
//! seconds, reporting packet counts and any decode failures. A differential
//! check of the codecs against a real vanilla server:
//!
//! cargo run -p horizon-protocol --example probe -- 127.0.0.1:25565 [seconds] [registries.json]
//! cargo run -p horizon-protocol --example protocol_probe -- 127.0.0.1:25565 [seconds] [registries.json]
use horizon_protocol::{
PROTOCOL_VERSION, State,
connection::Connection,
-20
View File
@@ -1,20 +0,0 @@
[package]
name = "horizon-shaderpack"
version = "0.1.0"
edition.workspace = true
license.workspace = true
rust-version.workspace = true
description = "Java (OptiFine/Iris) shader packs for Horizon: Iris's preprocessor and the GLSL to WGSL translation"
[dependencies]
glsl-lang = "0.8.1"
naga = { version = "30.0.1", features = ["glsl-in", "wgsl-in", "wgsl-out"] }
# The C preprocessor naga's GLSL front end uses.
pp-rs = "0.2.1"
zip = { version = "2", default-features = false, features = ["deflate"] }
[lints]
workspace = true
[dev-dependencies]
zip = { version = "2", default-features = false, features = ["deflate"] }
-198
View File
@@ -1,198 +0,0 @@
//! `#include` the way Iris resolves it (`IncludeGraph`, `IncludeProcessor`):
//! before any other directive, so include guards don't apply; `/lib/x.glsl`
//! from `shaders/`, `x.glsl` from the including file's folder. Every file
//! reachable from the programs is read once into a graph, and a program is
//! the graph inlined from its file.
use crate::source::PackFiles;
use std::collections::{BTreeMap, BTreeSet};
/// One file of the graph: its lines and the include on each `#include` line.
pub struct FileNode {
pub lines: Vec<String>,
/// Line index → included file (a normalised path relative to `shaders/`).
pub includes: BTreeMap<usize, String>,
}
pub struct IncludeGraph {
pub files: BTreeMap<String, FileNode>,
/// Files that couldn't be read or include themselves, with the reason.
pub failures: BTreeMap<String, String>,
}
/// A program after inlining: the source and, for each of its lines, the
/// file and 1-based line it came from (to report errors where they are).
pub struct Inlined {
pub text: String,
pub origin: Vec<(String, u32)>,
}
impl Inlined {
/// `file:line` of 1-based `line` of the inlined text.
pub fn locate(&self, line: u32) -> String {
match self.origin.get(line.saturating_sub(1) as usize) {
Some((file, at)) => format!("{file}:{at}"),
None => format!("line {line}"),
}
}
}
/// `target` from an `#include` in `from`, as a path relative to `shaders/`
/// with `.` and `..` resolved (Iris's `AbsolutePackPath`).
pub fn resolve(from: &str, target: &str) -> String {
let joined = match target.strip_prefix('/') {
Some(absolute) => absolute.to_owned(),
None => match from.rsplit_once('/') {
Some((dir, _)) => format!("{dir}/{target}"),
None => target.to_owned(),
},
};
let mut parts: Vec<&str> = Vec::new();
for segment in joined.split('/') {
match segment {
"" | "." => {}
".." => {
parts.pop();
}
s => parts.push(s),
}
}
parts.join("/")
}
/// The target of an `#include` line, or `None` for any other line.
fn include_target(line: &str) -> Option<&str> {
let t = line.trim();
if !t.starts_with("#include") {
return None;
}
let target = t.get("#include ".len()..).unwrap_or("").trim();
let target = target.strip_prefix('"').unwrap_or(target);
Some(target.strip_suffix('"').unwrap_or(target))
}
impl IncludeGraph {
/// Read `starts` and every file they include, transitively.
pub fn build(files: &PackFiles, starts: &[String]) -> Self {
let mut graph = Self {
files: BTreeMap::new(),
failures: BTreeMap::new(),
};
let mut seen: BTreeSet<String> = starts.iter().cloned().collect();
let mut queue: Vec<(String, Option<(String, usize)>)> =
starts.iter().map(|s| (s.clone(), None)).collect();
while let Some((path, from)) = queue.pop() {
let Some(text) = files.read(&path) else {
let why = match from {
Some((file, line)) => {
format!("{file}:{}: #include \"{path}\": file not found", line + 1)
}
None => format!("{path}: file not found"),
};
graph.failures.insert(path, why);
continue;
};
let lines: Vec<String> = text.lines().map(str::to_owned).collect();
let includes: BTreeMap<usize, String> = lines
.iter()
.enumerate()
.filter_map(|(i, l)| Some((i, resolve(&path, include_target(l)?))))
.collect();
if let Some((line, _)) = includes.iter().find(|(_, target)| **target == path) {
graph.failures.insert(
path.clone(),
format!("{path}:{}: file includes itself", line + 1),
);
continue;
}
for (&line, target) in &includes {
if seen.insert(target.clone()) {
queue.push((target.clone(), Some((path.clone(), line))));
}
}
graph.files.insert(path, FileNode { lines, includes });
}
graph
}
/// The graph's weakly connected components (files linked by includes in
/// either direction), each sorted. Iris discovers options per component.
pub fn components(&self) -> Vec<Vec<String>> {
let names: Vec<&String> = self.files.keys().collect();
let index: BTreeMap<&str, usize> = names
.iter()
.enumerate()
.map(|(i, n)| (n.as_str(), i))
.collect();
let mut parent: Vec<usize> = (0..names.len()).collect();
fn root(parent: &mut [usize], mut i: usize) -> usize {
while parent[i] != i {
parent[i] = parent[parent[i]];
i = parent[i];
}
i
}
for (i, node) in self.files.values().enumerate() {
for target in node.includes.values() {
if let Some(&j) = index.get(target.as_str()) {
let (a, b) = (root(&mut parent, i), root(&mut parent, j));
parent[a] = b;
}
}
}
let mut groups: BTreeMap<usize, Vec<String>> = BTreeMap::new();
for (i, name) in names.iter().enumerate() {
let r = root(&mut parent, i);
groups.entry(r).or_default().push((*name).clone());
}
groups.into_values().collect()
}
/// Inline `start`, passing every line that isn't an `#include` through
/// `edit(file, line index, text)` first (Iris applies option values there).
pub fn inline(
&self,
start: &str,
edit: &dyn Fn(&str, usize, &str) -> String,
) -> Result<Inlined, String> {
let mut out = Inlined {
text: String::new(),
origin: Vec::new(),
};
let mut stack = Vec::new();
self.inline_into(start, edit, &mut stack, &mut out)?;
Ok(out)
}
fn inline_into(
&self,
path: &str,
edit: &dyn Fn(&str, usize, &str) -> String,
stack: &mut Vec<String>,
out: &mut Inlined,
) -> Result<(), String> {
if let Some(why) = self.failures.get(path) {
return Err(why.clone());
}
if stack.iter().any(|p| p == path) {
return Err(format!("#include cycle: {} -> {path}", stack.join(" -> ")));
}
let node = self
.files
.get(path)
.ok_or_else(|| format!("{path}: file not found"))?;
stack.push(path.to_owned());
for (i, line) in node.lines.iter().enumerate() {
match node.includes.get(&i) {
Some(target) => self.inline_into(target, edit, stack, out)?,
None => {
out.text.push_str(&edit(path, i, line));
out.text.push('\n');
out.origin.push((path.to_owned(), i as u32 + 1));
}
}
}
stack.pop();
Ok(())
}
}
-205
View File
@@ -1,205 +0,0 @@
//! Java shader packs (OptiFine/Iris) for Horizon (docs/shader-packs.md,
//! "Java shader packs"). This crate does the part that needs no GPU: open a
//! pack, find its programs and options, and turn a program into plain GLSL
//! the way Iris does before compiling it (`#include`s inlined, the player's
//! option values written into the source, Iris's macros expanded), read its
//! `shaders.properties`, and translate programs to WGSL.
pub mod include;
pub mod options;
pub mod pipeline;
pub mod preprocess;
pub mod properties;
pub mod source;
pub mod translate;
pub mod uniforms;
use include::{IncludeGraph, Inlined};
use options::{Annotated, OptionSet, OptionValues};
use preprocess::{Environment, Preprocessed};
use properties::{Properties, ShaderProperties};
use source::PackFiles;
use std::{
collections::{BTreeMap, BTreeSet},
path::Path,
};
use translate::{Link, Translated};
/// A program's pipeline stage, from its file extension.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Stage {
Vertex,
Fragment,
Geometry,
Compute,
TessControl,
TessEval,
}
impl Stage {
/// The stage of `composite1.fsh` and the like; `None` for other files.
pub fn of(program: &str) -> Option<Self> {
Some(match program.rsplit_once('.')?.1 {
"vsh" => Self::Vertex,
"fsh" => Self::Fragment,
"gsh" => Self::Geometry,
"csh" => Self::Compute,
"tcs" => Self::TessControl,
"tes" => Self::TessEval,
_ => return None,
})
}
}
/// An opened Java shader pack.
pub struct JavaPack {
pub files: PackFiles,
/// Every program source file (`composite1.fsh`, `world-1/final.vsh`).
pub programs: Vec<String>,
pub graph: IncludeGraph,
annotations: BTreeMap<String, Annotated>,
/// The options the player can set (`ShaderPackOptions`).
pub options: OptionSet,
}
impl JavaPack {
/// Open the pack folder or zip at `path` and read every program and file
/// they include.
pub fn open(path: &Path) -> Result<Self, String> {
let files = PackFiles::open(path)?;
let programs = files.programs();
if programs.is_empty() {
return Err("no programs in shaders/".into());
}
let graph = IncludeGraph::build(&files, &programs);
let annotations: BTreeMap<String, Annotated> = graph
.files
.iter()
.map(|(path, node)| (path.clone(), Annotated::parse(&node.lines)))
.collect();
// A boolean #define is an option when an #ifdef in its component
// refers to it.
let mut options = OptionSet::default();
for component in graph.components() {
let refs: BTreeSet<&String> = component
.iter()
.flat_map(|f| &annotations[f].ifdef_refs)
.collect();
for file in &component {
let a = &annotations[file];
a.bools
.values()
.filter(|o| refs.contains(&o.name))
.for_each(|o| options.add_bool(o));
a.strings.values().for_each(|o| options.add_string(o));
}
}
Ok(Self {
files,
programs,
graph,
annotations,
options,
})
}
/// The player's values from saved `name=value` pairs (`shaderpacks/<pack>.txt`).
pub fn values(&self, saved: &BTreeMap<String, String>) -> OptionValues {
OptionValues::new(&self.options, saved)
}
/// `program` with its includes inlined and `values` applied.
pub fn source(&self, program: &str, values: &OptionValues) -> Result<Inlined, String> {
self.graph
.inline(program, &|file, i, line| match self.annotations.get(file) {
Some(a) => a.edit(values, i, line),
None => line.to_owned(),
})
}
/// `program` as plain GLSL: [`JavaPack::source`], then the preprocessor.
pub fn preprocess(
&self,
program: &str,
values: &OptionValues,
env: &Environment,
) -> Result<Preprocessed, String> {
preprocess::preprocess(&self.source(program, values)?, env)
}
/// `program` translated to WGSL. The programs of one pass (its `.vsh`,
/// then its `.fsh`) share `link`.
pub fn translate(
&self,
program: &str,
values: &OptionValues,
env: &Environment,
link: &mut Link,
) -> Result<Translated, String> {
let stage = Stage::of(program).ok_or_else(|| format!("{program}: not a program"))?;
let source = self.source(program, values)?;
let pre = preprocess::preprocess(&source, env)?;
translate::translate(stage, &pre.text, link, &|line| source.locate(line))
.map_err(|e| format!("{program}: {e}"))
}
/// The pack's `shaders.properties` with `values` (empty when it has none).
pub fn properties(
&self,
values: &OptionValues,
env: &Environment,
) -> Result<ShaderProperties, String> {
match self.files.read("shaders.properties") {
Some(text) => ShaderProperties::new(&text, env, &self.options, values),
None => Ok(ShaderProperties::default()),
}
}
/// The pack's language translations (`shaders/lang/<code_lower>.lang` or
/// `en_US.lang`), mapping keys such as `option.SHADOW_QUALITY`,
/// `value.SHADOW_QUALITY.2`, `screen.LIGHTING`, and `profile.HIGH` to their
/// display labels.
pub fn translations(&self, lang_code: &str) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
let lower = lang_code.to_lowercase();
let upper = match lower.split_once('_') {
Some((l, r)) => format!("{l}_{}", r.to_uppercase()),
None => lower.clone(),
};
let mut candidates = vec!["lang/en_US.lang".to_owned(), "lang/en_us.lang".to_owned()];
if lower != "en_us" {
candidates.push(format!("lang/{lower}.lang"));
candidates.push(format!("lang/{upper}.lang"));
}
for path in candidates {
if let Some(text) = self.files.read(&path) {
for (k, v) in Properties::parse(&text).iter() {
out.insert(k.to_owned(), v.to_owned());
}
}
}
out
}
}
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 {
program.rsplit_once('.').map_or(program, |(name, _)| name)
}
-669
View File
@@ -1,669 +0,0 @@
//! A pack's options, found in its source the way Iris finds them
//! (`OptionAnnotatedSource`, `ParsedString`, `OptionSet`), and the player's
//! values written back into the lines they came from.
//!
//! - `#define NAME` / `//#define NAME` (optionally `// comment`): a boolean,
//! on unless commented out, and an option only if some `#ifdef NAME` /
//! `#ifndef NAME` in the same include component refers to it.
//! - `#define NAME VALUE // comment [A B C]`: a value with its allowed list.
//! - `const int|float|bool NAME = VALUE; // [A B C]`: only for the constants
//! Iris lets players configure ([`CONST_OPTIONS`]).
use std::collections::{BTreeMap, BTreeSet};
/// The `const` names Iris configures (`VALID_CONST_OPTION_NAMES`), apart from
/// the per-buffer ones [`is_const_option`] adds.
pub const CONST_OPTIONS: &[&str] = &[
"shadowMapResolution",
"shadowDistance",
"voxelDistance",
"shadowDistanceRenderMul",
"entityShadowDistanceMul",
"shadowIntervalSize",
"generateShadowMipmap",
"generateShadowColorMipmap",
"shadowHardwareFiltering",
"shadowtex0Mipmap",
"shadowtexMipmap",
"shadowtex1Mipmap",
"shadowtex0Nearest",
"shadowtexNearest",
"shadow0MinMagNearest",
"shadowtex1Nearest",
"shadow1MinMagNearest",
"wetnessHalflife",
"drynessHalflife",
"eyeBrightnessHalflife",
"centerDepthHalflife",
"sunPathRotation",
"ambientOcclusionLevel",
"superSamplingLevel",
"noiseTextureResolution",
];
/// Iris's shadow colour buffer count (`MAX_SHADOW_COLOR_BUFFERS_IRIS`).
const SHADOW_COLOR_BUFFERS: u32 = 8;
pub fn is_const_option(name: &str) -> bool {
CONST_OPTIONS.contains(&name)
|| (0..SHADOW_COLOR_BUFFERS).any(|i| {
[
format!("shadowcolor{i}Mipmap"),
format!("shadowColor{i}Mipmap"),
format!("shadowcolor{i}Nearest"),
format!("shadowColor{i}Nearest"),
format!("shadowcolor{i}MinMagNearest"),
format!("shadowColor{i}MinMagNearest"),
format!("shadowHardwareFiltering{i}"),
]
.iter()
.any(|n| n == name)
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum OptionKind {
Define,
Const,
}
#[derive(Clone, Debug, PartialEq)]
pub struct BoolOption {
pub kind: OptionKind,
pub name: String,
pub comment: Option<String>,
pub default: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub struct StringOption {
pub kind: OptionKind,
pub name: String,
pub comment: Option<String>,
pub default: String,
/// The bracketed list, with the default added at the end when missing.
pub allowed: Vec<String>,
}
impl StringOption {
/// `StringOption.create`: `None` without a `[...]` list in the comment.
fn create(kind: OptionKind, name: &str, comment: Option<&str>, default: &str) -> Option<Self> {
let comment = comment?;
let open = comment.find('[')?;
let close = open + comment[open..].find(']')?;
// Java's split(" "): empty strings between doubled spaces stay,
// trailing ones go.
let mut allowed: Vec<String> = comment[open + 1..close]
.split(' ')
.map(str::to_owned)
.collect();
while allowed.last().is_some_and(String::is_empty) {
allowed.pop();
}
if !allowed.iter().any(|v| v == default) {
allowed.push(default.to_owned());
}
let rest = format!("{}{}", &comment[..open], &comment[close + 1..]);
Some(Self {
kind,
name: name.to_owned(),
comment: non_empty(&rest),
default: default.to_owned(),
allowed,
})
}
}
/// An option's comment; empty is none (`BaseOption`).
fn non_empty(comment: &str) -> Option<String> {
let c = comment.trim();
(!c.is_empty()).then(|| c.to_owned())
}
/// Iris's `ParsedString`: a cursor over a trimmed line.
struct Cursor(Vec<char>);
impl Cursor {
fn new(s: &str) -> Self {
Self(s.chars().collect())
}
fn literal(&mut self, token: &str) -> bool {
let t: Vec<char> = token.chars().collect();
if self.0.starts_with(&t) {
self.0.drain(..t.len());
true
} else {
false
}
}
/// Whitespace first, then trims both ends (as Iris does).
fn whitespace(&mut self) -> bool {
if !self.0.first().is_some_and(|c| c.is_whitespace()) {
return false;
}
let s: String = self.0.iter().collect();
self.0 = s.trim().chars().collect();
true
}
/// `//` and any further slashes.
fn comments(&mut self) -> bool {
if !self.literal("//") {
return false;
}
while self.literal("/") {}
true
}
fn contains(&self, s: &str) -> bool {
self.rest().contains(s)
}
fn is_end(&self) -> bool {
self.0.is_empty()
}
fn rest(&self) -> String {
self.0.iter().collect()
}
fn take(&mut self, n: usize) -> String {
self.0.drain(..n).collect()
}
fn word(&mut self) -> Option<String> {
let n = self
.0
.iter()
.take_while(|c| c.is_alphanumeric() || **c == '_')
.count();
(n > 0).then(|| self.take(n))
}
/// Iris's `takeNumber`: digits and any character next to a digit, an `f`
/// suffix, and the whole must parse as a Java float.
fn number(&mut self) -> Option<String> {
let c = &self.0;
let mut n = 0;
while n < c.len() {
if n + 1 < c.len() {
if !c[n].is_ascii_digit() && !c[n + 1].is_ascii_digit() {
break;
}
} else if !c[n].is_ascii_digit() {
break;
}
n += 1;
}
if n > 0 && n + 1 < c.len() && (c[n] == 'f' || c[n] == 'F') {
n += 1;
}
let text: String = c[..n].iter().collect();
java_float(&text).then(|| self.take(n))
}
fn word_or_number(&mut self) -> Option<String> {
self.number().or_else(|| self.word())
}
}
/// Whether Java's `Float.parseFloat` accepts `s` (decimal forms; a trailing
/// `f`/`d` is allowed).
fn java_float(s: &str) -> bool {
let s = s.trim();
let s = s.strip_suffix(['f', 'F', 'd', 'D']).unwrap_or(s);
!s.is_empty() && s.parse::<f64>().is_ok()
}
/// The options and `#ifdef` references of one file, by line index.
#[derive(Default, Debug)]
pub struct Annotated {
pub bools: BTreeMap<usize, BoolOption>,
pub strings: BTreeMap<usize, StringOption>,
/// Names tested by a bare `#ifdef NAME` / `#ifndef NAME` line.
pub ifdef_refs: BTreeSet<String>,
/// Why a line that looks like an option isn't one.
pub diagnostics: BTreeMap<usize, String>,
}
impl Annotated {
pub fn parse(lines: &[String]) -> Self {
let mut out = Self::default();
for (i, line) in lines.iter().enumerate() {
if !["#define", "const", "#ifdef", "#ifndef"]
.iter()
.any(|k| line.contains(k))
{
continue;
}
let mut c = Cursor::new(line.trim());
if c.literal("#ifdef") || c.literal("#ifndef") {
if !c.whitespace() {
continue;
}
let name = c.word();
c.whitespace();
if let (Some(name), true) = (name, c.is_end()) {
out.ifdef_refs.insert(name);
}
} else if c.literal("const") {
if let Err(why) = out.parse_const(i, &mut c) {
out.diagnostics.insert(i, why.into());
}
} else if c.contains("#define") {
if let Err(why) = out.parse_define(i, &mut c) {
out.diagnostics.insert(i, why.into());
}
}
}
out
}
fn parse_const(&mut self, i: usize, c: &mut Cursor) -> Result<(), &'static str> {
if !c.whitespace() {
return Err("expected whitespace after const");
}
let is_bool = if c.literal("int") || c.literal("float") {
false
} else if c.literal("bool") {
true
} else {
return Err("only int, float and bool consts are options");
};
if !c.whitespace() {
return Err("expected whitespace after the type");
}
let name = c.word().ok_or("expected a name after the type")?;
c.whitespace();
if !c.literal("=") {
return Err("expected = after the name");
}
c.whitespace();
let value = c
.word_or_number()
.ok_or("expected a word or number after =")?;
c.whitespace();
if !c.literal(";") {
return Err("expected ; after the value");
}
c.whitespace();
let comment = if c.comments() {
non_empty(&c.rest())
} else if !c.is_end() {
return Err("only a comment may follow the ;");
} else {
None
};
if !is_const_option(&name) {
return Err("not one of the configurable consts");
}
if is_bool {
let default = match value.as_str() {
"true" => true,
"false" => false,
_ => return Err("a bool const option must be true or false"),
};
self.bools.insert(
i,
BoolOption {
kind: OptionKind::Const,
name,
comment,
default,
},
);
return Ok(());
}
let option = StringOption::create(OptionKind::Const, &name, comment.as_deref(), &value)
.ok_or("a const option needs a [...] list of values in its comment")?;
self.strings.insert(i, option);
Ok(())
}
fn parse_define(&mut self, i: usize, c: &mut Cursor) -> Result<(), &'static str> {
let commented = c.comments();
c.whitespace();
if !c.literal("#define") {
return Err("#define isn't where an option's would be");
}
if !c.whitespace() {
return Err("expected whitespace after #define");
}
let name = c.word().ok_or("expected a name after #define")?;
let mut spaced = c.whitespace();
if c.is_end() {
self.bools.insert(
i,
BoolOption {
kind: OptionKind::Define,
name,
comment: None,
default: !commented,
},
);
return Ok(());
}
if c.comments() {
let comment = non_empty(&c.rest());
self.bools.insert(
i,
BoolOption {
kind: OptionKind::Define,
name,
comment,
default: !commented,
},
);
return Ok(());
}
if !spaced {
return Err("only letters, digits and _ may be in an option's name");
}
if commented {
return Err("only boolean #define options may be commented out");
}
let value = c
.word_or_number()
.ok_or("the value is neither a number nor a word")?;
spaced = c.whitespace();
if c.is_end() {
return Err("a value #define needs a comment with a [...] list of values");
}
if !c.comments() {
return Err(if spaced {
"only a comment may follow the value"
} else {
"unexpected characters after the value"
});
}
let comment = c.rest().trim().to_owned();
let option = StringOption::create(OptionKind::Define, &name, Some(&comment), &value)
.ok_or("a value #define needs a [...] list of values in its comment")?;
self.strings.insert(i, option);
Ok(())
}
/// Line `i` (text `line`) with the player's values applied
/// (`OptionAnnotatedSource.edit`).
pub fn edit(&self, values: &OptionValues, i: usize, line: &str) -> String {
if let Some(o) = self.bools.get(&i) {
let value = values.bools.get(&o.name).copied();
return match o.kind {
OptionKind::Define => {
let on = value.unwrap_or(o.default);
let commented = line.trim().starts_with("//");
if commented && on {
let mut c = Cursor::new(line);
c.whitespace();
c.comments();
c.rest()
} else if !on {
format!("//{line}")
} else {
line.to_owned()
}
}
OptionKind::Const => match value {
Some(v) => edit_const(line, &o.default.to_string(), &v.to_string()),
None => line.to_owned(),
},
};
}
if let Some(o) = self.strings.get(&i) {
if let Some(v) = values.strings.get(&o.name) {
return match o.kind {
OptionKind::Define => format!(
"#define {} {v} // OptionAnnotatedSource: Changed option",
o.name
),
OptionKind::Const => edit_const(line, &o.default, v),
};
}
}
line.to_owned()
}
}
/// The first `current` after the `=` replaced by `new`.
fn edit_const(line: &str, current: &str, new: &str) -> String {
match line.find('=') {
Some(eq) => format!("{}{}", &line[..eq], line[eq..].replacen(current, new, 1)),
None => line.to_owned(),
}
}
/// Every option of a pack by name. Two definitions with different defaults
/// make the name ambiguous: Iris drops it.
#[derive(Default, Debug)]
pub struct OptionSet {
pub bools: BTreeMap<String, BoolOption>,
pub strings: BTreeMap<String, StringOption>,
}
impl OptionSet {
pub fn add_bool(&mut self, o: &BoolOption) {
match self.bools.get_mut(&o.name) {
Some(existing) if existing.default != o.default => {
self.bools.remove(&o.name);
}
Some(existing) => {
if existing.comment.is_none() {
*existing = o.clone();
}
}
None => {
self.bools.insert(o.name.clone(), o.clone());
}
}
}
pub fn add_string(&mut self, o: &StringOption) {
match self.strings.get_mut(&o.name) {
Some(existing) if existing.default != o.default => {
self.strings.remove(&o.name);
}
Some(existing) => {
if existing.comment.is_none() {
*existing = o.clone();
}
}
None => {
self.strings.insert(o.name.clone(), o.clone());
}
}
}
}
/// The player's values that differ from the defaults (`MutableOptionValues`).
#[derive(Default, Debug, Clone, PartialEq)]
pub struct OptionValues {
pub bools: BTreeMap<String, bool>,
pub strings: BTreeMap<String, String>,
}
impl OptionValues {
/// From saved `name=value` pairs. A boolean other than `true`/`false` is
/// ignored; a value outside an option's allowed list is kept (profiles and
/// hand-edited files may set one, as in OptiFine).
pub fn new(options: &OptionSet, saved: &BTreeMap<String, String>) -> Self {
let mut out = Self::default();
for (name, o) in &options.bools {
let value = match saved.get(name).map(String::as_str) {
Some("true") => true,
Some("false") => false,
_ => continue,
};
if value != o.default {
out.bools.insert(name.clone(), value);
}
}
// A saved value the option doesn't list (an old or hand-edited
// file) is dropped, as OptiFine's `ShaderOption.setValue` refuses it.
for (name, o) in &options.strings {
if let Some(v) = saved.get(name)
&& *v != o.default
&& o.allowed.contains(v)
{
out.strings.insert(name.clone(), v.clone());
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
fn one(line: &str) -> Annotated {
Annotated::parse(&[line.to_owned()])
}
fn string(line: &str) -> Option<(String, String, Vec<String>)> {
one(line)
.strings
.into_values()
.next()
.map(|o| (o.name, o.default, o.allowed))
}
#[test]
fn value_defines_need_an_allowed_list() {
// Iris's OptionDiscoverTest cases.
let s = |v: &[&str]| v.iter().map(|x| x.to_string()).collect::<Vec<_>>();
assert_eq!(
string("#define SECRET 0 //[0]"),
Some(("SECRET".into(), "0".into(), s(&["0"])))
);
assert_eq!(
string("#define SECRET 0//[0]"),
Some(("SECRET".into(), "0".into(), s(&["0"])))
);
assert_eq!(
string("#define OPT 1 //[0]"),
Some(("OPT".into(), "1".into(), s(&["0", "1"])))
);
for none in [
"#define PI 3.14 // It's PI.",
"#define PI 3.14",
"const int noiseTextureResolution = 512;",
"const int noiseTextureResolution = 512; // Default noise texture size [",
] {
let a = one(none);
assert!(a.strings.is_empty() && a.bools.is_empty(), "{none}");
}
assert_eq!(
string(" #define SHADOW_QUALITY 2.0f // Shadows [1.0f 2.0f 3.0f]"),
Some((
"SHADOW_QUALITY".into(),
"2.0f".into(),
s(&["1.0f", "2.0f", "3.0f"])
))
);
assert_eq!(
string("#define SKY_STYLE SKY_REALISTIC // [SKY_REALISTIC SKY_STYLISED]")
.unwrap()
.1,
"SKY_REALISTIC"
);
assert_eq!(
string("const int shadowMapResolution = 2048; // [1024 2048 4096]")
.unwrap()
.0,
"shadowMapResolution"
);
assert!(string("const int myResolution = 2048; // [1024 2048 4096]").is_none());
assert!(string("//#define SHADOW_QUALITY 2 // [1 2 3]").is_none());
}
#[test]
fn boolean_defines_and_references() {
let a = Annotated::parse(&[
"#define BLOOM // Bloom".into(),
"//#define DOF".into(),
"// #define MOTION_BLUR // Blur".into(),
"#define FOG(x) x".into(),
"#ifdef BLOOM".into(),
"#ifndef DOF // trailing text: not a reference".into(),
"#if defined MOTION_BLUR".into(),
"const bool shadowHardwareFiltering = true; // Filtering".into(),
]);
let b: Vec<(&str, bool, Option<&str>)> = a
.bools
.values()
.map(|o| (o.name.as_str(), o.default, o.comment.as_deref()))
.collect();
assert_eq!(
b,
[
("BLOOM", true, Some("Bloom")),
("DOF", false, None),
("MOTION_BLUR", false, Some("Blur")),
("shadowHardwareFiltering", true, Some("Filtering"))
]
);
assert_eq!(a.ifdef_refs.iter().collect::<Vec<_>>(), ["BLOOM"]);
}
#[test]
fn values_are_written_into_the_lines() {
let lines: Vec<String> = [
"#define BLOOM",
"//#define DOF",
"#define QUALITY 2 // [1 2 3]",
"const float sunPathRotation = -40.0; // [-40.0 0.0 40.0]",
"const bool shadowHardwareFiltering = false;",
]
.map(String::from)
.into();
let a = Annotated::parse(&lines);
let mut set = OptionSet::default();
a.bools.values().for_each(|o| set.add_bool(o));
a.strings.values().for_each(|o| set.add_string(o));
let saved: BTreeMap<String, String> = [
("BLOOM", "false"),
("DOF", "true"),
("QUALITY", "3"),
("sunPathRotation", "0.0"),
("shadowHardwareFiltering", "yes"),
]
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.into();
let values = OptionValues::new(&set, &saved);
assert!(
!values.bools.contains_key("shadowHardwareFiltering"),
"not a boolean: ignored"
);
let stale = [("QUALITY", "abc"), ("sunPathRotation", "")]
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.into();
assert!(
OptionValues::new(&set, &stale).strings.is_empty(),
"values the options don't list: ignored"
);
let edited: Vec<String> = lines
.iter()
.enumerate()
.map(|(i, l)| a.edit(&values, i, l))
.collect();
assert_eq!(
edited,
[
"//#define BLOOM",
"#define DOF",
"#define QUALITY 3 // OptionAnnotatedSource: Changed option",
"const float sunPathRotation = 0.0; // [-40.0 0.0 40.0]",
"const bool shadowHardwareFiltering = false;",
]
);
}
#[test]
fn conflicting_defaults_drop_the_option() {
let mut set = OptionSet::default();
let a = Annotated::parse(&["#define Q 1 // [1 2]".into()]);
let b = Annotated::parse(&["#define Q 2 // Quality [1 2]".into()]);
let c = Annotated::parse(&["#define Q 1 // Quality [1 2]".into()]);
set.add_string(&a.strings[&0]);
set.add_string(&c.strings[&0]);
assert_eq!(
set.strings["Q"].comment.as_deref(),
Some("Quality"),
"the commented one wins"
);
set.add_string(&b.strings[&0]);
assert!(set.strings.is_empty());
}
}
-941
View File
@@ -1,941 +0,0 @@
//! Iris's full-screen passes and the colour targets they draw to: which
//! programs run (`begin*`, `prepare*`, `deferred*`, `composite*`, `final`),
//! the targets each one writes (`/* DRAWBUFFERS:01 */`, `/* RENDERTARGETS:
//! 0,1 */`), the targets' formats and clearing (`const int colortex0Format
//! = RGBA16F;` and friends), and which side of each ping-ponged target a
//! pass reads (`BufferFlipper`). Read from the preprocessed sources with
//! their comments, as Iris's `ProgramDirectives`, `PackRenderTargetDirectives`
//! and `CompositeRenderer` do.
use crate::{
JavaPack,
options::OptionValues,
preprocess::Environment,
properties::{Blend, LEGACY_TARGETS, ShaderProperties},
translate::{Geometry, Link, Translated},
};
use std::collections::{BTreeMap, BTreeSet};
/// Colour targets Iris supports (`IrisLimits.MAX_COLOR_BUFFERS`).
pub const MAX_COLOR_BUFFERS: u32 = 32;
/// The formats `const int colortexNFormat` may name (`InternalTextureFormat`).
pub const TEXTURE_FORMATS: &[&str] = &[
"RGBA",
"R8",
"RG8",
"RGB8",
"RGBA8",
"R8_SNORM",
"RG8_SNORM",
"RGB8_SNORM",
"RGBA8_SNORM",
"R16",
"RG16",
"RGB16",
"RGBA16",
"R16_SNORM",
"RG16_SNORM",
"RGB16_SNORM",
"RGBA16_SNORM",
"R16F",
"RG16F",
"RGB16F",
"RGBA16F",
"R32F",
"RG32F",
"RGB32F",
"RGBA32F",
"R8I",
"RG8I",
"RGB8I",
"RGBA8I",
"R8UI",
"RG8UI",
"RGB8UI",
"RGBA8UI",
"R16I",
"RG16I",
"RGB16I",
"RGBA16I",
"R16UI",
"RG16UI",
"RGB16UI",
"RGBA16UI",
"R32I",
"RG32I",
"RGB32I",
"RGBA32I",
"R32UI",
"RG32UI",
"RGB32UI",
"RGBA32UI",
"RGBA2",
"RGBA4",
"R3_G3_B2",
"RGB5_A1",
"RGB565",
"RGB10_A2",
"RGB10_A2UI",
"R11F_G11F_B10F",
"RGB9_E5",
];
/// The value of the last `/* NAME:value */` in `text` and where it starts
/// (`CommentDirectiveParser.findDirective`).
pub fn comment_directive(text: &str, name: &str) -> Option<(usize, String)> {
let prefix = format!("{name}:");
let at = text.rfind(&prefix)?;
if !text[..at].trim_end().ends_with("/*") {
return None;
}
let rest = &text[at + prefix.len()..];
let end = rest.find("*/")?;
Some((at, rest[..end].trim().to_owned()))
}
/// A `const <type> <key> = <value>;` line (`ConstDirectiveParser`).
#[derive(Clone, Debug, PartialEq)]
pub struct ConstDirective {
/// `int`, `float`, `vec2`, `ivec3`, `vec4` or `bool`.
pub ty: &'static str,
pub key: String,
pub value: String,
}
/// Every line of `text` that starts with a `const` Iris reads, in order.
pub fn const_directives(text: &str) -> Vec<ConstDirective> {
text.lines().filter_map(const_directive).collect()
}
fn const_directive(line: &str) -> Option<ConstDirective> {
if !line.contains("const") || !line.contains('=') || !line.contains(';') {
return None;
}
let rest = line.trim().strip_prefix("const")?;
if !rest.starts_with(char::is_whitespace) {
return None;
}
let rest = rest.trim_start();
// In Iris's order: `ivec3` before `vec4` doesn't matter, but `int` must
// not take `ivec3`'s place, and it can't (`ivec3` starts with `i`, `v`).
let ty = ["int", "float", "vec2", "ivec3", "vec4", "bool"]
.into_iter()
.find(|t| rest.starts_with(t))?;
let rest = &rest[ty.len()..];
if !rest.starts_with(char::is_whitespace) {
return None;
}
let (key, value) = rest.split_once('=')?;
let key = key.trim();
if key.is_empty() || !key.chars().all(|c| c.is_alphanumeric() || c == '_') {
return None;
}
let value = value.split_once(';')?.0.trim();
Some(ConstDirective {
ty,
key: key.to_owned(),
value: value.to_owned(),
})
}
/// The targets a fragment program writes, by output location: the later of
/// its `DRAWBUFFERS` and `RENDERTARGETS` directives, or `None` without one
/// (Iris then takes the `gl_FragData` indices it writes).
pub fn draw_buffers(fragment: &str) -> Result<Option<Vec<u32>>, String> {
let draw = comment_directive(fragment, "DRAWBUFFERS");
let render = comment_directive(fragment, "RENDERTARGETS");
let (digits, value) = match (draw, render) {
(Some(d), Some(r)) if d.0 > r.0 => (true, d.1),
(_, Some(r)) => (false, r.1),
(Some(d), None) => (true, d.1),
(None, None) => return Ok(None),
};
let bad = || format!("invalid render targets `{value}`");
let targets: Vec<u32> = if digits {
value
.chars()
.map(|c| c.to_digit(10).ok_or_else(bad))
.collect::<Result<_, _>>()?
} else {
value
.split(',')
.map(|t| t.trim().parse().map_err(|_| bad()))
.collect::<Result<_, _>>()?
};
match targets.iter().find(|&&t| t >= MAX_COLOR_BUFFERS) {
Some(t) => Err(format!(
"render target {t} is not supported, only {MAX_COLOR_BUFFERS} are"
)),
None => Ok(Some(targets)),
}
}
/// The index of a target name, `colortex<N>` or a legacy one (`gaux1`).
pub fn target_index(name: &str) -> Option<u32> {
match LEGACY_TARGETS.iter().position(|t| *t == name) {
Some(i) => Some(i as u32),
None => name
.strip_prefix("colortex")?
.parse()
.ok()
.filter(|&i| i < MAX_COLOR_BUFFERS),
}
}
/// A key naming a target and a suffix (`colortex4Format`, `gaux1Clear`).
fn target_key(key: &str, suffix: &str) -> Option<u32> {
target_index(key.strip_suffix(suffix)?)
}
/// A colour target's settings (`RenderTargetSettings`, `size.buffer.*`).
#[derive(Clone, Debug, PartialEq)]
pub struct TargetSettings {
/// One of [`TEXTURE_FORMATS`]; `RGBA` (8 bits per channel) by default.
pub format: String,
/// Cleared every frame (the default), or kept for the next frame.
pub clear: bool,
/// Iris clears to black, or `colortex0` to the fog colour, without one.
pub clear_color: Option<[f32; 4]>,
/// `size.buffer.<name>`: a fraction of the screen or a size in pixels.
pub size: Option<TargetSize>,
}
impl Default for TargetSettings {
fn default() -> Self {
Self {
format: "RGBA".into(),
clear: true,
clear_color: None,
size: None,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum TargetSize {
Relative(f32, f32),
Pixels(u32, u32),
}
/// Every target's settings from the consts of `fragments`, read in order so
/// the last one wins, then `size.buffer.*` from the properties.
pub fn target_settings<'a>(
fragments: impl IntoIterator<Item = &'a str>,
properties: &ShaderProperties,
) -> Vec<TargetSettings> {
let mut targets = vec![TargetSettings::default(); MAX_COLOR_BUFFERS as usize];
for fragment in fragments {
for c in const_directives(fragment) {
match c.ty {
"int" => {
if let Some(i) = target_key(&c.key, "Format") {
let format = c.value.to_uppercase();
if TEXTURE_FORMATS.contains(&format.as_str()) {
targets[i as usize].format = format;
}
}
}
"bool" => {
if let (Some(i), Ok(clear)) = (target_key(&c.key, "Clear"), c.value.parse()) {
targets[i as usize].clear = clear;
}
}
"vec4" => {
if let (Some(i), Some(color)) =
(target_key(&c.key, "ClearColor"), parse_vec4(&c.value))
{
targets[i as usize].clear_color = Some(color);
}
}
_ => {}
}
}
}
for (key, value) in properties.preprocessed.iter() {
let Some(i) = key.strip_prefix("size.buffer.").and_then(target_index) else {
continue;
};
let parts: Vec<&str> = value.split_whitespace().collect();
if let [w, h] = parts[..] {
targets[i as usize].size = if w.contains('.') || h.contains('.') {
w.parse()
.ok()
.zip(h.parse().ok())
.map(|(w, h)| TargetSize::Relative(w, h))
} else {
w.parse()
.ok()
.zip(h.parse().ok())
.map(|(w, h)| TargetSize::Pixels(w, h))
};
}
}
targets
}
fn parse_vec4(value: &str) -> Option<[f32; 4]> {
let args = value.strip_prefix("vec4")?.trim();
let args = args.strip_prefix('(')?.strip_suffix(')')?;
let parts: Vec<f32> = args
.split(',')
.map(|p| p.trim().parse().ok())
.collect::<Option<_>>()?;
parts.try_into().ok()
}
/// One full-screen pass.
#[derive(Clone, Debug, PartialEq)]
pub struct Pass {
/// The program's path without extension (`world0/composite1`).
pub program: String,
/// What it writes, by output location; `None`: the outputs it writes.
pub draw_buffers: Option<Vec<u32>>,
/// Targets it reads from their alternate side (it writes the other side).
pub reads_alt: BTreeSet<u32>,
/// Targets whose mipmaps it wants generated before it runs.
pub mipmapped: BTreeSet<u32>,
/// `scale.<name>`: the fraction of the screen it draws.
pub scale: f32,
/// `blend.<program>` override for all outputs of this pass.
pub blend: Option<Blend>,
/// `blend.<program>.<buffer>` overrides per colour target index.
pub buffer_blends: Vec<(u32, Blend)>,
}
/// The full-screen passes of one program folder, in order, and the
/// flipping state between them.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Passes {
pub targets: Vec<TargetSettings>,
pub begin: Vec<Pass>,
pub prepare: Vec<Pass>,
/// The targets gbuffers programs (and Horizon's world) write on their
/// alternate side, before and after translucent geometry.
pub flipped_after_prepare: BTreeSet<u32>,
/// The target Horizon's world goes into: the first one `gbuffers_terrain`
/// (or the program Iris falls back to for terrain) draws to, `colortex0`
/// without one.
pub world_target: u32,
pub deferred: Vec<Pass>,
pub flipped_after_translucent: BTreeSet<u32>,
pub composite: Vec<Pass>,
/// `final`, which writes the screen; `None` without one (Iris then
/// copies `colortex0` to the screen).
pub final_pass: Option<Pass>,
/// After `final`, these targets' alternate side is copied back so the
/// 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 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)]
pub struct Directives {
/// `sunPathRotation`, degrees: how far the sun and moon's path tilts
/// toward the north or south (a Z rotation between the sky's Y -90 and
/// its angle, `CelestialUniforms`).
pub sun_path_rotation: f32,
/// `eyeBrightnessHalflife`, tenths of a second: `eyeBrightnessSmooth`.
pub eye_brightness_half_life: f32,
/// `wetnessHalflife`, tenths of a second: `wetness` smoothing when raining.
pub wetness_half_life: f32,
/// `drynessHalflife`, tenths of a second: `wetness` smoothing when dry.
pub dryness_half_life: f32,
}
impl Default for Directives {
fn default() -> Self {
Self {
sun_path_rotation: 0.0,
eye_brightness_half_life: 10.0,
wetness_half_life: 600.0,
dryness_half_life: 200.0,
}
}
}
impl Directives {
/// Read from `fragments` in order, the last one winning.
pub fn read<'a>(fragments: impl IntoIterator<Item = &'a str>) -> Self {
let mut out = Self::default();
for fragment in fragments {
for c in const_directives(fragment) {
// Java's `Float.parseFloat` takes a trailing `f`.
let value = c.value.trim_end_matches(['f', 'F']).parse::<f32>();
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,
("float", "wetnessHalflife", Ok(v)) => out.wetness_half_life = v,
("float", "drynessHalflife", Ok(v)) => out.dryness_half_life = v,
_ => {}
}
}
}
out
}
}
/// Iris's single programs (`ProgramId`), in order.
const SINGLE_PROGRAMS: &[&str] = &[
"shadow",
"shadow_solid",
"shadow_cutout",
"shadow_water",
"shadow_entities",
"shadow_lightning",
"shadow_block",
"gbuffers_basic",
"gbuffers_line",
"gbuffers_textured",
"gbuffers_textured_lit",
"gbuffers_skybasic",
"gbuffers_skytextured",
"gbuffers_clouds",
"gbuffers_terrain",
"gbuffers_terrain_solid",
"gbuffers_terrain_cutout",
"gbuffers_damagedblock",
"gbuffers_block",
"gbuffers_block_translucent",
"gbuffers_beaconbeam",
"gbuffers_item",
"gbuffers_entities",
"gbuffers_entities_translucent",
"gbuffers_lightning",
"gbuffers_particles",
"gbuffers_particles_translucent",
"gbuffers_entities_glowing",
"gbuffers_armor_glint",
"gbuffers_spidereyes",
"gbuffers_hand",
"gbuffers_weather",
"gbuffers_water",
"gbuffers_hand_water",
"dh_terrain",
"dh_water",
"dh_generic",
"dh_shadow",
"final",
];
/// The program groups that run as full-screen passes, in Iris's order.
const GROUPS: &[&str] = &["begin", "prepare", "deferred", "composite"];
impl JavaPack {
/// The full-screen passes of the programs in `dir` (`world0`, or `""`
/// for the pack's root), with the player's `values`.
pub fn passes(
&self,
dir: &str,
values: &OptionValues,
env: &Environment,
properties: &ShaderProperties,
) -> Result<Passes, String> {
let path = |name: &str| {
if dir.is_empty() {
name.to_owned()
} else {
format!("{dir}/{name}")
}
};
// A pass needs both halves, and its `program.*.enabled` to hold.
let exists = |name: &str| {
let p = path(name);
self.programs.contains(&format!("{p}.vsh"))
&& self.programs.contains(&format!("{p}.fsh"))
&& properties.program_enabled(&p, &self.options, values)
};
let fragment = |name: &str| -> Result<String, String> {
let file = format!("{}.fsh", path(name));
Ok(self.preprocess(&file, values, env)?.with_comments())
};
let group = |base: &str| -> Vec<String> {
(0..100)
.map(|i| {
if i == 0 {
base.to_owned()
} else {
format!("{base}{i}")
}
})
.filter(|name| exists(name))
.collect()
};
let groups: Vec<Vec<String>> = GROUPS.iter().map(|g| group(g)).collect();
// Target settings come from the fragments of every pass, in Iris's
// order (`ProgramSet.locateDirectives`): some packs declare them in
// `final` only.
let mut sources: Vec<String> = Vec::new();
for name in group("shadowcomp")
.iter()
.chain(&groups[0])
.chain(&groups[1])
{
sources.push(fragment(name)?);
}
for name in SINGLE_PROGRAMS {
if exists(name) {
sources.push(fragment(name)?);
}
}
let mut fragments: BTreeMap<String, String> = BTreeMap::new();
for name in groups[2].iter().chain(&groups[3]) {
let text = fragment(name)?;
sources.push(text.clone());
fragments.insert(name.clone(), text);
}
for name in &groups[0..2].concat() {
fragments.insert(name.clone(), fragment(name)?);
}
let mut out = Passes {
targets: target_settings(sources.iter().map(String::as_str), properties),
directives: Directives::read(sources.iter().map(String::as_str)),
..Passes::default()
};
let resolve_blends = |prog: &str| {
let blend = properties.blend(prog).and_then(Result::ok);
let buffer_blends: Vec<(u32, Blend)> = properties
.buffer_blends(prog)
.into_iter()
.filter_map(|(i, b)| Some((i, b.ok()?)))
.collect();
(blend, buffer_blends)
};
let flips = explicit_flips(properties);
let run = |group: &[String], pre: &str, flipped: &mut BTreeSet<u32>| {
*flipped = flips_pre(&flips, pre, flipped);
let mut passes = Vec::new();
for name in group {
let text = &fragments[name];
let prog = path(name);
let (blend, buffer_blends) = resolve_blends(&prog);
let pass = Pass {
program: prog.clone(),
draw_buffers: draw_buffers(text).map_err(|e| format!("{prog}: {e}"))?,
reads_alt: flipped.clone(),
mipmapped: mipmapped(text),
scale: properties
.preprocessed
.get(&format!("scale.{name}"))
.and_then(|v| v.split_whitespace().next()?.parse().ok())
.unwrap_or(1.0),
blend,
buffer_blends,
};
*flipped = flip_set(&pass, &flips);
passes.push(pass);
}
Ok::<_, String>(passes)
};
let mut flipped = BTreeSet::new();
out.begin = run(&groups[0], "begin_pre", &mut flipped)?;
out.prepare = run(&groups[1], "prepare_pre", &mut flipped)?;
out.flipped_after_prepare = flipped.clone();
// Iris's fallbacks for terrain (`ProgramFallbackResolver`).
let terrain = [
"gbuffers_terrain",
"gbuffers_textured_lit",
"gbuffers_textured",
"gbuffers_basic",
]
.into_iter()
.find(|name| exists(name));
if let Some(name) = terrain {
let buffers =
draw_buffers(&fragment(name)?).map_err(|e| format!("{}: {e}", path(name)))?;
out.world_target = buffers.and_then(|b| b.first().copied()).unwrap_or(0);
}
out.deferred = run(&groups[2], "deferred_pre", &mut flipped)?;
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)?;
if exists("final") {
let prog = path("final");
let (blend, buffer_blends) = resolve_blends(&prog);
out.final_pass = Some(Pass {
program: prog,
draw_buffers: None,
reads_alt: out.flipped_at_end.clone(),
mipmapped: mipmapped(&fragment("final")?),
scale: 1.0,
blend,
buffer_blends,
});
}
Ok(out)
}
}
/// A pass's programs translated, with the targets it draws to.
pub struct Program {
pub pass: Pass,
pub vertex: Translated,
pub fragment: Translated,
/// The target each output location writes: the pass's directive, or
/// as Iris does without one, location `i` to target `i`.
pub draw_buffers: Vec<u32>,
/// The bindings both programs share.
pub link: Link,
}
impl JavaPack {
/// Translate `pass`'s `.vsh` and `.fsh`.
pub fn compile(
&self,
pass: &Pass,
targets: &[TargetSettings],
values: &OptionValues,
env: &Environment,
properties: &ShaderProperties,
) -> Result<Program, String> {
let mut link = Link {
image_formats: properties.image_formats(),
shared_samplers: shared_samplers(targets),
..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 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(Program {
pass: pass.clone(),
vertex,
fragment,
draw_buffers,
link,
})
}
}
/// 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
/// [`Link::shared_samplers`].
pub fn shared_samplers(targets: &[TargetSettings]) -> BTreeMap<String, String> {
let mut out = BTreeMap::new();
for (i, t) in targets.iter().enumerate() {
let f = &t.format;
let class = if f.ends_with("32F") || f.ends_with('I') {
"iris_nearest"
} else {
"iris_linear"
};
out.insert(format!("colortex{i}"), class.to_owned());
if let Some(legacy) = LEGACY_TARGETS.get(i) {
out.insert((*legacy).to_owned(), class.to_owned());
}
}
for name in [
"depthtex0",
"depthtex1",
"depthtex2",
"gdepthtex",
"shadowtex0",
"shadowtex1",
"shadow",
"watershadow",
"dhDepthTex",
"dhDepthTex0",
"dhDepthTex1",
"vxDepthTexOpaque",
"vxDepthTexTrans",
] {
out.insert(name.to_owned(), "iris_nearest".to_owned());
}
for name in ["shadowcolor", "shadowcolor0", "shadowcolor1"] {
out.insert(name.to_owned(), "iris_linear".to_owned());
}
out.insert("noisetex".to_owned(), "iris_repeat".to_owned());
out
}
fn toggle(set: &mut BTreeSet<u32>, buffer: u32) {
if !set.remove(&buffer) {
set.insert(buffer);
}
}
/// `flip.<pass>.<buffer> = true|false`, by pass and target index.
fn explicit_flips(properties: &ShaderProperties) -> BTreeMap<&str, BTreeMap<u32, bool>> {
let mut out: BTreeMap<&str, BTreeMap<u32, bool>> = BTreeMap::new();
for (key, value) in properties.preprocessed.iter() {
let Some((pass, buffer)) = key.strip_prefix("flip.").and_then(|k| k.split_once('.')) else {
continue;
};
if let (Some(i), Ok(flip)) = (target_index(buffer), value.trim().parse()) {
out.entry(pass).or_default().insert(i, flip);
}
}
out
}
/// The flipped set after `pass` ran: what it read, flipped by its writes
/// and its explicit flips.
fn flip_set(pass: &Pass, flips: &BTreeMap<&str, BTreeMap<u32, bool>>) -> BTreeSet<u32> {
let name = pass.program.rsplit('/').next().unwrap_or(&pass.program);
let explicit = flips.get(name);
let mut set = pass.reads_alt.clone();
for &buffer in pass.draw_buffers.as_deref().unwrap_or(&[0]) {
if explicit.and_then(|f| f.get(&buffer)) != Some(&false) {
toggle(&mut set, buffer);
}
}
for (&buffer, &flip) in explicit.into_iter().flatten() {
if flip {
toggle(&mut set, buffer);
}
}
set
}
fn flips_pre(
flips: &BTreeMap<&str, BTreeMap<u32, bool>>,
pre: &str,
before: &BTreeSet<u32>,
) -> BTreeSet<u32> {
let mut set = before.clone();
for (&buffer, &flip) in flips.get(pre).into_iter().flatten() {
if flip {
toggle(&mut set, buffer);
}
}
set
}
/// `const bool colortexNMipmapEnabled = true;` in a program's fragment.
fn mipmapped(fragment: &str) -> BTreeSet<u32> {
let mut out = BTreeSet::new();
for c in const_directives(fragment) {
if c.ty != "bool" {
continue;
}
if let Some(i) = target_key(&c.key, "MipmapEnabled") {
match c.value.as_str() {
"true" => out.insert(i),
"false" => out.remove(&i),
_ => false,
};
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn comment_directives_as_iris_finds_them() {
// Iris's own cases (`CommentDirectiveParser.Tests`).
let find = |text: &str, name: &str| comment_directive(text, name).map(|d| d.1);
let draw = |text: &str| find(text, "DRAWBUFFERS");
assert_eq!(
draw("Some normal text that doesn't contain a DRAWBUFFERS directive"),
None
);
assert_eq!(
draw("doesn't contain a /* DRAWBUFFERS: directive of any sort"),
None
);
assert_eq!(
draw("/*DRAWBUFFERS:321*/ OptiFine will detect this").as_deref(),
Some("321")
);
assert_eq!(
draw("a directive: /* DRAWBUFFERS:321 */").as_deref(),
Some("321")
);
assert_eq!(
draw("This is a line /* DRAWBUFFERS:31 */ containing").as_deref(),
Some("31")
);
assert_eq!(
draw("/* DRAWBUFFERS: */ invalid but matching").as_deref(),
Some("")
);
assert_eq!(draw(" /*DRAWBUFFERS:12*/").as_deref(), Some("12"));
let lines = "/* RENDERTARGETS:Duplicate handling? */\nuniform sampler2D test;\n\
/* RENDERTARGETS:Within a line? */ Let's see /* RENDERTARGETS:It works */\n";
assert_eq!(find(lines, "RENDERTARGETS").as_deref(), Some("It works"));
// The later directive wins, whichever kind it is.
assert_eq!(
draw_buffers("/* RENDERTARGETS: 4,10 */ /* DRAWBUFFERS:02 */"),
Ok(Some(vec![0, 2]))
);
assert_eq!(
draw_buffers("/* DRAWBUFFERS:02 */ /* RENDERTARGETS: 4,10 */"),
Ok(Some(vec![4, 10]))
);
assert_eq!(draw_buffers("void main() {}"), Ok(None));
assert!(draw_buffers("/* RENDERTARGETS: 40 */").is_err());
assert!(draw_buffers("/* DRAWBUFFERS:0a */").is_err());
}
#[test]
fn sky_directives_last_one_wins() {
assert_eq!(Directives::read([]), Directives::default());
assert_eq!(Directives::default().eye_brightness_half_life, 10.0);
let d = Directives::read([
"const float sunPathRotation = 30.0;\nconst float eyeBrightnessHalflife = 4.0f;\nconst float wetnessHalflife = 300.0;",
"const float sunPathRotation = -40.0; // Complementary Unbound",
"const int sunPathRotation = 5;",
]);
assert_eq!(d.sun_path_rotation, -40.0);
assert_eq!(d.eye_brightness_half_life, 4.0);
assert_eq!(d.wetness_half_life, 300.0);
assert_eq!(d.dryness_half_life, 200.0, "the default");
}
#[test]
fn const_directives_and_target_settings() {
let text = "\
const int colortex4Format = rgba16f;
const bool gaux2Clear = false; // kept between frames
const vec4 colortex0ClearColor = vec4(0.5, 0.25, 0.0, 1.0);
const int colortex5Format = NOT_A_FORMAT;
uniform const int x = 1;
const int colortex6Format=RGBA8;
const intx colortex7Format = RGBA8;
const ivec3 workGroups = ivec3(16, 8, 16);
";
let consts = const_directives(text);
assert_eq!(
consts
.iter()
.map(|c| (c.ty, c.key.as_str(), c.value.as_str()))
.collect::<Vec<_>>(),
[
("int", "colortex4Format", "rgba16f"),
("bool", "gaux2Clear", "false"),
("vec4", "colortex0ClearColor", "vec4(0.5, 0.25, 0.0, 1.0)"),
("int", "colortex5Format", "NOT_A_FORMAT"),
("int", "colortex6Format", "RGBA8"),
("ivec3", "workGroups", "ivec3(16, 8, 16)"),
],
"a type needs a space after it, the name doesn't"
);
let targets = target_settings([text], &ShaderProperties::default());
assert_eq!(targets.len(), MAX_COLOR_BUFFERS as usize);
assert_eq!(targets[4].format, "RGBA16F");
assert_eq!(targets[5].format, "RGBA", "unknown formats are ignored");
assert!(!targets[5].clear, "gaux2 is colortex5");
assert_eq!(targets[0].clear_color, Some([0.5, 0.25, 0.0, 1.0]));
assert_eq!(targets[1], TargetSettings::default());
}
}
-594
View File
@@ -1,594 +0,0 @@
//! The C preprocessor pass: Iris's standard macros (`StandardMacros`,
//! `IrisDefines`) defined, then every directive and macro expanded by pp-rs,
//! the preprocessor naga's GLSL front end uses. The output keeps the inlined
//! source's line numbers, so an error later on still names `file:line`.
use crate::include::Inlined;
use pp_rs::{
pp::Preprocessor,
token::{PreprocessorError, Punct, TokenValue},
};
use std::collections::HashMap;
/// The version of Minecraft packs are told they run on (`MC_VERSION`,
/// Iris's `formatVersionString("26.3")`).
pub const MC_VERSION: &str = "260300";
/// The Iris release whose behaviour the translation follows (`IRIS_VERSION`, 1.10.0).
pub const IRIS_VERSION: &str = "11000";
/// Iris's `WorldRenderingPhase`, in order: `MC_RENDER_STAGE_<name>` is the index.
pub const RENDER_STAGES: &[&str] = &[
"NONE",
"SKY",
"SUNSET",
"CUSTOM_SKY",
"SUN",
"MOON",
"STARS",
"VOID",
"TERRAIN_SOLID",
"TERRAIN_CUTOUT_MIPPED",
"TERRAIN_CUTOUT",
"ENTITIES",
"BLOCK_ENTITIES",
"DESTROY",
"OUTLINE",
"DEBUG",
"HAND_SOLID",
"TERRAIN_TRANSLUCENT",
"TRIPWIRE",
"PARTICLES",
"CLOUDS",
"RAIN_SNOW",
"WORLD_BORDER",
"HAND_TRANSLUCENT",
];
/// Iris's `BiomeCategories`, in order: `CAT_<name>` is the index.
pub const BIOME_CATEGORIES: &[&str] = &[
"NONE",
"TAIGA",
"EXTREME_HILLS",
"JUNGLE",
"MESA",
"PLAINS",
"SAVANNA",
"ICY",
"THE_END",
"BEACH",
"FOREST",
"OCEAN",
"DESERT",
"RIVER",
"SWAMP",
"MUSHROOM",
"NETHER",
"MOUNTAIN",
"UNDERGROUND",
];
/// Distant Horizons block kinds Iris always defines (`DH_BLOCK_<name>`).
const DH_BLOCKS: &[&str] = &[
"UNKNOWN",
"LEAVES",
"STONE",
"WOOD",
"METAL",
"DIRT",
"LAVA",
"DEEPSLATE",
"SNOW",
"SAND",
"TERRACOTTA",
"NETHER_STONE",
"WATER",
"GRASS",
"AIR",
"ILLUMINATED",
];
/// The macros every program is preprocessed with.
#[derive(Clone, Debug)]
pub struct Environment {
pub defines: Vec<(String, String)>,
}
impl Environment {
/// What Iris defines (`IrisDefines.createIrisReplacements`) for a GPU
/// whose adapter reports `vendor` and `renderer`, without any GL
/// extension (`MC_GL_*`) or mod macro: packs then take their portable
/// paths. Biomes (`BIOME_*`) and the texture format come from the game:
/// add them with [`Environment::define`].
pub fn iris(vendor: &str, renderer: &str, mipmap_levels: u32) -> Self {
let mut env = Self {
defines: Vec::new(),
};
env.define("MC_VERSION", MC_VERSION);
env.define("MC_MIPMAP_LEVEL", &mipmap_levels.to_string());
env.define("IRIS_VERSION", IRIS_VERSION);
// Horizon's renderer stands in for GL 4.6 / GLSL 4.60.
env.define("MC_GL_VERSION", "460");
env.define("MC_GLSL_VERSION", "460");
env.define(os_macro(), "");
env.define(vendor_macro(vendor), "");
env.define(renderer_macro(renderer), "");
for flag in [
"IS_IRIS",
"IRIS_REQUIRES_SEPARATE_ENTITY_DRAWS",
"IRIS_HAS_TRANSLUCENCY_SORTING",
] {
env.define(flag, "");
}
env.define("MAX_COLOR_BUFFERS", "32");
env.define("IRIS_TAG_SUPPORT", "2");
for (i, name) in DH_BLOCKS.iter().enumerate() {
env.define(&format!("DH_BLOCK_{name}"), &i.to_string());
}
env.define("MC_NORMAL_MAP", "");
env.define("MC_SPECULAR_MAP", "");
env.define("MC_RENDER_QUALITY", "1.0");
env.define("MC_SHADOW_QUALITY", "1.0");
env.define("MC_HAND_DEPTH", "0.125");
for (i, name) in RENDER_STAGES.iter().enumerate() {
env.define(&format!("MC_RENDER_STAGE_{name}"), &i.to_string());
}
for (i, name) in BIOME_CATEGORIES.iter().enumerate() {
env.define(&format!("CAT_{name}"), &i.to_string());
}
env.define("PPT_NONE", "0");
env.define("PPT_RAIN", "1");
env.define("PPT_SNOW", "2");
env
}
/// Define `name` (replacing an earlier value).
pub fn define(&mut self, name: &str, value: &str) {
self.defines.retain(|(n, _)| n != name);
self.defines.push((name.to_owned(), value.to_owned()));
}
}
fn os_macro() -> &'static str {
if cfg!(target_os = "macos") {
"MC_OS_MAC"
} else if cfg!(windows) {
"MC_OS_WINDOWS"
} else {
"MC_OS_LINUX"
}
}
/// `StandardMacros.getVendor`.
pub fn vendor_macro(vendor: &str) -> &'static str {
let v = vendor.to_lowercase();
const TABLE: &[(&str, &str)] = &[
("ati", "MC_GL_VENDOR_ATI"),
("intel", "MC_GL_VENDOR_INTEL"),
("nvidia", "MC_GL_VENDOR_NVIDIA"),
("amd", "MC_GL_VENDOR_AMD"),
("x.org", "MC_GL_VENDOR_XORG"),
];
TABLE
.iter()
.find(|(prefix, _)| v.starts_with(prefix))
.map_or("MC_GL_VENDOR_OTHER", |(_, m)| m)
}
/// `StandardMacros.getRenderer`.
pub fn renderer_macro(renderer: &str) -> &'static str {
let r = renderer.to_lowercase();
const TABLE: &[(&str, &str)] = &[
("amd", "MC_GL_RENDERER_RADEON"),
("ati", "MC_GL_RENDERER_RADEON"),
("radeon", "MC_GL_RENDERER_RADEON"),
("gallium", "MC_GL_RENDERER_GALLIUM"),
("intel", "MC_GL_RENDERER_INTEL"),
("geforce", "MC_GL_RENDERER_GEFORCE"),
("nvidia", "MC_GL_RENDERER_GEFORCE"),
("quadro", "MC_GL_RENDERER_QUADRO"),
("nvs", "MC_GL_RENDERER_QUADRO"),
("mesa", "MC_GL_RENDERER_MESA"),
("apple", "MC_GL_RENDERER_APPLE"),
];
TABLE
.iter()
.find(|(prefix, _)| r.starts_with(prefix))
.map_or("MC_GL_RENDERER_OTHER", |(_, m)| m)
}
/// A program after the preprocessor: plain GLSL with no directives.
#[derive(Debug)]
pub struct Preprocessed {
/// The source, one output line per inlined line (macro expansions joined).
pub text: String,
/// The `#version` line's words (`["330", "compatibility"]`), if any.
pub version: Vec<String>,
/// `#extension` lines' words (`["GL_ARB_gpu_shader5", ":", "enable"]`).
pub extensions: Vec<Vec<String>>,
/// The comments Iris reads directives from (`/* DRAWBUFFERS:01 */`,
/// `const int colortex0Format = RGBA16F;`) that are outside `#if`'d out
/// blocks, with their output line. Iris keeps comments through its
/// preprocessor; pp-rs drops them.
pub comments: Vec<(u32, String)>,
}
impl Preprocessed {
/// The text with its kept comments put back at the start of their
/// lines: what Iris's directive parsers read.
pub fn with_comments(&self) -> String {
let mut comments = self.comments.iter().peekable();
let mut out = String::new();
for (i, line) in self.text.lines().enumerate() {
while let Some((_, comment)) = comments.next_if(|(at, _)| *at as usize <= i + 1) {
out.push_str(comment);
out.push(' ');
}
out.push_str(line);
out.push('\n');
}
out
}
}
/// What a comment must hold for Iris to read a directive from it.
const DIRECTIVE_WORDS: &[&str] = &["DRAWBUFFERS:", "RENDERTARGETS:", "const"];
/// The identifier a kept comment is replaced by while pp-rs runs.
const COMMENT_MARK: &str = "iris_comment_";
/// Replace the comments holding [`DIRECTIVE_WORDS`] with an identifier
/// (and their line breaks), so the ones in `#if`'d out blocks disappear with
/// them. Comments on directive lines are left alone: there the identifier
/// would end up in a macro or an `#if`.
fn mark_comments(text: &str) -> (String, Vec<String>) {
let mut out = String::with_capacity(text.len());
let mut comments = Vec::new();
let (mut directive, mut line_start, mut continued) = (false, true, false);
let mut rest = text;
while let Some(c) = rest.chars().next() {
let comment_len = if rest.starts_with("//") {
rest.find('\n').unwrap_or(rest.len())
} else if let Some(body) = rest.strip_prefix("/*") {
body.find("*/").map_or(rest.len(), |i| i + 4)
} else {
0
};
if comment_len > 0 {
let comment = &rest[..comment_len];
if !directive && DIRECTIVE_WORDS.iter().any(|w| comment.contains(w)) {
out.push_str(&format!(" {COMMENT_MARK}{} ", comments.len()));
out.extend(std::iter::repeat_n('\n', comment.matches('\n').count()));
comments.push(comment.to_owned());
} else {
out.push_str(comment);
}
if comment.contains('\n') {
// A comment spanning lines ends a directive like a line break.
directive = false;
}
line_start = false;
rest = &rest[comment_len..];
continue;
}
match c {
'\n' => {
directive = directive && continued;
line_start = !directive;
}
'#' if line_start => directive = true,
c if c.is_whitespace() => {}
_ => line_start = false,
}
if c != '\r' && !c.is_whitespace() || c == '\n' {
continued = c == '\\';
}
out.push(c);
rest = &rest[c.len_utf8()..];
}
(out, comments)
}
/// Whether trimmed `line` is an `#if` or `#elif` directive.
pub(crate) fn is_condition(line: &str) -> bool {
line.strip_prefix('#')
.map(str::trim_start)
.and_then(|d| d.strip_prefix("elif").or_else(|| d.strip_prefix("if")))
.is_some_and(|r| !r.starts_with(|c: char| c.is_ascii_alphanumeric() || c == '_'))
}
/// Whether `word` is a float literal.
pub(crate) fn is_float(word: &str) -> bool {
word.starts_with(|c: char| c.is_ascii_digit() || c == '.')
&& !word.starts_with("0x")
&& !word.starts_with("0X")
&& word.contains(['.', 'e', 'E'])
}
/// What JCPP's `#if` makes of a number (`Preprocessor.expr` takes
/// `NumericValue.longValue`): a float counts as its integer part, so
/// `0.75` is 0 and `1.5e1` is 10. `None` for a non-number.
pub(crate) fn jcpp_integer(number: &str) -> Option<i64> {
let number = number.trim_end_matches(['f', 'F', 'l', 'L', 'u', 'U']);
let (mantissa, exponent) = match number.find(['e', 'E']) {
Some(i) => (&number[..i], number[i + 1..].parse::<i32>().ok()?),
None => (number, 0),
};
let integer = mantissa.split('.').next()?;
let value = if integer.is_empty() {
0
} else {
integer.parse::<i64>().ok()?
};
Some((value as f64 * 10f64.powi(exponent)) as i64)
}
/// `line` with each word (identifier or number, with an exponent's sign:
/// `1e-3`) replaced by what `f` returns for it, if anything. The directive's
/// name and the names after `defined` are kept.
pub(crate) fn rewrite_condition(line: &str, f: impl Fn(&str) -> Option<String>) -> String {
let mut out = String::with_capacity(line.len());
let (mut rest, mut first, mut after_defined) = (line, true, false);
while let Some(c) = rest.chars().next() {
if !(c.is_ascii_alphanumeric() || c == '_' || c == '.') {
if !c.is_whitespace() && c != '(' && c != '#' {
after_defined = false;
}
out.push(c);
rest = &rest[c.len_utf8()..];
continue;
}
let bytes = rest.as_bytes();
let mut len = 0;
while let Some(&b) = bytes.get(len) {
let sign = (b == b'+' || b == b'-')
&& bytes[0].is_ascii_digit()
&& matches!(bytes[len - 1], b'e' | b'E');
if !(b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || sign) {
break;
}
len += 1;
}
let word = &rest[..len];
let kept = first || after_defined;
match (!kept).then(|| f(word)).flatten() {
Some(replacement) => out.push_str(&replacement),
None => out.push_str(word),
}
after_defined = word == "defined";
first = false;
rest = &rest[len..];
}
out
}
/// Make pp-rs read `text` as JCPP, Iris's preprocessor, does:
/// - a `#define` replaces an earlier macro (`Preprocessor.addMacro`; Bloop
/// and Steadfast redefine theirs), where pp-rs refuses a different
/// redefinition: an `#undef` line goes before every `#define`;
/// - in `#if`/`#elif`, a float counts as its integer part, and so does a
/// macro always defined as the same float (an option: `#define LEVEL 0.5`);
/// a macro always defined empty (Iris's `MC_GL_VENDOR_OTHER`, Bloop's
/// `#define TAA_USE_YCOCG`) counts as 0: JCPP reports the missing operand
/// and goes on with 0. pp-rs stops on both;
/// - `#error "..."` (Photon's guard in programs it disables) lexes: pp-rs
/// has no string tokens and fails on the quote, even in a skipped block.
///
/// Returns the text and the added lines' 1-based numbers in it.
fn jcpp_compatible(text: &str, env: &Environment) -> (String, Vec<u32>) {
// A `#define` line's name and body.
let define = |l: &str| {
let rest = l
.trim_start()
.strip_prefix('#')?
.trim_start()
.strip_prefix("define")
.filter(|rest| rest.starts_with([' ', '\t']))?
.trim_start();
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
let (name, body) = rest.split_at(end);
let body = body.split("//").next().unwrap_or("").trim();
(!name.is_empty()).then(|| (name.to_owned(), body.to_owned()))
};
// Each macro's body, if every definition has the same one.
let mut bodies: HashMap<String, Option<String>> = HashMap::new();
let definitions = env
.defines
.iter()
.cloned()
.chain(text.lines().filter_map(define));
for (name, body) in definitions {
bodies
.entry(name)
.and_modify(|b| {
if b.as_ref() != Some(&body) {
*b = None;
}
})
.or_insert(Some(body));
}
let number = |word: &str| -> Option<String> {
let word = match bodies.get(word) {
Some(Some(body)) if body.is_empty() => return Some("0".into()),
Some(Some(body)) => body.as_str(),
Some(None) => return None,
None => word,
};
is_float(word)
.then(|| jcpp_integer(word))
.flatten()
.map(|n| n.to_string())
};
let mut out = String::with_capacity(text.len() + text.len() / 16);
let mut added = Vec::new();
let (mut line, mut continued) = (1, false);
for l in text.split_inclusive('\n') {
if !continued && let Some((name, _)) = define(l) {
out.push_str(&format!("#undef {name}\n"));
added.push(line);
line += 1;
}
let directive = l.trim_start().strip_prefix('#').map(str::trim_start);
if !continued
&& directive.is_some_and(|d| d.starts_with("error") || d.starts_with("warning"))
{
out.push_str(&l.replace(['"', '\''], ""));
} else if !continued && is_condition(l.trim_start()) {
out.push_str(&rewrite_condition(l, number));
} else {
out.push_str(l);
}
continued = l.trim_end().ends_with('\\');
line += 1;
}
(out, added)
}
/// Expand `source` with `env`'s macros. Errors name the file and line.
pub fn preprocess(source: &Inlined, env: &Environment) -> Result<Preprocessed, String> {
let (marked, comments) = mark_comments(&source.text);
let (text, added) = jcpp_compatible(&marked, env);
let text = &text;
let line_starts: Vec<usize> = std::iter::once(0)
.chain(text.match_indices('\n').map(|(i, _)| i + 1))
.collect();
// Lines of the source: the added `#undef` lines don't count.
let line_of = |offset: u32| {
let line = line_starts.partition_point(|&s| s <= offset as usize) as u32;
line - added.partition_point(|&a| a < line) as u32
};
let mut pp = Preprocessor::new(text);
for (name, value) in &env.defines {
pp.add_define(name, value)
.map_err(|e| format!("macro {name}: {:?}", e.0))?;
}
let mut out = Preprocessed {
text: String::new(),
version: Vec::new(),
extensions: Vec::new(),
comments: Vec::new(),
};
let mut line = 1;
for item in pp {
let token = match item {
// JCPP lets an `#if` run to the end of the input (Bloop's
// taa.fsh never closes its `#ifdef TAA`); pp-rs says so there,
// after the last token.
Err((PreprocessorError::UnfinishedBlock, _)) => break,
item => {
item.map_err(|(e, at)| format!("{}: {e:?}", source.locate(line_of(at.start))))?
}
};
let at = line_of(token.location.start);
let piece = match token.value {
TokenValue::Version(v) => {
out.version = v.tokens.into_iter().map(|t| spelling(t.value)).collect();
continue;
}
TokenValue::Extension(e) => {
out.extensions
.push(e.tokens.into_iter().map(|t| spelling(t.value)).collect());
continue;
}
TokenValue::Pragma(_) => continue,
TokenValue::Ident(ref name) if name.starts_with(COMMENT_MARK) => {
if let Some(comment) = name[COMMENT_MARK.len()..]
.parse::<usize>()
.ok()
.and_then(|i| comments.get(i))
{
out.comments.push((at, comment.clone()));
}
continue;
}
value => spelling(value),
};
if at > line {
out.text
.extend(std::iter::repeat_n('\n', (at - line) as usize));
line = at;
} else if !out.text.is_empty() {
out.text.push(' ');
}
out.text.push_str(&piece);
}
out.text.push('\n');
Ok(out)
}
/// A token as GLSL source.
fn spelling(value: TokenValue) -> String {
match value {
TokenValue::Ident(s) => s,
TokenValue::Integer(i) => {
let suffix = if i.signed { "" } else { "u" };
// Bit patterns such as 0xFFFFFFFF stay hexadecimal: as decimals
// they would overflow a signed int.
if i.value > i32::MAX as u64 {
format!("{:#X}{suffix}", i.value)
} else {
format!("{}{suffix}", i.value)
}
}
TokenValue::Float(f) => {
let mut s = format!("{:?}", f.value);
if !s.contains(['.', 'e', 'i', 'N']) {
s.push_str(".0");
}
s
}
TokenValue::Punct(p) => punct(p).to_owned(),
TokenValue::Version(_) | TokenValue::Extension(_) | TokenValue::Pragma(_) => String::new(),
}
}
fn punct(p: Punct) -> &'static str {
use Punct::*;
match p {
AddAssign => "+=",
SubAssign => "-=",
MulAssign => "*=",
DivAssign => "/=",
ModAssign => "%=",
LeftShiftAssign => "<<=",
RightShiftAssign => ">>=",
AndAssign => "&=",
XorAssign => "^=",
OrAssign => "|=",
Increment => "++",
Decrement => "--",
LogicalAnd => "&&",
LogicalOr => "||",
LogicalXor => "^^",
LessEqual => "<=",
GreaterEqual => ">=",
EqualEqual => "==",
NotEqual => "!=",
LeftShift => "<<",
RightShift => ">>",
LeftBrace => "{",
RightBrace => "}",
LeftParen => "(",
RightParen => ")",
LeftBracket => "[",
RightBracket => "]",
LeftAngle => "<",
RightAngle => ">",
Semicolon => ";",
Comma => ",",
Colon => ":",
Dot => ".",
Equal => "=",
Bang => "!",
Minus => "-",
Tilde => "~",
Plus => "+",
Star => "*",
Slash => "/",
Percent => "%",
Pipe => "|",
Caret => "^",
Ampersand => "&",
Question => "?",
}
}
File diff suppressed because it is too large. Load diff
-155
View File
@@ -1,155 +0,0 @@
//! A Java shader pack's files: a folder or a zip holding a `shaders/` folder,
//! found the way Iris finds it (`Iris.loadExternalZipShaderpack`).
use std::{
collections::BTreeMap,
fs,
io::Read,
path::{Path, PathBuf},
};
/// Program source extensions Iris loads (vertex, fragment, geometry,
/// compute, tessellation control and evaluation).
pub const PROGRAM_EXTENSIONS: &[&str] = &["vsh", "fsh", "gsh", "csh", "tcs", "tes"];
/// The files under a pack's `shaders/` folder, addressed by `/`-separated
/// paths relative to it (`lib/common.glsl`).
pub enum PackFiles {
Folder(PathBuf),
/// A zip is small (a few MB of text), so it is read once.
Zip(BTreeMap<String, Vec<u8>>),
}
impl PackFiles {
/// Open `path`, a pack folder (with `shaders/` in it) or a `.zip`.
pub fn open(path: &Path) -> Result<Self, String> {
if path.is_dir() {
let shaders = path.join("shaders");
return if shaders.is_dir() {
Ok(Self::Folder(shaders))
} else {
Err("no shaders folder".into())
};
}
let file = fs::File::open(path).map_err(|e| e.to_string())?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("not a zip: {e}"))?;
let names: Vec<String> = zip.file_names().map(str::to_owned).collect();
// `shaders/` at the root, else the first folder ending in `shaders`
// (`Pack-main/shaders/`), as Iris walks the zip.
let mut prefixes: Vec<&str> = names
.iter()
.filter_map(|n| {
let at = n.find("shaders/")?;
(at == 0 || n.as_bytes()[at - 1] == b'/').then(|| &n[..at + "shaders/".len()])
})
.collect();
prefixes.sort_by_key(|p| (p.len(), *p));
let prefix = prefixes.first().ok_or("no shaders folder")?.to_string();
let mut files = BTreeMap::new();
for name in &names {
let Some(rel) = name.strip_prefix(&prefix) else {
continue;
};
if rel.is_empty() || rel.ends_with('/') {
continue;
}
let mut entry = zip.by_name(name).map_err(|e| e.to_string())?;
let mut bytes = Vec::new();
entry
.read_to_end(&mut bytes)
.map_err(|e| format!("{name}: {e}"))?;
files.insert(rel.to_owned(), bytes);
}
Ok(Self::Zip(files))
}
/// Raw bytes of a file relative to `shaders/` (leading `/` or `./` stripped),
/// or `None` when it doesn't exist.
pub fn read_bytes(&self, rel: &str) -> Option<Vec<u8>> {
let clean = rel
.trim()
.trim_start_matches("./")
.trim_start_matches('/')
.trim_start_matches("shaders/");
if clean.is_empty() || clean.contains("..") {
return None;
}
match self {
Self::Folder(root) => fs::read(root.join(clean)).ok(),
Self::Zip(files) => files.get(clean).cloned(),
}
}
/// A text file, or `None` when it doesn't exist. Invalid UTF-8 is
/// replaced, as packs are often saved in Latin-1.
pub fn read(&self, rel: &str) -> Option<String> {
let bytes = self.read_bytes(rel)?;
Some(String::from_utf8_lossy(&bytes).into_owned())
}
/// The files directly in folder `dir` (`""` for `shaders/` itself), sorted.
pub fn list(&self, dir: &str) -> Vec<String> {
let prefix = if dir.is_empty() {
String::new()
} else {
format!("{dir}/")
};
let mut out: Vec<String> = match self {
Self::Folder(root) => fs::read_dir(root.join(dir))
.into_iter()
.flatten()
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_file()))
.filter_map(|e| Some(format!("{prefix}{}", e.file_name().to_str()?)))
.collect(),
Self::Zip(files) => files
.keys()
.filter(|k| {
k.strip_prefix(&prefix)
.is_some_and(|rest| !rest.contains('/'))
})
.cloned()
.collect(),
};
out.sort();
out
}
/// The program folders: `shaders/` itself and the per-dimension folders,
/// from `dimension.properties` (`dimension.world-1=minecraft:the_nether`)
/// or, without one, Iris's defaults `world0`, `world-1` and `world1`.
pub fn program_dirs(&self) -> Vec<String> {
let mut dirs = vec![String::new()];
match self.read("dimension.properties") {
Some(text) => {
for line in text.lines() {
if let Some((key, _)) = line.trim().split_once('=') {
if let Some(dir) = key.trim().strip_prefix("dimension.") {
dirs.push(dir.to_owned());
}
}
}
}
None => dirs.extend(["world0", "world-1", "world1"].map(String::from)),
}
dirs.dedup();
dirs
}
/// Every program source file of the pack (`composite1.fsh`,
/// `world-1/gbuffers_water.vsh`), sorted.
pub fn programs(&self) -> Vec<String> {
let mut out: Vec<String> = self
.program_dirs()
.iter()
.flat_map(|d| self.list(d))
.filter(|f| {
f.rsplit_once('.')
.is_some_and(|(_, ext)| PROGRAM_EXTENSIONS.contains(&ext))
})
.collect();
out.sort();
out.dedup();
out
}
}
File diff suppressed because it is too large. Load diff
Loaded 100 of 424 files, more files were not shown because too many files have changed in this diff. Show more