Compare commits

...
Author SHA1 Message Date
Ludovic Marie 036e6bd8d6 Merge pull request #1444 from ludovic111/fix/parity-shard-world-spawn
🤖 Agent: merged after verify and CI parity passed. world_spawn runs as 4 shards, so the job's tail drops from 1,436 s to about 465 s.
2026-10-23 17:40:37 +02:00
Ludovic Marie 80d6252734 Merge pull request #1443 from ludovic111/fix/parity-prebuild-selection
🤖 Agent: merged after verify passed. The parity probe prebuild repeats each harness's own -p and features selection, so harness builds after the prebuild compile nothing (0 of 10, was 10 of 10). The full harness set ran on it locally in #1453's before/after diff.
2026-10-23 17:39:09 +02:00
Ludovic Marie f7144ccd19 Merge pull request #1435 from ludovic111/fix/ci-verify-no-examples
🤖 Agent: merged after verify passed. CI verify no longer builds the ~275 examples: the job goes from 7.8 to 5.6 min (review-1-ci).
2026-10-23 17:37:42 +02:00
Ludovic Marie a74d6e49e6 Merge pull request #1458 from ludovic111/fix/high-res-pack-gui
🤖 Agent: merged after verify passed, with local parity proof (103/103 on this head). GUI sprites are measured in GUI units whatever the pack's resolution, as in vanilla: inventories, tooltips and toasts look right with Faithful 32x and 64x.
2026-10-23 17:31:27 +02:00
Ludovic Marie ea426bd53d Merge pull request #1457 from ludovic111/fix/resource-pack-crash
🤖 Agent: merged after verify passed, with local parity proof: parity --changed passed 103/103 on #1458's head, which contains this PR. Faithful 64x reproduced a startup panic on main (a 32768 px shared atlas over the 16384 GPU limit); with the fix it reaches the world. A pack that can't fit now falls back to the default resources, as vanilla does.
2026-10-23 17:29:56 +02:00
Ludovic Marie ce3284a516 Merge pull request #1429 from ludovic111/feat/launcher-single-channel
🤖 Agent: merged after verify and CI parity passed; release dry run 39935111815 was green (the smoke test drew a world, no blocker, it would publish as the release and latest). One channel, as Ludovic decided on 2026-10-23.
2026-10-23 17:11:04 +02:00
Ludovic Marie 9137279e10 Merge pull request #1418 from ludovic111/fix/st2-saved-state
🤖 Agent: merged after verify and CI parity passed; lan_e2e (hands, styles, feedback, chat, frames, riding, mounts, beds, contacts, worldspawn) passes on the server thread and in-frame (bugs-st-2).
2026-10-23 17:09:33 +02:00
Ludovic Marie 55034fcce1 Merge pull request #1446 from ludovic111/fix/macos-guest-cdylib-link
🤖 Agent: merged after verify passed. No runtime change for players: #1445 deletes 10 tools/ scripts that nothing references (no harness depends on them); #1446 exports the mod-SDK symbols only on wasm32, so the guest crates link on macOS, and the mod sandbox tests pass.
2026-10-23 16:37:13 +02:00
Ludovic Marie 9a8003df0b Merge pull request #1445 from ludovic111/chore/delete-dead-tools
🤖 Agent: merged after verify passed. No runtime change for players: #1445 deletes 10 tools/ scripts that nothing references (no harness depends on them); #1446 exports the mod-SDK symbols only on wasm32, so the guest crates link on macOS, and the mod sandbox tests pass.
2026-10-23 16:35:43 +02:00
Ludovic Marie d759a89cf0 Merge pull request #1451 from ludovic111/docs/agent-map
🤖 Agent: merged after verify passed; docs only (AGENTS.md, CLAUDE.md symlink, docs/architecture.md). Every path and harness name was checked against the repo.
2026-10-23 16:31:47 +02:00
Ludovic MarieandClaude Opus 5.5 1960dc6eb2 AGENTS.md: agents never add release-now
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:30:31 +02:00
Ludovic MarieandClaude Opus 5.5 7c42cc63ce parity: leave out of the prebuild probes that no longer exist
A record naming an example since renamed or deleted made the whole Cargo
run fail ("no example target named ..."), so no probe was prebuilt and
every harness built its own: the probe binaries rename ~250 of them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:30:27 +02:00
Ludovic MarieandClaude Opus 5.5 79ee670d79 High-resolution resource packs draw the GUI at its size
With Faithful 32x the inventory showed the top-left quarter of its
background stretched over the screen, tooltips lost their background and
toasts doubled in size (#1442): the GUI code drew every atlas sprite in
texels, where vanilla draws in GUI units whatever the pack's resolution.

- Atlas sprites carry their GUI size: the size of the default resources'
  texture of that name (what vanilla's `blit(..., 256, 256)` calls and
  sprite sizes assume), their texels only for what copies pixels (font
  sheets, fire frames). Textures only a pack has keep their own size.
  The texture size filter reads the GUI size too, so Faithful 64x's
  1024x1024 container backgrounds are no longer dropped.
- Nine-slice sprites follow `GuiGraphicsExtractor.blitNineSlicedSprite`:
  their `gui.scaling` width, height and borders (Faithful's tooltip
  background is 50x50 with a 10-pixel border), `stretch_inner`, the
  single-quad and three-part cases, and borders capped at half the
  drawn size by integer division.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:28:50 +02:00
Ludovic MarieandClaude Opus 5.5 22ad65611e High-resolution resource packs no longer crash the game at launch
The shared GUI atlas was always 4096 texels wide, so Faithful 64x needed
4096x32768: past the 16384 texels a side GPUs allow, and wgpu panicked
in create_texture at every launch until the pack was removed (#1442).

- The atlas widens (4096, 8192, 16384) until it fits within 16384 a
  side, so Faithful 64x loads. Its height rounds up to a multiple of
  1024 instead of a power of two: the default resources' atlas drops
  from 4096x8192 to 4096x5120, Faithful 32x's from 4096x16384 to
  4096x12288.
- Textures that still don't fit are an error, not a GPU panic. At
  startup it rolls the packs back as Minecraft.rollbackResourcePacks
  does: the game starts with its own resources, and the selection is
  cleared and saved (clearResourcePacksOnError), so the next launch
  doesn't fail again. The existing "Resource reload failed" screen
  shows the reason.
- The renderer refuses an atlas larger than the device allows with an
  error instead of a wgpu validation panic.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:28:50 +02:00
Ludovic Marie 172ae241f1 Merge pull request #1430 from ludovic111/feat/release-single-channel
🤖 Agent: merged after verify and CI parity passed; release dry run 39935111815 was green (the smoke test drew a world, no blocker, it would publish as the release and latest). One channel, as Ludovic decided on 2026-10-23.
2026-10-23 16:24:35 +02:00
Ludovic MarieandClaude Opus 5.5 2eb699228a AGENTS.md and docs/architecture.md: a map of the code for agents
AGENTS.md (CLAUDE.md links to it): the crates and what each owns, how to
build and test fast, where each subsystem of horizon-client lives, how Game
is organised, the two single-player paths and how to test both, the vanilla
reference, and the rules that bite. docs/architecture.md: feature -> files ->
checks for the 40 areas changed most in the last 30 days.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 16:18:44 +02:00
Ludovic Marie 1487a1bff7 Merge pull request #1434 from ludovic111/perf/metal-cull-flat
🤖 Agent: merged after verify and CI parity passed. Metal GPU occlusion culling: on the M4 Pro at RD 32, FPS is +8% to +48% (interleaved A/B against main); the hidden sections match Vulkan's; parity --changed passed 104/104.
2026-10-23 16:03:14 +02:00
Ludovic MarieandClaude Opus 5.5 eeb4cdb680 Mod SDK: export_mod! exports only in WebAssembly, so the guests link on macOS
A host build of a mod (cargo build or test on the workspace) made a cdylib
whose component exports macOS's ld couldn't take: its exported-symbols list
cuts `horizon:mods/[email protected]#init` at the `#`, so coords-hud and
horizon-mods-test-guest failed to link. The exports now exist only for
wasm32; on the host the macro still checks the mod and keeps it used.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 15:06:50 +02:00
Ludovic Marie 2439475716 Merge pull request #1414 from kerneldroid/feat/shader-api2-pipeline
🤖 Agent: merged after the contribution gate. The claims were checked against Iris and OptiFine, and the weak parts were rewritten on the branch: the stage kinds, the Java shadow feed and the fake uniforms were removed; settings now apply on Done; packs are imported by drag and drop; memory is only allocated while a pack reads it; the MakeUp target is fixed. Tested on Vulkan and Metal with 6 packs, profiles, imports and broken packs; local parity passed 96/96. Frame times before and after are in the PR comment. Thanks @kerneldroid.
2026-10-23 15:05:34 +02:00
Ludovic Marie 1959c397a0 Merge pull request #1433 from ludovic111/chore/unify-naga-features
🤖 Agent: merged after verify and CI parity passed (review-0-survey quick win; see horizon-parity reports/review-plan.md).
2026-10-23 14:44:50 +02:00
Ludovic MarieandClaude Opus 5.5 7cc47e2b55 Delete ten tools/ scripts nothing references
command_parse_summary, structure_start_oracle, fetch_client_assets,
compare_structure_starts, profile_behaviour_cache, command_tree_diff,
compare_loot_fill, compare_structure_blocks, test_lan_lifecycle and
PROFILE_SHARED_JSON.md: no workflow, doc, harness, test or other tool
names them (review-plan.md, Dead code). History keeps them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:41:40 +02:00
Ludovic MarieandClaude Opus 5.5 c9c53629d4 parity: run world_spawn in four shards
world_spawn starts a vanilla and a Horizon server for each of 60 seeds,
one at a time: 1,144-1,548 s on the CI runner, the parity job's longest
harness and its floor whenever a PR's changes reach it. With
PARITY_SHARDS = 4 each shard creates every fourth seed's worlds, in its
own build directory.

The shard helper datafix had moves to tools/parity_shard.py, which both
use. It stays out of lab.py, which every harness imports.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:36:58 +02:00
Ludovic Marie 6fe88978a7 Merge origin/main into feat/shader-api2-pipeline 2026-10-23 14:17:13 +02:00
Ludovic MarieandClaude Opus 5.5 d9591047fa Shader pack docs: MakeUp, Chocapic13' and Photon on the Java path, and where the world goes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:17:13 +02:00
Ludovic MarieandClaude Opus 5.5 fe3a3af568 Shader Packs page: the pack's settings button sits beside the folder button
As in Iris's ShaderPackScreen: at the end of the list it scrolled out
of sight with a few packs. It's greyed out while the picked pack has no
options.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:16:51 +02:00
Ludovic MarieandClaude Opus 5.5 ac54227465 parity: prebuild probes with each harness's own package selection
The probe prebuild ran `cargo build --workspace --examples --bins` (or
named targets with --workspace). The harnesses build with `-p <crate>`, and
Cargo unifies features over the selected packages: under --workspace,
flate2, libc, memchr, syn, thiserror, postcard and digest get other
features for horizon-world, and horizon-mods (pack) and horizon-telemetry
(upload) for horizon-client. So every probe the prebuild made was a
variant no harness used, and each harness rebuilt its crate and its probe
again while the others waited on Cargo's lock.

Record each probe with its command's package and feature flags and
whether that command builds examples (which turns dev-dependencies on),
and prebuild one Cargo run per such selection. --bins and --examples are
recorded too, so the fast-profile horizon-client build that
save_roundtrip and world_reopen run is prebuilt as well. A harness that
never ran here takes its probes from the shared dependency lists instead
of a build of all ~275 examples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:11:22 +02:00
Ludovic MarieandClaude Opus 5.5 4a61134edd CI verify: test without building the examples
cargo test --workspace built and linked all ~275 examples, 59% of the
test build (review-plan section 1), though none of their tests run.
Test the libraries, binaries and integration tests, then the doc tests,
as tools/gate.py and tools/pr_ready.py already do. Clippy --all-targets
still type-checks every example, and the parity job builds and runs them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:06:25 +02:00
Ludovic MarieandClaude Opus 5.5 39ae543796 Shader packs: test importing zips and folders, and refusing what isn't a pack
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:02:12 +02:00
Ludovic MarieandClaude Opus 5.5 963d5cedd3 Shader packs: keep the opaque depth and G-buffer only for packs that read them
The world target made the opaque depth, normal and material textures
for every scaled or anti-aliased frame, packs or not: 12 bytes a pixel
nobody read. They now follow what the pack reads, and Post::generation
tells the stages to bind again when its targets change (a recreated
target of the same size, or a shadow map made after the stages bound).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:02:12 +02:00
Ludovic MarieandClaude Opus 5.5 fe7bb3a34b Java packs: the world goes where the pack's terrain program draws
MakeUp's gbuffers write colortex1, and its composites read the world
from there: with the world in colortex0, it showed nothing but fog.
Iris's fallbacks for terrain pick the program (gbuffers_terrain, then
textured_lit, textured, basic); its first draw buffer takes the world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:02:12 +02:00
Ludovic MarieandClaude Opus 5.5 eafe97da74 Shader packs: keep what Iris does, drop what doesn't work, apply settings on Done
Review fixes for #1414 (contribution gate):

- Java packs no longer get Horizon's shadow map: it was mirrored in y and
  reversed in depth against the GL conventions the packs sample with, had
  no distortion and only the camera's sections, so BSL drew no shadows
  while the pass cost 0.65 ms (MakeUp, M4 Pro). shadowtex* stays a far
  depth, as on main; the packs' own shadow programs are #1326.
- Invented uniforms removed (centerDepthSmooth fixed at 0.5,
  ambientOcclusionLevel and shadowMapResolution as uniforms, constant
  temp/rainfall/biome/held items); an unknown uniform stays zero as
  before. wetness keeps its Iris smoothing. Unused directives removed.
- texture.<stage>.<sampler> is scoped to Iris's stages (final reads the
  composite ones) instead of overriding the sampler in every pass;
  texture.noise replaces noisetex. One noise generator and one RGBA
  upload for both pack kinds.
- The G-buffer pass, the opaque-depth copy and the shadow pass run only
  for a pack that reads them (naga's per-entry-point binding use, not a
  text search): every pack paid all three before.
- Api 2's prelude additions go in their own file after api 1's, so api 1
  packs keep the names they declare (aces_tonemap, material_at...).
- Stage kinds shadow, gbuffer, deferred and final ran as composite: only
  composite is accepted; api 2 adds formats (validated), profiles,
  option descriptions and a noise texture.
- Shader Pack Settings applies once when the page closes, as Iris's Done
  (each click or slider step rebuilt the pack: 2.7 s per click, 12.7 s
  for one drag with BSL). Labels follow Iris (.lang, else the name).
  A pack whose options can't be read isn't read again every frame.
- Saved option values an option doesn't list are ignored, as OptiFine's
  ShaderOption.setValue (a stale file broke BSL's compile).
- Import: the blocking file dialog (osascript/zenity/kdialog/PowerShell
  on the event loop) is gone; a pack dropped on the Shader Packs page is
  copied and picked, as Iris's onFilesDrop. Drops elsewhere do nothing,
  an existing pack isn't overwritten, links aren't followed.
- Docs and the example say what the code does (SSAO, not GTAO).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 14:02:12 +02:00
Ludovic Marie d95932e62c Merge origin/main into feat/shader-api2-pipeline 2026-10-23 14:02:12 +02:00
Ludovic MarieandClaude Opus 5.5 660407fa58 Metal: cull only frames with 4,096 section draws or more
On Apple's GPUs the passes the test adds cost a fixed share of the frame
(it is stored and reloaded at each), which the hidden sections repay only
at high render distances: at 12 culling cost an M4 Pro 0.05 ms of GPU time
a frame, at 32 it saved 0.25 to 1.6 ms. Other backends keep culling every
frame; HORIZON_GPU_CULL=all culls every frame on Metal too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 13:22:10 +02:00
Ludovic Marie 6be7daebc8 Merge pull request #1417 from ludovic111/fix/st2-rules
🤖 Agent: merged after verify and CI parity passed; lan_e2e (hands, styles, feedback, chat, frames, riding, mounts, beds, contacts, worldspawn) passes on the server thread and in-frame with all four merged on main (bugs-st-2).
2026-10-23 13:20:42 +02:00
Ludovic Marie 2639e9e1d7 Merge pull request #1422 from ludovic111/fix/st-leads
🤖 Agent: merged after verify and CI parity passed; each fix has a lan_e2e check (host and guest) passing on the server thread and in-frame (bugs-st-1).
2026-10-23 13:20:40 +02:00
Ludovic MarieandClaude Opus 5.5 1d81c67b7b Build naga once for the game, its tests and its examples
horizon-client's dev-dependency on naga added the hlsl-out feature for the
gfx.rs test that translates every shader for DirectX 12. With resolver 2 a
dev-dependency's features apply only to tests and examples, so Cargo kept
two builds of naga, wgpu-hal, wgpu-core, wgpu, horizon-shaderpack and the
horizon-client lib, and rebuilt them on the first example or test after a
game build.

Measured on the Mac mini (cargo build of the game, then one example):
the example took 37.3 s and recompiled that whole stack before, 1.2 s
and only its own crate after; the target dir is 1 GB smaller (4.9 to
3.9 GB). The game binary is the same size: nothing calls the HLSL writer,
so the linker drops it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:13:58 +02:00
Ludovic Marie 9c09d2897d Merge pull request #1428 from ludovic111/fix/alpha-regress-1
🤖 Agent: merged after verify and CI parity passed. In-frame single player is the default again until the server thread reaches parity (FEATURES 4zg); release builds were compared with v0.2.0 and v0.2.11 on the Mac mini. The server thread stays available with --server-thread or HORIZON_SERVER_THREAD=1.
2026-10-23 12:11:36 +02:00
Ludovic MarieandClaude Opus 5.5 14be6ca394 Metal: occlusion culling with flat draws too
Flat draws (one index list per frame, the default on Apple GPUs at high
render distances) turned occlusion culling off. Now phase 2's draws are
flattened after the test, by a compute pass that copies only the sections
the test saw, and each page run is drawn indirectly with the count it
reached.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:03:39 +02:00
Ludovic MarieandClaude Opus 5.5 9fcf91e4bc Metal: occlusion culling of solid sections, the depth test in the shader
GPU occlusion culling ran on Vulkan only: its test needs early fragment
tests, which naga's Metal backend does not emit. On Metal the boxes are
tested in a pass of their own: phase 1's depth is bound read-only as the
attachment and as a texture, and the fragment shader compares its own
depth with it (reversed Z, greater or equal), as the fixed-function test
would. HORIZON_GPU_CULL=shader takes this path on Vulkan too, for checks.

Frames drawn flat (the default on Apple GPUs at high render distances)
still skip culling here.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 12:03:38 +02:00
Ludovic MarieandClaude Opus 5.5 630f521072 Launcher: one update channel, and the menu entry is never taken over
Every release is now published as the release (one channel, Ludovic's
decision of 2026-10-23), so the launcher drops its Stable/Alpha choice:
the Updates row in Settings, `horizon-launcher update-channel` and its MCP
tool, the channel on the Versions page, in `versions` and in the footer.
It updates to the newest game release that is not a pre-release (GitHub's
latest) and looks every 30 minutes. The pre-releases of the old Alpha
channel stay listed and installable by hand, never as an update. Old
`update_channel` / `beta_builds` keys in settings.json are ignored.

On Linux a release build that starts no longer rewrites the
applications-menu entry to point at itself. It writes the entry only when
there is none, or when the program it starts (TryExec, else Exec) no longer
exists; turning the Settings switch on or `desktop-entry` still points it
at this launcher, and HORIZON_LAUNCHER_DESKTOP_EXEC is still honored. A
test build started with the real HOME took over a working entry and then
left it broken when its target/ was deleted (2026-10-23).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 11:53:00 +02:00
Ludovic MarieandClaude Opus 5.5 0e65e85878 Releases: one channel, every release is the latest once its checks pass
Ludovic's decision (2026-10-23): no more Alpha and Stable. release.yml
publishes every game release as the release and GitHub's latest instead of
a pre-release that promote.yml made stable once a day. The checks that
gated a promotion run in the release itself: `decide` holds the train while
an issue labelled release-blocker is open (forced runs too), and the publish
job runs `release_train.py gate` right before publishing: the Mac mini's
smoke-test.json must be there and show a world was drawn, and no blocker may
be open. Otherwise nothing is published.

Notes cover everything since the last release that is not a pre-release, so
the first single-channel release lists all of it since v0.2.0. promote.yml,
tools/release_channels.py and its tests are deleted. The cadence is
unchanged (5 minutes of settling, 20 at most, release-now at once).

Docs and site: one channel in release-flow, versioning, security, the FAQ,
install, the home page and the Releases page (Game / Launcher filter); no
player-facing Alpha wording left.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 11:53:00 +02:00
Ludovic Marie 62a6b3a620 Merge pull request #1421 from ludovic111/fix/st-entity-data
🤖 Agent: merged after verify and CI parity passed; each fix has a lan_e2e check (host and guest) passing on the server thread and in-frame (bugs-st-1).
2026-10-23 11:12:06 +02:00
Ludovic MarieandClaude Opus 5.5 6049012b52 Single player runs in the frame by default again; the server thread is opt-in
Players on the Alpha channel report single player as broken since v0.2.1
(#1265 made the integrated server thread the default again). On v0.2.11's
server thread, placed signs open no editor (#1380), held books don't open
(#1366), a totem doesn't save its holder (#1292) and death keeps the
inventory with keep_inventory off (#1284); the in-frame world and Stable
v0.2.0 do all four right. About 45 more [server-thread] issues are open.

`--server-thread` or HORIZON_SERVER_THREAD=1 turns it on, as before #1114;
`--no-server-thread` and HORIZON_SERVER_THREAD=0 still work. LAN guests and
dedicated servers are untouched. Temporary until the server thread reaches
parity (FEATURES 4zg).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:54:42 +02:00
Ludovic MarieandClaude Opus 5.5 0d8249e124 Leads: a server's clients draw the rope (ClientboundSetEntityLinkPacket)
A server never told its clients who holds a mob's lead, so on single
player's server thread, a LAN guest's or a dedicated server's client the
lead was used up and the mob tied, but no rope showed.

The tracker now sends ClientboundSetEntityLinkPacket as vanilla does: in
ServerEntity.sendPairingData for a leashed entity, and when the lead is tied
or comes off (Leashable.setLeashedTo / dropLeash, the holder 0). The holder
is the player the mob sees (a guest, else the host) or a mob; a fence knot
isn't an entity on Horizon's server yet. The client sets the mob's holder
from it: itself, another player or a mob.

lan_e2e's new `leads` check ties and unties a cow on both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic MarieandClaude Opus 5.5 c35ea9568f Server's mobs: babies, wool, collars, fire, slime sizes and health reach its clients
A client of a server (single player on its server thread, a LAN guest, a
dedicated server's player) only applied a rabbit's baby flag and a creeper's
swell from SetEntityData. It now reads the synched data vanilla's entities
read in onSyncedDataUpdated: Entity's on-fire flag, LivingEntity's health
(the wither's boss bar follows it), the baby flag of every ageable mob,
Sheep.DATA_WOOL_ID, TamableAnimal's sitting and tame flags, the wolf's and
the cat's collar colour, and the cube mobs' size. A remote wolf or cat starts
with vanilla's red collar.

The server sends the wolf's and cat's collar colour. Pairing still carries
only the values that differ from the defaults; a mob's state now lists the
defaults of the entries that can go back to theirs (health, the baby flag,
the wool, the collar), and the tracker sends that default when one does:
a calf growing up or a sheep's white wool growing back reach the clients.

Sheep saves wrote the colour and shearing they were loaded with (0 unless
the save had them) and loads ignored both: they now save and load the wool
as it is (Sheep.add/readAdditionalSaveData).

lan_e2e's new `looks` check summons the mobs and changes them on both sides.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 10:02:19 +02:00
Ludovic Marie e325fa6587 Merge pull request #1397 from ludovic111/fix/metal-gpu-timer
🤖 Agent: merged after verify and CI parity passed; the change only touches the GPU timer (gfx/timing.rs), proven on the M4 at RD 32.
2026-10-23 09:57:36 +02:00
Ludovic Marie 2423e9da00 Merge remote-tracking branch 'origin/main' into fix/st2-saved-state
# Conflicts:
#	crates/horizon-client/src/game/lan_actions.rs
2026-10-23 09:44:30 +02:00
Ludovic Marie cb81f645da Merge remote-tracking branch 'origin/main' into fix/st2-rules
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-23 09:44:12 +02:00
Ludovic MarieandClaude Opus 5.5 7e5e469783 InLove only for vanilla's Animal subclasses (not allays)
save_roundtrip found an allay saved with InLove 0: Horizon's `animal`
flag is broader than vanilla's Animal class.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 09:19:48 +02:00
Ludovic Marie 4680a73f51 Merge pull request #1413 from ludovic111/feat/bench-1080p
🤖 Agent: merged after verify passed; #1190 has interleaved GPU A/B against main on the HD 620 and M4 (within noise) and 103 parity harnesses passing; #1413 was proven with a 1080p run on the desktop.
2026-10-23 09:06:57 +02:00
Ludovic Marie e0cb831dbf Merge pull request #1190 from ludovic111/fix/texture-mcmeta-blur-clamp
🤖 Agent: merged after verify passed; #1190 has interleaved GPU A/B against main on the HD 620 and M4 (within noise) and 103 parity harnesses passing; #1413 was proven with a 1080p run on the desktop.
2026-10-23 09:05:27 +02:00
Ludovic Marie 6b35c35ebf Merge pull request #1416 from ludovic111/fix/bed-respawn
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 09:00:44 +02:00
Ludovic Marie 48863fcf84 Merge pull request #1412 from ludovic111/fix/mob-knockback-view
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:59:16 +02:00
Ludovic Marie 982f7f12be Merge pull request #1411 from ludovic111/fix/items-out-of-blocks
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:58:50 +02:00
Ludovic Marie 12f2ed9ff7 Merge pull request #1410 from ludovic111/fix/setworldspawn
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:57:20 +02:00
Ludovic Marie 10db017c7c Merge pull request #1409 from ludovic111/fix/server-player-shield-mining
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:54:51 +02:00
Ludovic Marie e6acebc23d Merge pull request #1408 from ludovic111/fix/contact-damage
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:53:24 +02:00
Ludovic Marie 69df5671e7 Merge pull request #1407 from ludovic111/fix/server-air-absorption
🤖 Agent: merged after verify passed; lan_e2e (absorption breath lava contacts shield deathmining worldspawn drops underwater beds minecarts levels) passes in both single-player modes on the top of the chain, plus clippy and the client tests.
2026-10-23 08:48:53 +02:00
Ludovic MarieandClaude Opus 5.5 06b4c7faf5 Rules: crits, mending, phantoms, per-level health rules, /item with air, frozen potions
- Critical hits also need the attacker out of water, not blind and not
  riding (Player.canCriticalAttack) (#1393).
- Mending repairs the off hand too: candidates in EquipmentSlot order,
  main hand, off hand, then the armour (#1349).
- Phantoms spawn only in the Overworld with spawn_mobs, spawn_monsters and
  spawn_phantoms on, near every player (the local one and each guest in
  the level, with their own time since rest), with vanilla's draw order
  (#1388, #1387).
- A level entered by a portal or a dimension change keeps the world's
  natural_health_regeneration (#1339).
- /item replace ... with air empties the slot, and counts it as changed
  as vanilla's SlotAccess.set does (#1332).
- Thrown potions stay where they are on frozen ticks (/tick freeze) and
  land once the game runs again (#1364).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:48:41 +02:00
Ludovic MarieandClaude Opus 5.5 d522d8fe50 Saved state: burning, air and falling survive a reopen; InLove for every animal
- Guests' player data saves Fire, Air and fall_distance and puts them back
  on their body when they come back (single player's players are guests
  of the integrated server), so a burning player still burns after a
  reopen (#1323); the old single player's level.dat keeps fall_distance
  too (#1327).
- Every Animal saves and loads InLove, so a fed animal stays in love
  across a reopen and is not fed again (#1396).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:39:29 +02:00
Ludovic Marie 55e7084890 Merge branch 'fix/mob-knockback-view' into fix/bed-respawn 2026-10-23 08:36:16 +02:00
Ludovic Marie cffa98d509 Merge branch 'fix/items-out-of-blocks' into fix/mob-knockback-view 2026-10-23 08:36:16 +02:00
Ludovic Marie 9df27670f2 Merge branch 'fix/setworldspawn' into fix/items-out-of-blocks 2026-10-23 08:36:16 +02:00
Ludovic Marie 83c6d78013 Merge branch 'fix/server-player-shield-mining' into fix/setworldspawn 2026-10-23 08:36:16 +02:00
Ludovic Marie 83db7e3c41 Merge branch 'fix/contact-damage' into fix/server-player-shield-mining 2026-10-23 08:36:16 +02:00
Ludovic MarieandClaude Opus 5.5 39438efd76 lan_e2e contacts: sneak before landing on magma, read health before letting go
The sneaking case read the health after releasing Shift and waiting 10
ticks on the magma, so a hit from standing up counted (and one could land
between the first reading and the key press).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:36:16 +02:00
Ludovic MarieandClaude Opus 5.5 0e36cb4d7a Beds: respawning checks the bed and stands up beside it, else the world spawn
A bed sets the respawn point as vanilla's ServerPlayer.startSleepInBed does
(once in reach, day or night): a RespawnConfig at the bed's head with the
player's yaw, not forced; straw beds never set it (straw_bed_rule).
Respawning resolves it through findRespawnAndUseSpawnBlock, which now
handles beds: AbstractBedBlock.findStandUpPosition (beside the bed on the
side away from the yaw it was set from, around it, then on it; bunk beds
too) through DismountHelper, facing the bed. A bed that is gone or boxed
in sends the player to the world spawn with "You have no home bed or
charged respawn anchor, or it was obstructed", and the point is forgotten:
in the window's world, on the server thread and for LAN guests. Leaving
the End goes to the bed too (not using it up).

Saves keep a bed as the player's `respawn` (resolved through its block
when loaded), and a `spawnpoint` in the Overworld as forced. A fallback
that is the world spawn follows `setworldspawn` with a bed or anchor set.

tools/test_use_item_on_parity.py: respawn cases at a bed's head set from
some yaw (intact, bunk, only the foot left or gone, crowded), against
vanilla's real findRespawnAndUseSpawnBlock; 4000/4000 and 12000/12000 on
two seeds. lan_e2e `beds`: intact, gone and boxed in, host and guest.

Fixes #1308

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 08:11:22 +02:00
kerneldroid 4dff61b697 feat(shaders): complete wgpu Iris/Horizon API-2 shader pipeline, shadow & G-buffer passes, GLSL translator fixes, and settings/import UI 2026-10-23 11:07:03 +05:00
Ludovic Marie 13f98233ea Merge remote-tracking branch 'origin/main' into fix/texture-mcmeta-blur-clamp
# Conflicts:
#	crates/horizon-client/src/hud.rs
2026-10-23 07:59:50 +02:00
Ludovic MarieandClaude Opus 5.5 5c9f11b954 Benchmark: a 1080p set next to 720p
perf_bench ran every game in a 1280x720 window only. It now runs the
standard set at 1280x720 and 1920x1080 (--sizes picks others), each size
its own reports: <label>-rd<N>.json at 720p as before, <label>-1080p-rd<N>.json
at 1080p.

Horizon takes vanilla's --width and --height for its window's starting size
(1280x720 by default); perf_bench passes them to every game, and to Horizon
only off 720p, so builds before this one still run the 720p set.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 07:58:53 +02:00
Ludovic Marie c8f00dcf1e Merge branch 'fix/items-out-of-blocks' into fix/mob-knockback-view 2026-10-23 07:52:34 +02:00
Ludovic Marie 3d36883f52 Merge branch 'fix/setworldspawn' into fix/items-out-of-blocks
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 07:52:33 +02:00
Ludovic Marie e84f86a813 Merge branch 'fix/server-player-shield-mining' into fix/setworldspawn
# Conflicts:
#	crates/horizon-client/src/lan/playerdata.rs
#	tools/lan_e2e.py
2026-10-23 07:51:25 +02:00
Ludovic Marie 48c1b04cb0 Merge branch 'fix/contact-damage' into fix/server-player-shield-mining
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 07:50:58 +02:00
Ludovic Marie 10f6b183e3 Merge branch 'fix/server-air-absorption' into fix/contact-damage
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 07:50:48 +02:00
Ludovic Marie f949f08488 Merge remote-tracking branch 'origin/main' into fix/server-air-absorption
# Conflicts:
#	tools/lan_e2e.py
2026-10-23 07:45:28 +02:00
Ludovic MarieandClaude Opus 5.5 144741b7c7 Screen overlays: one texture sample, as on main
The vignette, drawn over every frame, went from one texture sample to up to
four texel loads and per-pixel address arithmetic: +0.85 ms of GPU a frame on
an HD 620 (integer modulo, removed in the commit before), still +0.4 ms after
it.

- A blurred sprite gets a one-texel border in the atlas, copied from its
  opposite edges: the hardware's bilinear sample of the atlas with the
  entity group's linear sampler is then GL_LINEAR with repeat, edges
  included. Clamp-to-edge keeps the sample point half a texel inside the
  sprite, which is what the hardware's clamp does for both filters.
- Each sampling has its own pipeline, the shader specialised by override
  constants: a repeat sprite runs main's arithmetic and one sample, nearest
  or linear; only clamp adds a clamp. The sampling moves from the vertices
  to the quad, so the vertex layout is main's again.
- A black vignette changes no pixel (it darkens by texture x colour), and is
  no longer drawn: in daylight its brightness fades to 0 within half a
  minute, so most frames outdoors skip it, which main never could.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 07:35:26 +02:00
Ludovic Marie 5bb492ca54 Merge pull request #1318 from levzzz5154/fix/tnt-minecart-explosions
🤖 Agent: merged after verify passed and a proof run on the Mac mini: the self-check passed (parity 22/22), and six TNT minecarts now chain (6 → 0; 5 survive on main). Thanks @levzzz5154!
2026-10-23 06:56:31 +02:00
Ludovic Marie 15616aa115 Merge pull request #1317 from levzzz5154/fix/minecart-collisions
🤖 Agent: merged after verify passed and a proof run on the Mac mini: the self-check passed (parity 22/22), and lan_e2e minecarts passes in both single-player modes. Thanks @levzzz5154!
2026-10-23 06:56:07 +02:00
Ludovic Marie 910f59bf0e Merge pull request #1213 from ludovic111/fix/server-respawn-levels
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:27:48 +02:00
Ludovic Marie 247a33d889 Merge pull request #1212 from ludovic111/fix/server-end-gateways
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:26:20 +02:00
Ludovic Marie a1597c041f Merge pull request #1211 from ludovic111/fix/server-command-levels
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:24:51 +02:00
Ludovic Marie 1a4e783a04 Merge pull request #1210 from ludovic111/feat/lan-levels-check
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:23:22 +02:00
Ludovic Marie 91466d160c Merge pull request #1216 from ludovic111/feat/truetype-fonts
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:21:55 +02:00
Ludovic Marie ce04baf1ba Merge pull request #1215 from ludovic111/feat/win-screen
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 06:20:28 +02:00
Ludovic Marie b75370cf26 Merge remote-tracking branch 'origin/main' into m1215
# Conflicts:
#	crates/horizon-client/src/game/startup_menu.rs
#	crates/horizon-client/src/options_ui.rs
2026-10-23 06:11:37 +02:00
Ludovic MarieandClaude Opus 5.5 feeefb3a33 Mobs: a punch shows smooth and slow, as on vanilla's client
The server sends a knocked-back, pushed or jumping mob's move at once
(Entity.needsSync), and the remote client walks every living entity along
the server's positions with vanilla's SteppedInterpolationHandler: each
position takes the type's update interval to reach, sped up while steps
pile up. Before, the client waited for the next due update and then
lerped there in fewer ticks, so a hit pig jumped away fast.

tools/test_stepped_interpolation_parity.py drives vanilla's unmodified
InterpolationData and the Rust port with the same packets and ticks
(random cases and a punched pig's knockback); 135236 positions and
rotations match bit for bit.

Fixes #1374

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:46 +02:00
Ludovic MarieandClaude Opus 5.5 65bd2d0d45 Items push out of blocks; a server's client digs at the server's speed
ItemEntity.tick: an item stuck inside a full block (one set on it) has no
physics on the server and moves toward the closest free side
(Entity.moveTowardsClosestSpace: north, south, west, east, up; 0.1 to 0.3 a
tick), instead of staying in the block (#1252).

A server's client (LAN guests, and the window on the server thread) digs at
the same speed as the server: getDestroyProgress with the submerged mining
speed, haste, fatigue and efficiency, not the plain tool speed, so it no
longer finishes early under water and gets the block put back (#1254).

lan_e2e: `drops` (a diamond under a block set on it comes out, in both
views) and `underwater` (dirt by hand at eye height under water: about
halfway at 35 ticks on client and server, broken by 105).

Fixes #1252, #1254.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:25 +02:00
Ludovic MarieandClaude Opus 5.5 5c4600e403 /setworldspawn moves the world spawn, not the player's respawn point
SetWorldSpawnCommand sets the level's spawn and its angle; a player without
a bed or anchor respawns there (it used to set the player's own spawn
point, so other players and later deaths kept the old one). The angle is
saved in level.dat's spawn (yaw, pitch), and a guest whose saved respawn
point is the world spawn follows it when it moves.

lan_e2e `worldspawn`: /setworldspawn 24 blocks east, then the host and the
guest die and respawn there.

Fixes #1310.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:25 +02:00
Ludovic MarieandClaude Opus 5.5 2898b40670 Server players: an axe-disabled shield stays down 5 s, death forgets the digging
ItemCooldowns ticks once a tick for each player: a server's players count
their shield cooldown down in tick_guests, and the host's world in tick_world
(it was in tick_using, which ran for the host and again for every guest's
swap, so a guest's shield came back early on the server thread).

A guest that dies stops digging (its mining, the delay and the crack go), so
the block it was breaking doesn't break once it respawns.

lan_e2e: `shield` (a vindicator's axe disables each player's shield; the
server's cooldown then falls 40 in 40 ticks) and `deathmining` (die while
breaking obsidian, respawn, the obsidian stays). `lan status` and the agent
state report the shield cooldown.

Fixes #1315, #1316.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:24 +02:00
Ludovic MarieandClaude Opus 5.5 ed28e1dd0d Contact damage: magma, cactus, lit campfires and sweet berry bushes hurt in both modes
Entity.applyEffectsFromBlocks: MagmaBlock.stepOn burns a player standing on
it who is not sneaking (hot_floor, 1), CactusBlock and CampfireBlock.entityInside
hurt what touches them (cactus 1, campfire 1, soul campfire 2, lit only).
A server's players (LAN guests, and the host on the server thread) now get
these and the grown sweet berry bush prick along the moves their client sent
that tick: the server rebuilds the tick's movement history and runs the
damage half of the block effects.

lan_e2e `contacts`: magma, sneaking on magma, a lit campfire, a cactus, grown
and young berry bushes, for the host and the guest.

Fixes #1305, #1311, #1312, #1313.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:24 +02:00
Ludovic MarieandClaude Opus 5.5 7acebbae45 Server players: the HUD's bubbles and golden hearts follow the server, absorption is saved, Nether lava pushes harder
The server sends a player's own air supply and absorption amount
(Entity.DATA_AIR_SUPPLY_ID, Player.DATA_PLAYER_ABSORPTION_ID) when they
change, and the client applies them: on the server thread and as a LAN
guest the breath meter follows the server's Respiration-extended air
(#1304) and the golden hearts show (#1306). The connected client's tick
sets the helmet's Respiration and the Nether's FAST_LAVA as the window's
own tick does (#1309).

AbsorptionAmount is written from the live body and read back on load,
in the window's world and for a server's players (#1307).

lan_e2e: absorption, breath and lava checks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:38:24 +02:00
Ludovic MarieandClaude Opus 5.5 0c133101b6 Screen overlays: wrap texels with a select, not integer modulo
The camera overlays (the vignette every frame) read up to four texels a
pixel, each wrapped with two integer modulos. Intel GPUs emulate integer
division. Reducing the coordinate to one period first (fract, as the shader
did before) leaves every read at most one texel outside the sprite, so a
select wraps it. Same texels and weights; clamp-to-edge is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:21:47 +02:00
Ludovic Marie 9c6b35d4cb Merge remote-tracking branch 'origin/main' into fix/texture-mcmeta-blur-clamp 2026-10-23 05:21:47 +02:00
Ludovic Marie 357dceb999 Merge pull request #1197 from ludovic111/feat/locator-bar
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:21:26 +02:00
Ludovic Marie aec75bbd28 Merge pull request #1194 from ludovic111/feat/armour-trims-worn
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:19:59 +02:00
Ludovic Marie 8b6d49c4b4 Merge pull request #1357 from ludovic111/docs/agent-control
🤖 Agent: merged after verify passed and the agent-control-2 integration of all six PRs on the Mac mini. agent_control_e2e passes in CLI and MCP (one known survival flake), launcher_mcp_e2e passes, No AI is refused, and the options, screen_layout, menu_click and world_options parity harnesses pass. Ludovic asked for this on 2026-10-23.
2026-10-23 05:17:45 +02:00
Ludovic Marie 03c4d2c683 Merge pull request #1353 from ludovic111/feat/agent-connect-game
🤖 Agent: merged after verify passed and the agent-control-2 integration of all six PRs on the Mac mini. agent_control_e2e passes in CLI and MCP (one known survival flake), launcher_mcp_e2e passes, No AI is refused, and the options, screen_layout, menu_click and world_options parity harnesses pass. Ludovic asked for this on 2026-10-23.
2026-10-23 05:16:19 +02:00
Ludovic Marie 891b122783 Merge pull request #1256 from ludovic111/perf/random-tick-counts
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:15:28 +02:00
Ludovic Marie 06b754db4a Merge pull request #1253 from ludovic111/perf/spawn-checks
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:14:01 +02:00
Ludovic Marie 1c18ac6087 Merge pull request #1239 from ludovic111/perf/animated-texture-uploads
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:13:35 +02:00
Ludovic MarieandClaude Opus 5.5 d6f8dda823 Metal: the GPU frame timer measures the frame again (read about 0.01 ms)
On Apple GPUs timestamps go only at pass boundaries, and the frame was
bracketed by two one-thread compute passes. Two things made it read
about 0.01 ms:

- Metal resolves counter samples without waiting for the passes that
  write them. Resolved in the frame's own command buffer, the end
  timestamp was mostly the one an earlier frame left in the shared
  query. Each readback slot now has its own queries, and with markers a
  slot is resolved in a later frame, once on_submitted_work_done says the
  GPU finished the frame it timed.
- A compute pass depends on nothing, so the end marker ran alongside the
  frame's render passes. The end is now a render pass that loads the
  frame's target, shading one pixel with every write masked: it runs
  after every pass that drew into the target. The start stays a compute
  pass (cheaper than a load and store of the target).

M4 Pro, render distance 32, offscreen: GPU p50 0.002 ms before, now
1.95 ms against a 1.95 ms frame (village) and 4.46 ms against 4.51 ms
(mountain), where the frame is GPU-bound. Vulkan and DirectX 12, which
write timestamps between commands, are unchanged but for the per-slot
queries.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 05:13:25 +02:00
Ludovic Marie 0e81f178ec Merge pull request #1258 from ludovic111/perf/frustum-inside-columns
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:12:08 +02:00
Ludovic Marie d4c37adb0b Merge pull request #1199 from ludovic111/feat/far-detail-reduced
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:10:41 +02:00
Ludovic Marie 8120559415 Merge pull request #1198 from ludovic111/perf/frame-encode
🤖 Agent: merged after verify passed and the wave-3 integration on the desktop by integ-w3: main + 17 PRs, fmt, clippy 0, 1929 workspace tests, parity --changed (only known flakes failed, and those passed on rerun), lan_e2e in both modes, and the perf numbers back to back against main posted on each PR.
2026-10-23 05:10:15 +02:00
Ludovic Marie 0623d41954 Merge remote-tracking branch 'origin/main' into m1353
# Conflicts:
#	tools/agent_control_e2e.py
2026-10-23 05:04:53 +02:00
Ludovic MarieandClaude Opus 5.5 7db6ce51a6 Docs: control Horizon with your AI agent (players), the full inventory (developers)
website/docs/agents.md, in the nav under "Going further", is the player's page.
It covers what an agent can do, connecting it from the launcher, copy-paste
config for Claude Code, Codex, Cursor and any MCP client, the game's AI Agent
page, a CLI quick reference, safety and No AI.

docs/agent-control.md is rewritten for developers:
- the surfaces, and why one MCP server covers the launcher and the game;
- the inventory, before and after, for the launcher and the game;
- No AI on every surface, and the launcher commands that stay;
- connecting from the launcher and the game;
- the design rules for a new command.

docs/launcher-commands.md lists every launcher command, its arguments and its
MCP tool. It is generated from the clap definition (cli_tools::markdown). The
launcher test the_launcher_reference_is_up_to_date fails when it is stale or a
tool is undocumented, like horizon-ctl's generated reference.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 03:10:58 +02:00
Ludovic MarieandClaude Opus 5.5 768f149190 Horizon Settings > AI Agent: turn agent control on, see who is connected, copy the details
Horizon Settings gains a row: "AI Agent..." and "No AI: Off". No AI hides the
AI Agent page and closes the control socket at once.

The AI Agent page has an Agent Control switch (horizon-extras.txt
agentControl, off by default): on, the game the player starts opens the
default control endpoint, so horizon-ctl and MCP clients reach it without
--control. Turning it off closes the endpoint at once; dropping a control
server now also stops its listener thread. The page shows whether an agent
is connected, when it last sent a command and the socket, and Copy
Connection Details copies the socket path, a horizon-ctl line to try and the
MCP config for this game.

agent_control_e2e.py --scenario setting covers it: Agent Control on opens the
endpoint without --control, the pages list their widgets, Copy works, and
turning the switch off removes the endpoint while the game keeps running.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 03:04:19 +02:00
Ludovic MarieandClaude Opus 5.5 7eeb7ebbc2 Merge feat/win-screen (Options test fix) into feat/truetype-fonts
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:16:55 +02:00
Ludovic MarieandClaude Opus 5.5 3f58e512e0 Options test: Credits & Attribution follows Horizon's two buttons
main (#1271) pins Horizon Settings and Mods as the grid's last buttons;
this branch adds Credits & Attribution after them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 02:16:54 +02:00
Ludovic MarieandClaude Opus 5.5 e6237aa1da Merge the updated stack base (with origin/main) into fix/server-respawn-levels
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:57:17 +02:00
Ludovic MarieandClaude Opus 5.5 f582eae9f4 Merge the updated stack base (with origin/main) into fix/server-end-gateways
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:57:17 +02:00
Ludovic MarieandClaude Opus 5.5 e4fb8f0f3f Merge the updated stack base (with origin/main) into fix/server-command-levels
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:57:17 +02:00
Ludovic MarieandClaude Opus 5.5 b6a018bf87 Merge origin/main into feat/lan-levels-check
tools/lan_e2e.py: main added the mounts check; levels stays last (it moves
the players between dimensions, and skips itself without --server-thread).
CHECKS and CHECK_FNS: 67 each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:57:10 +02:00
Ludovic MarieandClaude Opus 5.5 bdbe59155d Locator bar: drop the live-backdrop check main removed
#1271 removed the title's live world backdrop (backdrop_live), so the
locator bar hides on the title alone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:56:28 +02:00
Ludovic MarieandClaude Opus 5.5 0ff735b664 Merge the updated stack base (with origin/main) into fix/server-respawn-levels
Brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:59 +02:00
Ludovic MarieandClaude Opus 5.5 2b088da069 Merge the updated stack base (with origin/main) into fix/server-end-gateways
Brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:59 +02:00
Ludovic MarieandClaude Opus 5.5 5472d4c668 Merge the updated stack base (with origin/main) into fix/server-command-levels
Brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:58 +02:00
Ludovic MarieandClaude Opus 5.5 5e55ebd2a7 Merge origin/main into feat/lan-levels-check
tools/lan_e2e.py: main added the minecarts, frames, lightning and riding
checks; levels stays last (it moves the players between dimensions, and
skips itself without --server-thread). CHECKS and CHECK_FNS: 66 each.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:58 +02:00
Ludovic MarieandClaude Opus 5.5 181c91d1b8 Merge feat/win-screen (now with main) into feat/truetype-fonts
Brings #1215's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:30 +02:00
Ludovic MarieandClaude Opus 5.5 906d02fff3 Merge origin/main into feat/win-screen
- game.rs: main (#1271) dropped the Horizon-theme checks from menu_blur and
  the title scroll; kept without them, with this branch's WinScreen parts
  (the credits alone blur, the title doesn't scroll under the Win screen).
  Screen keeps main's Friends and this branch's Win.
- control.rs: horizon-ctl options pages keep credits, horizon and
  shader-packs.
- options_ui.rs: Page keeps Credits and main's Horizon. The Options grid is
  main's (Horizon Settings and Mods in vanilla's Telemetry and Credits slots;
  Horizon Extras now sits inside Horizon Settings) with Credits & Attribution
  after them, laid out by this branch's row count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:32:30 +02:00
Ludovic MarieandClaude Opus 5.5 9e11394600 Merge feat/armour-trims-worn (now with main) into feat/locator-bar
No conflicts: brings #1194's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:31:20 +02:00
Ludovic MarieandClaude Opus 5.5 901a5b8207 Merge origin/main into feat/armour-trims-worn
hud.rs: HudAtlas::slots keeps this branch's trim size parameter, with
main's doc line (the on-screen keyboard's text replaced the Horizon menu
text, #1271).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:31:19 +02:00
Ludovic MarieandClaude Opus 5.5 ed76af1c11 Merge origin/main into feat/far-detail-reduced
graphics.rs: main (#1271) dropped the level key from horizon-graphics.txt;
farDetail stays after lodDistance in main's format (21 keys, 21 values).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-23 01:31:05 +02:00
levzzz 18078e058d Explosions hurt and push minecarts, so TNT minecarts chain
ServerExplosion.hurtEntities skipped minecarts: a TNT minecart's blast
never reached the carts beside it. Carts now take the explosion's damage
through VehicleEntity.hurtServer (destroyed past 40, where
MinecartTNT.destroy primes a TNT cart with a 0-39 tick fuse, as
damageSourceIgnitesTnt counts explosions) and the knockback from their
eyes. The exploding cart is the explosion's source, so it is removed
before the blast and can't hit itself. Cart ticks now look carts up by id,
since a blast can remove or prime other carts mid-loop.
2026-10-23 03:47:04 +05:00
levzzz 18bf8cd2ef Minecarts push each other in their own ticks, as vanilla does
OldMinecartBehavior.tick ends with pushAndPickupEntities: after a cart
moves, every other cart in its box (0.2 wider) gets push(cart). Horizon
pushed all pairs once before any cart moved, so carts placed inside each
other stayed exactly overlapping (push skips distances under 1e-4) and
never split apart. Pushes now run per cart, after its move, with
pushOtherMinecart's Mth sin table facing and float thresholds.
2026-10-23 03:40:19 +05:00
Ludovic MarieandClaude Opus 5.5 725962b858 Merge feat/win-screen into feat/truetype-fonts
No conflicts: brings #1215's Screen::Win menu-blur arm.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 23:04:30 +02:00
Ludovic MarieandClaude Opus 5.5 9f54087a86 Win screen: cover main's menu-blur match (Screen::Win)
Main's Screen::blurs_background (in-world menu blur) has no arm for the
new Screen::Win. WinScreen.extractBackground fills the screen with the End
portal while the poem rolls and falls back to Screen.extractBackground
(blurred) for the credits alone, so Game::menu_blur blurs only for the
credits without the poem.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 23:04:29 +02:00
Ludovic MarieandClaude Opus 5.5 c8d8a35273 Merge feat/armour-trims-worn (#1194) into feat/locator-bar
Stacks #1197 on #1194 so it merges cleanly after it. game/packs.rs: both
PRs add one line to the resource reload; both are kept (waypoint styles,
then armour-stand trims).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:58:43 +02:00
Ludovic MarieandClaude Opus 5.5 65fbde0f15 Merge the updated stack base (with origin/main) into fix/server-respawn-levels
No conflicts: brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:58:17 +02:00
Ludovic MarieandClaude Opus 5.5 54e9379bc1 Merge the updated stack base (with origin/main) into fix/server-end-gateways
No conflicts: brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:58:16 +02:00
Ludovic MarieandClaude Opus 5.5 5a2d55c863 Merge the updated stack base (with origin/main) into fix/server-command-levels
No conflicts: brings the base branch's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:58:15 +02:00
Ludovic MarieandClaude Opus 5.5 b6b6eb9a87 Merge origin/main into feat/lan-levels-check
tools/lan_e2e.py: main added the ladder and boats checks where this branch
added levels. Both are kept, with levels still last: it moves the players
between dimensions (and skips itself without --server-thread).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:58:00 +02:00
Ludovic MarieandClaude Opus 5.5 6bd80fe817 Merge feat/win-screen (now with main) into feat/truetype-fonts
No conflicts: brings #1215's merge of origin/main into this stacked branch.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:57:07 +02:00
Ludovic MarieandClaude Opus 5.5 3ce3fe0374 Merge origin/main into feat/win-screen
hud.rs: HudBatch keeps both new fields, end_portal (this branch) and
blur_at (main's menu blur), in the struct, its constructor and the test
batch. draw_range already draws EndPortal runs on either side of blur_at.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 22:56:56 +02:00
Ludovic MarieandClaude Opus 5.5 8b16f76b3c Section walk: no per-section frustum test in columns entirely inside
A column whose box lies entirely inside the frustum holds no section the
frustum would reject (plane distances only grow from its nearest corner
to a section's farthest, and rounding keeps the order), so its sections
skip the test. At render distance 32 most columns are past the distance
where the vertical field of view takes in their whole height.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 19:29:54 +02:00
Ludovic MarieandClaude Opus 5.5 abb7a97136 Animated sprites on the GPU: frames uploaded once, one dispatch per mip level
Every animation frame's mip chain goes to video memory once, at the
sprite's own size, as vanilla keeps its animated sprites' frames on the
GPU. A tick that changes sprites writes one 20-byte job per changed
sprite, and a compute dispatch per mip level repeats the chain into the
array's larger layers and mixes interpolated frames
(animate_sprite_interpolate), instead of blending on the CPU at the
array's size and copying each sprite level by level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 17:56:43 +02:00
Ludovic MarieandClaude Opus 5.5 4ad6bb18ef Natural spawning: resolve the entry's type once, skip the mobsAt recheck where it was picked, measure only nearby players
spawn_category_for_position looked up the picked entry's type twice per
attempt and asked mobsAt again at the position it had just picked the entry
from. The type is now resolved when the entry is picked, and the recheck is
skipped at that position (the list there holds the entry). The nearest player
distance only feeds comparisons with 24 and the despawn distances (128 at
most), so an attempt near its start measures only the players that can be
within 128 blocks of it; farther ones read as infinitely far, which every check
answers the same. The water-ambient biome tag is read only for that category.
Same lists, same random draws.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 17:54:06 +02:00
Ludovic MarieandClaude Opus 5.5 2ced9a451d Server players respawn at their point in the Nether (anchors, spawnpoint)
A server's player (a LAN guest, and since the server thread the single
player too) always respawned in the Overworld. Its respawn anchor was
dropped when its action ended, and its save kept only an Overworld point.

- The guest keeps a respawn point outside the Overworld (respawn_elsewhere,
  saved as the player's `respawn`) and has it in place while it acts, so an
  anchor it uses is its own.
- guest_respawn respawns it in that point's level, waits there for the
  chunks, then resolves the point through its block
  (findRespawnAndUseSpawnBlock): beside the anchor, using a charge, or the
  world spawn and block.minecraft.spawn.not_valid when the block is gone.
- `spawnpoint` in another level than the Overworld sets a forced point there
  (SetSpawnCommand) instead of Overworld coordinates, and a forced point that
  is no anchor holds where a player fits (isPossibleToRespawnInThis), in the
  window's own game too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:32:24 +02:00
Ludovic MarieandClaude Opus 5.5 86fdc72069 Server players go through End gateways
handle_server_portal ignored End gateways, so on a server (dedicated, or
single player's server thread) no player could use one: the in-frame game's
gateway code moved only its own player.

A gateway that is not cooling down now takes a server player at once
(EndGatewayBlock.entityInside, triggerCooldown, setAsInsidePortal), within
the End, keeping its motion and look (Relative.DELTA and ROTATION). An exact
exit is reached straight away; otherwise the player waits at the destination
while it loads, and the exit is found or built there (the in-frame
resolve_gateway, shared through gateway_job).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:32:24 +02:00
Ludovic MarieandClaude Opus 5.5 9fe228cd1e Server commands run in their source's level (execute in, execute at)
On a server (dedicated, or single player's server thread) each dimension is
its own level, but a command ran in whichever level was in place. So a guest
in the Nether running `execute in minecraft:overworld run setblock ...` got
"That position is not loaded", and `execute at <a player in another level>`
used the wrong level's blocks.

with_source now puts the source's level in place for the command
(CommandSourceStack.getLevel), as as_target already does for a player in
another level, and puts the old one back after. `execute at` takes the
entity's level (withLevel(entity.level())), and a player's level is the
guest's own (target_level) even while another level is in place.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:32:24 +02:00
Ludovic MarieandClaude Opus 5.5 84171cfc94 lan_e2e portals: wait for the guest to be placed in its Nether portal
On a server the guest is held where it entered the Nether until the
chunks around the destination are lit, then placed in its portal. The
check read the guest's position 40 ticks after the dimension changed,
which on a busy machine was still the held spot, and then stepped the
guest out of a portal that wasn't there.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:32:24 +02:00
Ludovic MarieandClaude Opus 5.5 bd36508c29 lan_e2e levels: players in their own levels on the server thread; design note
A new `levels` check (--server-thread) runs the host and a guest in different
dimensions: execute in ... tp, mobs ticking in two levels at once with unique
ids, rejoin and save/reload with the guest in the Nether, death there, the
host through a Nether portal alone, the guest through an End portal and the
exit portal alone. All of it already works on the server thread.

docs/server-levels.md says how the server's levels map onto vanilla's, what
the dedicated server and the server thread share, the cost of an extra level
and what's left. docs/server.md no longer says players can't change dimension.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:32:24 +02:00
Ludovic MarieandClaude Opus 5.5 a069c2716f Random ticks: ask for the next ticking section, and read a section's blocks faster
tick_chunk_random asks for the lowest randomly ticking section from a height
up, instead of asking about every section: a chunk costs one question per
section that ticks, not 24. PalettedContainer::has tests whole words for the
few matching palette indices (a field-wise zero test) instead of reading
every entry, so a section read again after an edit costs a few hundred word
operations.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 15:24:07 +02:00
Ludovic MarieandClaude Opus 5.5 7e410d93b3 Fonts: TrueType providers and the font picked by text components
Every font definition of the resources (and packs) is now loaded, not
just font/default.json, and a text component's "font" field picks one:
"minecraft:alt" draws the enchanting table's Galactic alphabet,
"minecraft:illageralt" the illager runes, a pack's own font its glyphs,
and a font that doesn't exist draws every character as the missing
glyph (FontManager.getFontSetRaw).

- font.rs: one FontSet per font id with its providers in order (space,
  bitmap, unihex, ttf) and their filters ("uniform", "jp"). Bitmap
  sheets outside the atlas (another namespace or folder) are copied into
  the glyph region when first drawn.
- TrueTypeGlyphProvider: "ttf" providers (file under font/, size,
  oversample, shift, skip; CFF fonts refused) rasterized with ab_glyph
  into the glyph region as rrrr coverage (text_intensity), metrics as
  GlyphBitmap (bearings over the oversampling, whole-pixel advances).
- text::Style gains the font id; it is parsed from JSON and SNBT
  components, pack descriptions and scoreboard number formats, inherited
  by children and written back.
- Obfuscated text picks its random glyph in the style's font.

Fixes #336

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 14:27:47 +02:00
Ludovic MarieandClaude Opus 5.5 4e45a3bddf Credits and end poem (WinScreen)
Leaving the End through the exit portal for the first time now rolls the
end poem, the credits and the post-credits text (WinScreen), as does a
server's WIN_GAME event; Options -> Credits & Attribution -> Credits rolls
the credits alone. The texts come from the resource root
(texts/end.txt, credits.json, postcredits.txt), so packs can replace them.

- win_screen.rs: vanilla's line building (PLAYERNAME, the obfuscated
  names from LegacyRandom(8124371), wrapping at 256), scroll speeds and
  keys (Space, Ctrl, Up, Escape), the last line pinned mid-screen, the
  logo, the vignette and the backgrounds.
- HUD: Blend::Vignette (RenderPipelines.VIGNETTE) and Blend::EndPortal
  (the End portal effect as a GUI fill, the poem's background).
- Obfuscated text draws a random glyph of the same width each frame.
- seenCredits is read and saved; the player stays out of the world while
  the poem rolls and arrives in the Overworld after it.
- horizon-ctl `credits [poem] [SCROLL]` and `options credits`.

Fixes #338

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 14:25:47 +02:00
Ludovic MarieandClaude Opus 5.5 9d58d6f525 Random ticks: a section ticks while it holds a randomly ticking block, as in vanilla
Vanilla random-ticks a section while its count of randomly ticking blocks
and fluids is above zero (LevelChunkSection.isRandomlyTicking), and asks as
it reaches each section. Horizon ticked every section whose palette listed
such a state, even after the last one was gone, and decided before the tick's
scheduled ticks, which drew positions vanilla doesn't and shifted its
position sequence.

RandomTickSections now reads each section's blocks (PalettedContainer::has),
again only for the sections that changed, and tick_chunk_random asks for
each section when it reaches it; the game reads a chunk's sections again only
after a block changed. New harness random_tick_sections compares the
sections and positions with vanilla's real section counts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 13:31:27 +02:00
Ludovic MarieandClaude Opus 5.5 38276fbc30 Locator bar HUD and waypoint styles
LocatorBar: when the server tracks waypoints for the player, the bar
above the hotbar shows a dot per waypoint within 60 degrees of the
view (farthest first), coloured from the waypoint's colour or its id's
hash, with the sprite its style picks by distance
(assets/*/waypoint_style/*.json, WaypointStyleManager) and the blinking
up or down arrow when the waypoint is above or below the screen. The
background is nine-sliced. Hud.nextContextualInfoState picks between
the jump bar, the experience bar (100 ticks after an experience change)
and the locator bar.

Waypoints come from ClientboundTrackedWaypointPacket on remote servers
and, on a served world, from the host's own connection in
lan/waypoints.rs; entity waypoints in view follow the entity's eyes.

Fixes #339

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 12:42:40 +02:00
Ludovic MarieandClaude Opus 5.5 c77863f163 Armour trims on worn armour (player, LAN guests, armour stands)
EquipmentLayerRenderer's trim layer: the pattern's
textures/trims/entity/<layer>/<asset>.png recoloured from its base
palette (palette metadata section) to the material's palette, or to the
equipment asset's trim_overrides palette (iron armour with an iron trim
uses trim/iron_darker), as PalettedTextureManager does. Each texture is
made once, on first use, into one of 32 atlas trim slots (least recently
drawn reused), and drawn over the armour layer on the player, on the
guests a LAN host sees and on armour stands.

Fixes #337

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 12:13:24 +02:00
Ludovic MarieandClaude Opus 5.5 21baf0dc99 Screen overlays: blur and clamp from the texture's .png.mcmeta
TextureMetadataSection's blur and clamp choose how vanilla samples a
standalone texture (ReloadableTexture.setSampler). The atlas now keeps
each texture's flags and the screen-effect quads (vignette, pumpkin blur,
powder snow outline, underwater, equippable camera overlays) sample
their sprite rectangle linearly and with clamp or repeat as those flags
say, instead of always nearest and repeat.

Fixes #335

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 11:53:03 +02:00
Ludovic MarieandClaude Opus 5.5 d0330061bd Far Detail: an opt-in Reduced level for far terrain, suggested on integrated GPUs
Reduced starts each far tile level at half the distance Full does (about a
quarter of the far quads beyond the full-detail edge, whose finest tiles stay).
Full stays the default everywhere and no preset changes it. On integrated
GPUs other than Apple's, the setting's description suggests Reduced.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:45:33 +02:00
Ludovic MarieandClaude Opus 5.5 14bd239141 Solid section draws grouped by arena page within 64-block bands
A list sorted only by distance switched vertex buffer every few sections:
about 1200 indirect multi-draws a frame at render distance 32, most of a
few entries, which Mesa's Intel driver unrolls on the CPU. Grouping each
band's sections by page keeps the list front to back within the band, with
one call per page and band.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:37:40 +02:00
183 changed files with 16507 additions and 3865 deletions

No files matched your search

-1
View File
@@ -6,7 +6,6 @@
/.github/ @ludovic111
/tools/ci/ @ludovic111
/tools/release_train.py @ludovic111
/tools/release_channels.py @ludovic111
/crates/horizon-release-sign/ @ludovic111
/crates/horizon-server/src/update.rs @ludovic111
/crates/horizon-server/Cargo.toml @ludovic111
+5 -1
View File
@@ -64,9 +64,13 @@ jobs:
cargo +stable clippy --workspace --all-targets -- -D warnings
# The server build (released as horizon-engine): the game without sound or controllers.
cargo +stable clippy -p horizon-client --all-targets --no-default-features -- -D warnings
# Libraries, binaries and integration tests, then doc tests: not the ~275 examples, which clippy above already
# type-checks and the parity job builds and runs (they were most of this step's build and link time).
- name: Test Rust core and voxel kernel correctness
if: steps.rust.outputs.required != 'false'
run: cargo +stable test --workspace
run: |
cargo +stable test --workspace --lib --bins --tests
cargo +stable test --workspace --doc
parity:
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
uses: ./.github/workflows/parity.yml
-55
View File
@@ -1,55 +0,0 @@
name: promote
# Beta game releases become stable once a day (docs/release-flow.md, "Channels"): at 20:00 UTC (21:00 in Paris in
# winter time, 22:00 in summer) tools/release_channels.py promotes the newest beta that had been out 6 hours by then,
# passed its release smoke test and isn't covered by an issue labelled release-blocker. Launchers on the Alpha
# channel install every beta when it is published; Stable gets the day's promotion. The later runs of the evening are
# retries for a run GitHub starts late or skips: they promote the same beta, so stable still moves once.
# By hand, from GitHub's app too (Actions > promote > Run workflow):
# gh workflow run promote -f tag=v0.2.3 # that beta now, without waiting for 20:00
# gh workflow run promote -f tag=v0.2.3 -f force=true # even blocked or without its smoke test
# gh workflow run promote -f dry_run=true # say what would be promoted
# Holding betas back: label an issue release-blocker. While it is open it holds every beta; closed as completed it
# still holds the betas published before it closed. The issue's text is never read (its author could edit it).
# When the oldest waiting beta is 30 hours old (it missed a promotion it was due for), an issue "Promotion to stable
# has stalled" says so; it closes itself after the next promotion.
on:
schedule:
- cron: '7,37 20-21 * * *'
workflow_dispatch:
inputs:
tag:
description: The beta to promote now (empty = the newest beta that is due)
type: string
default: ''
force:
description: Promote that tag even if a release-blocker covers it or its smoke test didn't run
type: boolean
default: false
dry_run:
description: Say what would be promoted, change nothing
type: boolean
default: false
permissions:
contents: write
issues: write
concurrency:
group: promote
cancel-in-progress: false
jobs:
promote:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
# The notes of a promoted release cover every pull request since the last stable one.
fetch-depth: 0
# gh uses GH_TOKEN below; nothing needs git credentials on disk.
persist-credentials: false
- name: Promote
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.tag }}
FORCE: ${{ inputs.force && 'yes' || '' }}
DRY_RUN: ${{ (inputs.dry_run || github.ref != 'refs/heads/main') && 'yes' || '' }}
run: python3 tools/release_channels.py promote ${TAG:+--tag "$TAG"} ${FORCE:+--force} ${DRY_RUN:+--dry-run}
+25 -10
View File
@@ -3,12 +3,15 @@ name: release
# 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.
# RELEASES_PAUSED=1 (repository variable) stops them.
# 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
# publishes vX.Y.Z on the release line in .github/release-line.txt (docs/versioning.md; without it, the dated
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as a beta (a pre-release) that promote.yml makes
# stable in its daily promotion. macOS: with the repository variable MACOS_SIGN_IDENTITY set, the programs are signed with
# vYYYY.MM.DD[.N] from before 0.2) with notes from the merged pull requests, as the release and GitHub's latest: one
# channel, which launchers, `horizon-server update` and install.sh follow at once. So it is published only after the
# checks that used to gate a promotion pass in this run: the Mac mini's smoke test drew a world, and no release-blocker
# is open (tools/release_train.py gate); otherwise nothing is published. macOS: with the repository variable
# MACOS_SIGN_IDENTITY set, the programs are signed with
# Developer ID and the hardened runtime and the zip is notarized, from the runner's signing keychain
# (packaging/macos/README.md); unset (forks), ad hoc.
# gh workflow run release --ref <branch> # dry run: builds and notes, publishes nothing
@@ -32,6 +35,8 @@ on:
default: false
permissions:
contents: read
# The open release-blocker issues (decide).
issues: read
concurrency:
group: game-release
cancel-in-progress: false
@@ -203,7 +208,7 @@ jobs:
or not report.get("meshed_sections") or not report.get("player"):
sys.exit("the smoke run did not draw a world")
EOF
# Published with the release: a beta becomes stable only with it (tools/release_channels.py).
# Published with the release; the publish job checks it again before it publishes anything.
cp "$home/smoke.json" "$RUNNER_TEMP/smoke-test.json"
- uses: actions/upload-artifact@v4
if: runner.environment == 'self-hosted'
@@ -319,6 +324,7 @@ jobs:
timeout-minutes: 15
permissions:
contents: write
issues: read
steps:
- uses: actions/checkout@v4
with:
@@ -341,6 +347,17 @@ jobs:
run: |
key=(); [[ -n "$EPHEMERAL" ]] && key=(--key "$(cat dist/ephemeral.pub)")
cargo run --locked --quiet -p horizon-release-sign -- verify --tag "$TAG" --publishing "${key[@]}" dist
- name: Ready to publish (the smoke test passed, no release-blocker is open)
# Right before publishing: an issue labelled while the builds ran still holds this release. A dry run only
# reports what a real run would do.
env:
GH_TOKEN: ${{ github.token }}
DRY_RUN: ${{ needs.decide.outputs.dry_run }}
run: |
if ! python3 tools/release_train.py gate --kind game --smoke dist/smoke-test.json --blockers; then
[[ "$DRY_RUN" == true ]] || { echo "::error::Nothing published"; exit 1; }
echo "::warning::A real run would publish nothing"
fi
- name: Publish ${{ needs.decide.outputs.tag }}
if: needs.decide.outputs.dry_run != 'true'
env:
@@ -348,17 +365,15 @@ jobs:
TAG: ${{ needs.decide.outputs.tag }}
run: |
signature=(); [[ -f dist/SHA256SUMS.sig ]] && signature=(dist/SHA256SUMS.sig)
smoke=(); [[ -f dist/smoke-test.json ]] && smoke=(dist/smoke-test.json)
# A beta (pre-release), the Alpha channel's: promote.yml makes it stable and latest in its daily promotion
# (docs/release-flow.md).
# The release and the latest at once: there is one channel (docs/release-flow.md).
gh release create "$TAG" --target "${{ needs.decide.outputs.head }}" --title "$(cat notes/title.txt)" \
--notes-file notes/body.md --prerelease --latest=false dist/horizon-linux-x86_64.tar.gz \
--notes-file notes/body.md --latest dist/horizon-linux-x86_64.tar.gz \
dist/horizon-windows-x64.zip dist/horizon-macos-arm64.zip dist/horizon-server-linux-x86_64.tar.gz \
dist/SHA256SUMS "${signature[@]}" "${smoke[@]}"
dist/SHA256SUMS "${signature[@]}" dist/smoke-test.json
- name: Dry run, the release that would be published
if: needs.decide.outputs.dry_run == 'true'
run: |
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }}: $(cat notes/title.txt)"
echo "Would publish ${{ needs.decide.outputs.tag }} at ${{ needs.decide.outputs.head }} as the release and latest: $(cat notes/title.txt)"
ls -l dist
- uses: actions/upload-artifact@v4
with:
+175
View File
@@ -0,0 +1,175 @@
# Horizon: a map for agents
Minecraft: Java Edition 26.3 rewritten in Rust (wgpu). Vanilla is the specification wherever it has the
feature. Read [CONTRIBUTING.md](CONTRIBUTING.md) once; this page tells you where things are.
Feature → files → harness for the most-changed areas: [docs/architecture.md](docs/architecture.md).
## Rules that bite
- **Never commit Mojang's files**: game jars, assets, decompiled source, prepared resources (`.local/`).
`tools/check_public_tree.py` rejects them; `git config core.hooksPath .githooks` runs it on commit.
- **Keep your runs away from real player data.** Any game or launcher you start gets fresh `mktemp -d`
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)`.
- 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)).
- Contributors first: `gh pr list --state open --search <area>` before you start; never duplicate theirs.
## Crates
Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-server` are separate workspaces.
| Crate | Owns |
|---|---|
| `horizon-client` | The game: a lib (`src/lib.rs`, ~190 flat modules) and the bin (`src/main.rs` → `game.rs` + `game/`), plus `horizon-ctl` (`src/bin/horizon-ctl/`). Also the dedicated server (`--dedicated`) |
| `horizon-world` | Vanilla world data with no gameplay loop: NBT, regions, chunks, `datafix/`, `worldgen/`, `level/` (server-side block behaviour, ticks, redstone), entity physics, items, effects, food, damage |
| `horizon-core` | Simulation primitives: Java `Random`s, `jmath` (HotSpot-exact `Math`), positions, noise, density, paletted storage, `version.rs` (the pinned version) |
| `horizon-protocol` | Network protocol 777: codecs, framing, encryption, `packets/{handshake,status,login,configuration,play}.rs`, `entity_data.rs` |
| `horizon-assets` | Resource decoding: block-state and item models, baking, atlases |
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
| `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 |
| `horizon-telemetry`, `horizon-telemetry-server`, `horizon-bench` | Opt-in data collection, its ingest server, the benchmark report format |
| `horizon-dualsense`, `horizon-threads`, `horizon-release-sign`, `horizon-bots`, `voxel-kernels`, `xtask` | DualSense output, thread priorities, signed release checksums, load-test bots, occupancy kernels, repo tasks |
| `launcher/` | GPUI launcher: `main.rs` (UI), `backend.rs` (installs, updates), `cli.rs`, `mcp.rs`, `*_page.rs` |
Other top-level folders: `tools/` (harnesses and dev scripts), `tests/` (Python unit tests: `python3 -m
unittest discover -s tests`), `mods/horizon/src/test/java/dev/horizonlab/parity/` (Java oracles),
`website/` (playhorizon.dev; its paths are relative to `website/`), `docs/`, `.github/workflows/`.
## Build and test fast
```sh
python3 tools/pr_ready.py # the self-check: fmt, clippy -D warnings, touched crates' tests, harnesses; paste its block in the PR
python3 tools/pr_ready.py --quick # same without harnesses
cargo test -p horizon-client --lib --bins --tests # skips the 104 examples (pr_ready does this); `--doc` separately
cargo build --profile fast -p horizon-client # optimized, quick to link: use it to play or run lan_e2e
python3 tools/parity.py --changed origin/main # the harnesses your branch can affect (CI's parity job)
python3 tools/parity.py --changed-files paths.txt --list # which harnesses cover these files, without running them
python3 tools/parity.py mob_ai sculk # named harnesses
python3 tools/lan_e2e.py --check chest # two real games over LAN (host in-frame); --server-thread for the host on its server thread
python3 tools/agent_control_e2e.py # horizon-ctl / MCP end to end
```
- Harnesses and the game need the vanilla data in `.local/` (`minecraft/`, `horizon-client/26.3`, `datagen/`,
`tools/`): see [docs/parity-workflow.md](docs/parity-workflow.md). Without it, pr_ready lists the
harnesses for CI.
- A harness = `tools/test_<name>_parity.py` (324 of them) + a Java oracle (`mods/horizon/src/test/java/dev/horizonlab/parity/Vanilla*Oracle.java`)
+ a Rust probe: `crates/<crate>/examples/<name>_{probe,fixture}.rs` (horizon-world 175, horizon-client 104),
or the game binary with a fixture env var (`game/dispense_fixture.rs`, `game/sculk_fixture.rs`,
`game/command_harness.rs`, `game/net_harness.rs`). Copy a nearby harness for a new one.
- Probes build into `.local/parity-target/`; harnesses find binaries via `lab.cargo_target()`, never `target/`.
- What a change reruns (of 329 harnesses, measured 2026-10-23): a file of the game binary (`game.rs`, `game/`)
~25, a file of the client lib ~103, of `horizon-world` ~103–109, of `horizon-core` ~119. Plan for it.
- Rust integration tests: `crates/horizon-client/tests/` (`lan_bot.rs`, `generation_saves.rs`, `streaming_meshes.rs`).
- **macOS:** `cargo build/test --workspace` may fail to link the two wasm guest cdylibs (undefined
`horizon:mods/events` symbols) until #1446 lands: add `--exclude coords-hud --exclude horizon-mods-test-guest`.
Their real check is `cargo test -p horizon-mods` (builds them for `wasm32-unknown-unknown`).
- Linux needs `libasound2-dev libudev-dev`; `--no-default-features` drops audio and controllers (the server build).
- Run and drive the game (starts the `horizon-client` beside `horizon-ctl`; other arguments go to the game):
`target/fast/horizon-ctl --socket $PWD/ctl.sock launch --world W --creative --offscreen --wait --resources $PWD/.local/horizon-client/26.3`,
then `horizon-ctl --socket … state|keys|turn|command|screenshot|quit`
([docs/horizon-ctl-commands.md](docs/horizon-ctl-commands.md)).
## Vanilla reference
- Decompiled sources: `.local/minecraft/26.3/sources/{client,server}/` (`python3 tools/lab.py decompile`).
- Bytecode queries (callers through lambdas, every overload):
`python3 tools/rea_vanilla.py method net.minecraft.world.level.block.DispenserBlock dispenseFrom`
([docs/decompilation-status.md](docs/decompilation-status.md)).
- A harness runs the oracle against the real 26.3 jars and the Rust probe on the same inputs, then compares
their JSON. Oracle runs are cached by `tools/parity_shim.py`; `--fresh` reruns them.
- Mirror vanilla's `Math` calls with `horizon_core::jmath`, and vanilla's RNG with `horizon_core::random`:
random draws must happen in vanilla's order.
## horizon-client: where things live
`L:` = lib (`crates/horizon-client/src/`), `G:` = bin (`crates/horizon-client/src/game/`). The lib can't see
`Game`; bin code reaches the lib as `horizon_client::<module>`.
| Subsystem | Files |
|---|---|
| Game loop, tick, frame | `game.rs`: `fn run`, `impl ApplicationHandler for Game` (`window_event`, `about_to_wait`), `fn tick`, `fn tick_world`, `fn render`; L: `clock.rs`, `window.rs`, `spans.rs` |
| Rendering | L: `gfx.rs` (`Renderer::new`, `Renderer::render`), `gfx/` (cull, occlusion, translucency, upload, timing, lod, weather, stars, end_sky), `*.wgsl`, `particles.rs`, `block_entity_render.rs`, `item_render.rs`, `fog.rs`, `clouds.rs`, `camera.rs`, `first_person.rs`; G: `render_tick.rs`, `item_draw.rs` |
| Chunks, streaming, light | L: `streaming.rs` (worker pool: generate, light, mesh), `world.rs`, `chunk.rs`, `chunk_map.rs`, `light.rs`, `mesher.rs` + `mesher/`, `vanillagen.rs` (binds horizon-world's generator), `lod.rs` + `lod/`, `ticket_levels.rs`; G: `world_loading.rs`, `postprocess.rs`; game.rs `fn update_world` |
| World generation | `crates/horizon-world/src/worldgen/` (`generator*.rs`, `feature_graph.rs`, `structure/`, `carver/`) |
| Block behaviour | `crates/horizon-world/src/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `redstone.rs`, `place.rs`, `interact.rs`); game.rs `fn tick_blocks`, `fn process_level_events` |
| Mobs | L: `mobs.rs` (types), `mobs/` (`ai.rs`, `goals.rs`, `brain.rs`, `*_brain.rs`, `nav.rs`, `path.rs`, `species.rs`, `spawning.rs`, `render.rs`, `anim.rs`); G: `creatures.rs` (ticking, spawning, mob events), one file per mob family (`dragon.rs`, `wither.rs`, `villagers.rs`, `piglins.rs`, `golems.rs`...), `natural_spawning.rs` |
| Other entities | L: `entities.rs`, `projectiles.rs`, `arrows.rs`; G: `boats.rs`, `minecarts.rs`, `mounts.rs`, `armor_stands.rs`, `paintings.rs`, `frames.rs`, `fireworks.rs`, `tnt.rs`, `explosion.rs` |
| Player actions | game.rs (`fn use_hand`, `fn use_item`, `fn attack`, `fn tick_mining`, `fn break_block`); G: `hand.rs`, `combat.rs`, `consume.rs`, `potions.rs`, `shield.rs`, `crossbow.rs`, `trident.rs`, `fishing.rs`; L: `motion.rs` + `motion/` (movement), `collisions.rs`, `items.rs`, `item_data.rs` |
| Inventory and containers | L: `menu.rs` (`AbstractContainerMenu.clicked`), `container_screen.rs`, `containers.rs`, `creative.rs`, `crafting.rs`, `recipe_book*.rs`, `stations.rs`, `merchant.rs`; game.rs `fn menu_ops`, `fn click_inventory`; G: `stations.rs`, `trading.rs`, `recipe_book.rs`, `machines.rs` |
| Commands | parse: L: `command/` (`args.rs`, `selector.rs`, `tree_*.rs`, `vanilla.rs`); run: G: `commands.rs` (dispatch) + `commands/*.rs` (`admin`, `execute`, `items`, `players`, `world_cmds`...); L: `scoreboard_cmd.rs`, `data_cmd.rs`, `nbt_path.rs`; G: `functions.rs`, `command_blocks.rs` |
| UI screens | `Screen` enum in game.rs; L: `title.rs` + `title/`, `options_ui.rs`, `multiplayer.rs`, `pack_screen.rs`, `graphics_ui.rs`, `screen_draw.rs`, `ui.rs` (widgets), `hud.rs`, `hud_layout.rs`, `hud_overlays.rs`, `chat.rs`, `tab_list.rs`; G: `startup_menu.rs`, `screens.rs` (pause, death), `options.rs`, `lan_screen.rs`, `chat.rs`, `sign_edit.rs`, `books.rs` |
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server` |
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `block_entity_nbt.rs`, `options_file.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
| 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` |
## How `Game` is organised
- `struct Game` (game.rs, ~290 fields) holds everything. Its methods are spread over ~180 files: game.rs keeps
the core (tick, render, input, inventory, world open/close) and each `game/<feature>.rs` adds an
`impl Game<'_>` block for one feature, with its own state types. Fields are private to the bin.
- Find a feature's code: `grep -rn "fn tick_<thing>\|fn <verb>_<thing>" crates/horizon-client/src/game*`;
the file is usually `game/<thing>s.rs`. Who calls it: grep the name in game.rs's `fn tick` / `fn tick_world`.
- Fields that say which mode runs: `remote` (this window is a client of a server: multiplayer, LAN guest, or
single player on its server thread), `lan` (`LanHost`: this world is open to the network), `integrated`
(the window's own server thread), `serving` (this `Game` *is* that server thread), `options.dedicated`.
`fn tick` returns `tick_remote()` when `remote` is set; nothing else of the local simulation runs.
- A server's players run through the same gameplay code as the local player: `as_guest` swaps the guest's
state (position, inventory, mode, mining, item use) into `Game` for one call (`game/lan_actions.rs`), and
`levels.rs` swaps whole dimensions in the same way. Changes reach clients through the block log, level
events and the entity tracker (`lan/tracking.rs`, `game/lan_entities.rs`).
- Shared read-only data: `Resources` (`res`: blocks, items, recipes, mob types, biomes) and `PackResources`
(`pack`), both in game.rs.
## 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.
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
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
```
## Docs worth knowing
[docs/parity-workflow.md](docs/parity-workflow.md) (runner, gate, shards, scopes) ·
[docs/native-client.md](docs/native-client.md) (options, controls, horizon-ctl) ·
[docs/server-levels.md](docs/server-levels.md) (levels per dimension) ·
[docs/rust-parity.md](docs/rust-parity.md) (what is ported) · [docs/known-issues.md](docs/known-issues.md) ·
[docs/release-flow.md](docs/release-flow.md) · [docs/README.md](docs/README.md) (index).
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+3 -5
View File
@@ -30,7 +30,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"
naga = { version = "30.0.1", features = ["wgsl-in"] }
# 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.
@@ -81,7 +83,3 @@ rng-trace = ["horizon-core/rng-trace"]
[lints]
workspace = true
[dev-dependencies]
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out"] }
@@ -111,6 +111,8 @@ struct World<'a> {
slime: f32,
starts: Vec<Start>,
players: Vec<([f64; 3], String)>,
/// Where the non-spectators among them are.
nearest: Vec<[f64; 3]>,
respawn: Option<[i32; 3]>,
unloaded: HashSet<(i32, i32)>,
spawn_mobs: bool,
@@ -302,19 +304,8 @@ impl NaturalLevel for World<'_> {
fn structure_spawns(&self) -> &StructureSpawns {
self.s.structures
}
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
let mut best: Option<f64> = None;
for (p, mode) in &self.players {
if mode == "spectator" {
continue;
}
let d = [p[0] - x, p[1] - y, p[2] - z];
let d = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
if best.is_none_or(|b| d < b) {
best = Some(d);
}
}
best
fn nearest_player_positions(&self) -> &[[f64; 3]] {
&self.nearest
}
fn respawn(&self) -> Option<[i32; 3]> {
self.respawn
@@ -730,6 +721,11 @@ fn main() -> horizon_client::Result<()> {
dimension_id: dimension_id.clone(),
slime: get("slime", "0.0").parse()?,
starts,
nearest: players
.iter()
.filter(|(_, mode)| mode != "spectator")
.map(|(p, _)| *p)
.collect(),
players,
respawn,
unloaded,
@@ -0,0 +1,132 @@
//! Random-tick sections companion for tools/test_random_tick_sections_parity.py:
//! the game's `RandomTickSections` choosing the sections of a chunk that
//! `Behaviours::tick_chunk_random` ticks, after the case's edits (see
//! VanillaRandomTickSectionsOracle for the format; the `#resources` line names
//! the prepared resources). The level the ticks run against reads as air
//! everywhere, so nothing ticks for real: its reads are the drawn positions.
use horizon_client::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, Section},
random_tick_sections::RandomTickSections,
};
use horizon_core::{
position::{BlockPos, ChunkPos},
random::LegacyRandom,
};
use horizon_world::{
fluid::FluidKind,
level::{GameRules, LevelAccess, LevelEvent, Ticks, data},
registry::BlockStateId,
};
use std::{cell::RefCell, error::Error, fmt::Write, path::PathBuf, sync::Arc};
struct Air {
reads: RefCell<Vec<BlockPos>>,
random: LegacyRandom,
speed: i32,
}
impl LevelAccess for Air {
fn block_state(&self, pos: BlockPos) -> BlockStateId {
self.reads.borrow_mut().push(pos);
BlockStateId(0)
}
fn set_raw(&mut self, _: BlockPos, _: BlockStateId) -> Option<BlockStateId> {
None
}
fn is_loaded(&self, _: BlockPos) -> bool {
true
}
fn random(&mut self) -> &mut LegacyRandom {
&mut self.random
}
fn game_time(&self) -> i64 {
0
}
fn game_rules(&self) -> GameRules {
GameRules {
random_tick_speed: self.speed,
..Default::default()
}
}
fn emit(&mut self, _: LevelEvent) {}
}
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = std::env::args().collect();
let input = std::fs::read_to_string(&args[1])?;
let root = input
.lines()
.find_map(|l| l.strip_prefix("#resources "))
.map(PathBuf::from)
.ok_or("no #resources line")?;
let registry = data::load_registry(&root)?;
let (facts, behaviours) = BlockFacts::load_pair(&root, &registry)?;
let mut output = String::new();
for line in input.lines().filter(|l| !l.starts_with('#')) {
let mut words = line.split(' ');
let mut ticks = Ticks::new(0);
ticks.random_tick_value = words.next().ok_or("no randValue")?.parse()?;
let mut level = Air {
reads: RefCell::default(),
random: LegacyRandom::new(0),
speed: words.next().ok_or("no speed")?.parse()?,
};
let mut chunk = Arc::new(Chunk::empty(0, 0));
let mut cache = RandomTickSections::default();
for word in words {
let op: Vec<&str> = word.split(':').collect();
let int = |i: usize| -> Result<i32, Box<dyn Error>> { Ok(op[i].parse()?) };
match op[0] {
"s" => {
let (section, index) = (int(1)?, int(2)?);
Arc::make_mut(&mut chunk).set(
index & 15,
MIN_Y + section * 16 + (index >> 8),
index >> 4 & 15,
int(3)? as u32,
);
}
"f" => {
let (a, b, m) = (int(2)? as u32, int(3)? as u32, int(4)? as usize);
let values = std::array::from_fn(|i| if i % m == m - 1 { b } else { a });
Arc::make_mut(&mut Arc::make_mut(&mut chunk).sections[int(1)? as usize])
.blocks = Section::from_blocks(&values);
}
"t" => {
output.push('|');
level.reads.borrow_mut().clear();
behaviours.tick_chunk_random(
&mut level,
&mut ticks,
ChunkPos::new(0, 0),
|_, from| cache.get((0, 0), &chunk, &facts).from(from),
);
for pos in level.reads.borrow().iter() {
let state = chunk.block(pos.x, pos.y, pos.z);
let fluid = matches!(
behaviours.fluid(state).kind(),
FluidKind::Lava | FluidKind::FlowingLava
);
write!(
output,
" {},{},{},{}{}",
pos.x,
pos.y,
pos.z,
if behaviours.is_randomly_ticking(state) {
'B'
} else {
'-'
},
if fluid { 'F' } else { '-' },
)?;
}
}
other => return Err(format!("unknown op {other}").into()),
}
}
writeln!(output, " | {}", ticks.random_tick_value)?;
}
std::fs::write(&args[2], output)?;
Ok(())
}
@@ -22,6 +22,11 @@ impl PlayerMotion {
pub fn refresh_attributes(&mut self) -> Result<()> {
live_updates::refresh(self)
}
/// `Player.internalSetAbsorptionAmount`: the amount as given, unclamped
/// (the saved `AbsorptionAmount`, or what a client's server sent).
pub fn internal_set_absorption(&mut self, value: f32) -> Result<()> {
self.store_absorption(value)
}
}
impl HealthWorld for PlayerMotion {
type Error = Box<dyn std::error::Error>;
+23
View File
@@ -167,6 +167,7 @@ fn object_style(o: &serde_json::Map<String, Value>) -> Style {
style.underlined = flag("underlined");
style.strikethrough = flag("strikethrough");
style.obfuscated = flag("obfuscated");
style.font = o.get("font").and_then(crate::text::font_id);
style
}
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
@@ -266,4 +267,26 @@ mod tests {
let (v, _) = parse_snbt("[\"a\",{\"text\":\"b\"}]").unwrap();
assert_eq!(to_text(&v, &mut |_| Text::empty()).plain(), "ab");
}
#[test]
fn the_font_is_inherited_and_kept() {
let (v, _) =
parse_snbt(r#"{text:"a",font:"alt",extra:["b",{text:"c",font:"minecraft:uniform"}]}"#)
.unwrap();
let t = to_text(&v, &mut |_| Text::empty());
let fonts: Vec<_> = t.chars().map(|(_, s)| s.font.clone()).collect();
let (alt, uniform) = (
Some("minecraft:alt".into()),
Some("minecraft:uniform".into()),
);
assert_eq!(fonts, [alt.clone(), alt, uniform]);
// Back to JSON with the field, which a re-read keeps.
let again = to_text(&t.component(), &mut |_| Text::empty());
let kept: Vec<_> = again.chars().map(|(_, s)| s.font.clone()).collect();
assert_eq!(kept, fonts);
// Not an identifier: no font.
let (v, _) = parse_snbt(r#"{text:"a",font:"Bad Font"}"#).unwrap();
let t = to_text(&v, &mut |_| Text::empty());
assert_eq!(t.spans[0].style.font, None);
}
}
+52 -2
View File
@@ -13,7 +13,11 @@ use std::{
io::{self, BufRead, BufReader, Read, Write},
net::{Ipv4Addr, TcpListener, TcpStream},
path::{Path, PathBuf},
sync::mpsc,
sync::{
Arc,
atomic::{AtomicBool, AtomicUsize, Ordering},
mpsc,
},
thread,
};
@@ -151,6 +155,12 @@ pub struct Server {
path: PathBuf,
requests: mpsc::Receiver<Request>,
sender: mpsc::Sender<Request>,
/// Clients connected now (an MCP server keeps one open; horizon-ctl one per command).
clients: Arc<AtomicUsize>,
/// Set when the server is dropped: the listener stops accepting and exits.
closed: Arc<AtomicBool>,
/// The TCP port, to wake the listener when closing.
port: Option<u16>,
}
/// `$HORIZON_CONTROL`, else `horizon-control.sock` in the user's runtime
@@ -174,10 +184,15 @@ impl Server {
}
let (sender, requests) = mpsc::channel();
let local = sender.clone();
let clients = Arc::new(AtomicUsize::new(0));
let counted = clients.clone();
let closed = Arc::new(AtomicBool::new(false));
let mut tcp_port = None;
if uses_tcp(path) {
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?;
let token = random_token();
let port = listener.local_addr()?.port();
tcp_port = Some(port);
write_private(
path,
&serde_json::json!({ "transport": "tcp", "port": port, "token": token })
@@ -186,6 +201,8 @@ impl Server {
spawn_listener(
move || listener.accept().map(|(s, _)| s),
sender,
counted,
closed.clone(),
move |stream: TcpStream| {
let _ = stream.set_nodelay(true);
let mut stream = Stream::Tcp(stream);
@@ -199,6 +216,8 @@ impl Server {
spawn_listener(
move || listener.accept().map(|(s, _)| s),
sender,
counted,
closed.clone(),
|s| Some(Stream::Unix(s)),
)?;
}
@@ -207,6 +226,9 @@ impl Server {
path: path.to_owned(),
requests,
sender: local,
clients,
closed,
port: tcp_port,
})
}
/// Commands from inside the process only (`--benchmark` without `--control`).
@@ -216,8 +238,15 @@ impl Server {
path: PathBuf::new(),
requests,
sender,
clients: Arc::default(),
closed: Arc::default(),
port: None,
}
}
/// How many clients are connected.
pub fn clients(&self) -> usize {
self.clients.load(Ordering::Relaxed)
}
/// Sends commands as a socket client would, from another thread.
pub fn sender(&self) -> mpsc::Sender<Request> {
self.sender.clone()
@@ -234,6 +263,8 @@ impl Server {
fn spawn_listener<S: Send + 'static>(
mut accept_next: impl FnMut() -> io::Result<S> + Send + 'static,
sender: mpsc::Sender<Request>,
clients: Arc<AtomicUsize>,
closed: Arc<AtomicBool>,
accept: impl Fn(S) -> Option<Stream> + Send + Sync + 'static,
) -> io::Result<()> {
let accept = std::sync::Arc::new(accept);
@@ -241,17 +272,24 @@ fn spawn_listener<S: Send + 'static>(
.name("horizon-control".into())
.spawn(move || {
loop {
let Ok(stream) = accept_next() else {
let next = accept_next();
if closed.load(Ordering::Relaxed) {
return;
}
let Ok(stream) = next else {
thread::sleep(std::time::Duration::from_millis(10));
continue;
};
let sender = sender.clone();
let accept = accept.clone();
let clients = clients.clone();
let _ = thread::Builder::new()
.name("horizon-control-client".into())
.spawn(move || {
if let Some(stream) = accept(stream) {
clients.fetch_add(1, Ordering::Relaxed);
serve(stream, &sender);
clients.fetch_sub(1, Ordering::Relaxed);
}
});
}
@@ -287,6 +325,18 @@ fn authenticate(stream: &mut Stream, token: &str) -> bool {
impl Drop for Server {
fn drop(&mut self) {
if self.path.as_os_str().is_empty() {
return;
}
// Wake the listener so it sees `closed` and exits (agent control turned off in-game).
self.closed.store(true, Ordering::Relaxed);
match self.port {
Some(port) => drop(TcpStream::connect((Ipv4Addr::LOCALHOST, port))),
#[cfg(unix)]
None => drop(UnixStream::connect(&self.path)),
#[cfg(not(unix))]
None => {}
}
let _ = std::fs::remove_file(&self.path);
}
}
+73 -5
View File
@@ -98,12 +98,73 @@ impl ItemEntity {
lava_hurt,
}
}
/// Returns false when the item should be removed.
/// `ItemEntity.tick` on a server: an item whose box is inside a block
/// (one placed where it lay) has no physics this tick
/// (`level.noCollision(box.deflate(1.0E-7))`).
pub fn inside_block(&self, world: &World, collision: &BlockCollisionRegistry<'_>) -> bool {
let mut scene = SceneCollisions {
world,
binding: collision,
subject: None,
};
let inner = self.bounds().deflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7));
!crate::collisions::no_collision_empty(&mut scene, inner)
}
/// `Entity.moveTowardsClosestSpace` from the middle of the item's box:
/// towards the nearest side of its block (north, south, west, east or
/// up) without a full collision cube, at `speed` (`nextFloat() * 0.2 +
/// 0.1`).
pub fn move_towards_closest_space(&mut self, world: &World, facts: &BlockFacts, speed: f32) {
let bounds = self.bounds();
let at = Vec3::new(
self.position.x,
(bounds.min_position().y + bounds.max_position().y) / 2.0,
self.position.z,
);
let pos = [
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
];
let delta = [
at.x - f64::from(pos[0]),
at.y - f64::from(pos[1]),
at.z - f64::from(pos[2]),
];
// (axis, step): NORTH, SOUTH, WEST, EAST, UP.
let mut closest = (1, 1);
let mut nearest = f64::MAX;
for (axis, step) in [(2, -1), (2, 1), (0, -1), (0, 1), (1, 1)] {
let mut n = pos;
n[axis] += step;
if !facts.get(world.block(n[0], n[1], n[2])).full_collision {
let d = if step > 0 {
1.0 - delta[axis]
} else {
delta[axis]
};
if d < nearest {
nearest = d;
closest = (axis, step);
}
}
}
let push = f64::from(closest.1 as f32 * speed);
let v = self.velocity.scale(0.75);
self.velocity = match closest.0 {
0 => Vec3::new(push, v.y, v.z),
1 => Vec3::new(v.x, push, v.z),
_ => Vec3::new(v.x, v.y, push),
};
}
/// Returns false when the item should be removed. `no_physics`: the item
/// moves through blocks this tick ([`ItemEntity::inside_block`]).
pub fn tick(
&mut self,
world: &World,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
no_physics: bool,
) -> bool {
self.ticks += 1;
self.previous = self.position;
@@ -140,15 +201,22 @@ impl ItemEntity {
subject: None,
};
let wanted = self.velocity;
let moved = scene
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.unwrap_or(Vec3::ZERO);
// Entity.move with noPhysics: straight through, the ground as it was.
let moved = if no_physics {
wanted
} else {
scene
.resolve(self.bounds(), wanted, 0.0, self.on_ground)
.unwrap_or(Vec3::ZERO)
};
self.position = Vec3::new(
self.position.x + moved.x,
self.position.y + moved.y,
self.position.z + moved.z,
);
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
if !no_physics {
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
}
if moved.x != wanted.x {
self.velocity.x = 0.0;
}
+92 -4
View File
@@ -89,6 +89,47 @@ impl Base<'_> {
}
/// `LivingEntity` + `Mob` (+ `AgeableMob`, creeper and skeleton specifics).
/// The mobs whose class extends vanilla's `Animal` (and so save `InLove`):
/// not allays, villagers, golems, dolphins or fish.
fn is_animal(id: &str) -> bool {
matches!(
id.strip_prefix("minecraft:").unwrap_or(id),
"pig"
| "cow"
| "mooshroom"
| "sheep"
| "chicken"
| "rabbit"
| "horse"
| "donkey"
| "mule"
| "zombie_horse"
| "skeleton_horse"
| "llama"
| "trader_llama"
| "goat"
| "panda"
| "polar_bear"
| "fox"
| "wolf"
| "cat"
| "ocelot"
| "parrot"
| "bee"
| "turtle"
| "armadillo"
| "frog"
| "axolotl"
| "hoglin"
| "strider"
| "camel"
| "camel_husk"
| "sniffer"
| "happy_ghast"
| "nautilus"
| "zombie_nautilus"
)
}
pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let base = Base {
id: &m.kind.id,
@@ -175,12 +216,15 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
for (key, value) in [
("Age", Tag::Int(0)),
("ForcedAge", Tag::Int(0)),
("InLove", Tag::Int(0)),
("AgeLocked", Tag::Byte(0)),
] {
map.entry(key.into()).or_insert(value);
}
}
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
if is_animal(id) {
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
}
match id {
"minecraft:creeper" => {
for (key, value) in [("Fuse", Tag::Short(30)), ("ExplosionRadius", Tag::Byte(3))] {
@@ -445,10 +489,10 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
map.entry(key.into()).or_insert(value);
}
}
// Sheep.addAdditionalSaveData: its wool as it is now.
"minecraft:sheep" => {
for (key, value) in [("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))] {
map.entry(key.into()).or_insert(value);
}
map.insert("Color".into(), Tag::Byte((m.extra.color & 15) as i8));
map.insert("Sheared".into(), Tag::Byte(i8::from(m.extra.sheared)));
}
// CopperGolem.addAdditionalSaveData.
"minecraft:copper_golem" => {
@@ -621,6 +665,9 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
m.extra.forced_age = number(tag.get("ForcedAge")).unwrap_or(0) as i32;
m.extra.age_locked = flag(tag, "AgeLocked");
}
if is_animal(&m.kind.id) {
m.extra.in_love = number(tag.get("InLove")).unwrap_or(0) as i32;
}
m.name_visible = flag(tag, "CustomNameVisible");
m.no_ai = flag(tag, "NoAI");
m.no_gravity = flag(tag, "NoGravity");
@@ -832,6 +879,11 @@ pub fn mob_from_nbt(tag: &Tag, types: &HashMap<String, Arc<MobType>>, id: u32) -
}
m.extra.egg_time = number(tag.get("life_ticks")).map_or(-1, |v| v as i32);
}
// Sheep.readAdditionalSaveData: white and unsheared when absent.
if m.species() == crate::mobs::Species::Sheep {
m.extra.color = number(tag.get("Color")).map_or(0, |c| (c as u8) & 15);
m.extra.sheared = flag(tag, "Sheared");
}
if m.species() == crate::mobs::Species::Shulker {
m.extra.variant3 = number(tag.get("AttachFace")).unwrap_or(0).clamp(0, 5) as u8;
m.extra.counter = number(tag.get("Peek")).unwrap_or(0).clamp(0, 100) as i32;
@@ -2051,3 +2103,39 @@ mod axolotl_nbt_tests {
assert_eq!(mm.temptation_cooldown_ticks.value(), Some(42));
}
}
#[cfg(test)]
mod sheep_nbt_tests {
use super::*;
#[test]
fn a_sheep_keeps_its_dyed_and_sheared_wool() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let mut m = Mob::new(
1,
Arc::new(data.types["minecraft:sheep"].clone()),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
// Summoned white, then dyed red and sheared: the save has it now.
let raw = Tag::compound([("Color", Tag::Byte(0)), ("Sheared", Tag::Byte(0))]);
(m.extra.color, m.extra.sheared) = (14, true);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], Some(&raw));
assert_eq!(
(number(tag.get("Color")), number(tag.get("Sheared"))),
(Some(14), Some(1))
);
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let back = mob_from_nbt(&tag, &types, 2).unwrap();
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
}
+23 -2
View File
@@ -109,6 +109,9 @@ pub struct Extras {
pub sort_key: String,
/// No AI: every AI feature hidden and off (`horizon_core::no_ai`; the launcher writes it too).
pub no_ai: bool,
/// Agents may control the game the player starts: it opens its control socket for
/// horizon-ctl and MCP clients (off by default; an agent's own launch always opens it).
pub agent_control: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -138,6 +141,7 @@ impl Default for Extras {
sort: false,
sort_key: "key.keyboard.unknown".into(),
no_ai: false,
agent_control: false,
}
}
}
@@ -202,6 +206,7 @@ impl Extras {
"sort" => e.sort = flag(e.sort),
"sortKey" if value.starts_with("key.") => e.sort_key = value.to_owned(),
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
_ => {}
}
}
@@ -209,7 +214,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -240,6 +245,7 @@ impl Extras {
self.sort_key,
horizon_core::no_ai::KEY,
bool_text(self.no_ai),
bool_text(self.agent_control),
)
}
/// Binds the extra key `name` (`ZOOM_KEY`, `SORT_KEY`); false for
@@ -457,6 +463,9 @@ pub enum Item {
MouseTweaksWheel,
Sort,
SortKey,
/// Not on the Extras page: Horizon Settings shows No AI, its AI Agent page Agent Control.
NoAi,
AgentControl,
}
/// The Extras page: headers and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
@@ -535,6 +544,8 @@ impl Item {
Item::FpsLows => "Minimum and 1% low",
Item::Keystrokes => "Keystrokes",
Item::KeystrokesCps => "Clicks per second",
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
@@ -548,7 +559,7 @@ impl Item {
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | Zoom | Minimap | Fps | Keystrokes | MouseTweaks | Sort
| AutoReconnect => {
| AutoReconnect | NoAi | AgentControl => {
return None;
}
})
@@ -592,6 +603,12 @@ impl Item {
}
Item::Keystrokes => "Movement keys, jump and mouse buttons as you press them.",
Item::KeystrokesCps => "Clicks in the last second under each mouse button.",
Item::NoAi => {
"Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too."
}
Item::AgentControl => {
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
}
}
}
/// The frame-time cost the main switch of an extra shows.
@@ -622,6 +639,8 @@ impl Item {
Item::MouseTweaks => e.mouse_tweaks,
Item::MouseTweaksWheel => e.mouse_tweaks_wheel,
Item::Sort => e.sort,
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
_ => return None,
})
}
@@ -677,6 +696,8 @@ impl Item {
}
Item::MinimapShape => e.minimap_round = !e.minimap_round,
Item::MinimapZoom => e.minimap_zoom = next(&MINIMAP_ZOOMS, e.minimap_zoom, back),
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::ZoomKey | Item::SortKey => {}
}
}
File diff suppressed because it is too large. Load diff
+236 -90
View File
@@ -3,6 +3,7 @@
mod advancements;
mod advancements_screen;
mod agent;
mod agent_settings;
mod allays;
mod armor_stands;
mod banners;
@@ -83,6 +84,7 @@ mod level_particles;
mod levels;
mod lightning;
mod llamas;
mod locator_bar;
mod loom;
mod mace;
mod machines;
@@ -119,6 +121,7 @@ mod pumpkin;
mod raids;
mod recipe_book;
mod remote;
mod remote_mob_data;
mod remote_objects;
mod remote_projectiles;
mod remote_stations;
@@ -160,6 +163,7 @@ mod ui_control;
mod vault;
mod villagers;
mod wandering_trader;
mod win;
mod wind;
mod wither;
mod world_loading;
@@ -264,6 +268,9 @@ struct Options {
/// Render into a texture instead of the window (`--offscreen`), so a
/// GPU the display cannot present from can still be benchmarked.
offscreen: bool,
/// `--width` and `--height` (vanilla's own arguments): the window's
/// starting size, 1280x720 by default.
window_size: [u32; 2],
/// Multiplayer: `--server HOST[:PORT]` joins at start; `--name` is the offline player name.
server: Option<String>,
/// `--name`, `HORIZON_PLAYER_NAME` or the signed-in account's name; else
@@ -276,9 +283,11 @@ struct Options {
benchmark: Option<String>,
/// `--dev-mod PATH` (repeatable): mods always on and reloaded when rebuilt.
dev_mods: Vec<PathBuf>,
/// Single player runs on an integrated server thread (game/integrated.rs),
/// as vanilla's does: the default. `--no-server-thread` or
/// `HORIZON_SERVER_THREAD=0` runs the world in the frame instead.
/// `--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.
server_thread: bool,
/// Set on the integrated server's own options: it draws nothing, so it skips meshing.
integrated: bool,
@@ -323,6 +332,7 @@ impl Options {
no_pause: false,
control: None,
offscreen: false,
window_size: [1280, 720],
spikes: std::env::var("HORIZON_SPIKES")
.ok()
.and_then(|v| v.parse().ok()),
@@ -335,7 +345,7 @@ impl Options {
dev_mods: Vec::new(),
// The command harness (HORIZON_COMMAND_SCRIPT) drives the world
// itself, without the event loop: it stays in the frame.
server_thread: std::env::var("HORIZON_SERVER_THREAD").map_or(true, |v| v != "0")
server_thread: std::env::var("HORIZON_SERVER_THREAD").is_ok_and(|v| v == "1")
&& std::env::var_os("HORIZON_COMMAND_SCRIPT").is_none(),
integrated: false,
};
@@ -345,7 +355,7 @@ impl Options {
match arg.as_str() {
"--help" | "-h" => {
println!(
"Horizon — Minecraft Java Edition {} in Rust\n\n --resources PATH Prepared resources (horizon-prepare)\n --render-distance N Chunks, 2..64 (default 12)\n --world NAME World folder under saves/ (default \"New World\")\n --seed N World seed for new worlds (default 0)\n --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 --no-server-thread 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 — 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_core::VANILLA_VERSION
);
return Ok(None);
@@ -394,6 +404,8 @@ impl Options {
"--timeout-seconds" => o.timeout = Duration::from_secs(value.parse()?),
"--smoke-report" => o.report = Some(value.into()),
"--screenshot-after" => o.screenshot_after = Some(value.parse()?),
"--width" => o.window_size[0] = value.parse::<u32>()?.max(1),
"--height" => o.window_size[1] = value.parse::<u32>()?.max(1),
"--world" => o.world = Some(value),
"--dev-mod" => o.dev_mods.push(std::path::absolute(&value)?),
"--benchmark" => {
@@ -462,6 +474,8 @@ enum Screen {
Friends,
/// CommandBlockEditScreen (game/command_blocks.rs).
CommandBlock,
/// WinScreen: the end poem and the credits (game/win.rs).
Win,
}
struct Mining {
pos: [i32; 3],
@@ -524,6 +538,7 @@ struct GameLevel<'w> {
spill: &'w mut spill::Spill,
/// Where the chunk tickets let blocks tick.
tickets: horizon_client::ticket_levels::Levels,
random_tick_sections: &'w mut horizon_client::random_tick_sections::RandomTickSections,
}
struct PlayerCollision {
y: f64,
@@ -1006,9 +1021,11 @@ pub struct UiResources {
pub panorama: Arc<horizon_client::gfx::panorama::Faces>,
}
impl UiResources {
pub fn load(root: &std::path::Path) -> Result<Self> {
/// From a resource root layering packs over `defaults`, the game's own
/// resources (`root` itself when no pack is on).
pub fn load(root: &std::path::Path, defaults: &std::path::Path) -> Result<Self> {
Ok(Self {
atlas: HudAtlas::build(root)?,
atlas: HudAtlas::build_over(root, defaults)?,
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(root)),
})
}
@@ -1354,6 +1371,8 @@ struct Game<'a> {
respawn_travel: Option<respawn_anchor::RespawnConfig>,
/// The next enter_dimension call comes from an End portal.
end_portal_travel: bool,
/// The open WinScreen (game/win.rs).
win: Option<win::Win>,
end: end::EndState,
block_ticks: Ticks,
tick_chunks: horizon_client::tick_chunk_membership::TickChunkMembership,
@@ -1373,6 +1392,14 @@ struct Game<'a> {
/// Entities of types Horizon does not simulate.
others: Vec<commands::OtherEntity>,
armor_stand_assets: armor_stands::ArmorStandAssets,
/// `WaypointStyleManager`.
waypoint_styles: horizon_client::locator_bar::WaypointStyles,
/// `ClientWaypointManager`: the locator bar's waypoints.
waypoints: horizon_client::locator_bar::ClientWaypoints,
/// The camera the last frame drew from (position, yaw, pitch, fov), for the locator bar.
locator_camera: Option<([f64; 3], f32, f32, f32)>,
/// `LocalPlayer.experienceDisplayStartTick`: (last progress, seen a change, tick).
experience_display: (f32, bool, u64),
boats: Vec<boats::Boat>,
boat_models: boats::BoatModels,
/// EndCrystalModel's baked layer.
@@ -1395,6 +1422,8 @@ struct Game<'a> {
/// (least recently shown first).
skin_downloads: RefCell<horizon_client::skins::Downloader>,
skin_slots: Vec<(String, usize)>,
/// Atlas trim slots in use: (sprite name, slot), least recently drawn first.
trim_slots: Vec<(String, usize)>,
cart_models: minecarts::CartModels,
/// The boat the player rides.
riding: Option<u32>,
@@ -1427,6 +1456,10 @@ struct Game<'a> {
item_columns: Option<machines::ItemColumns>,
creative_screen: Creative,
control: Option<horizon_client::control::Server>,
/// The Agent Control setting the socket was last opened or closed for (`agent_settings`).
agent_control_on: bool,
/// When an agent's last command came.
agent_last: Option<Instant>,
frame_stats: horizon_client::control::FrameStats,
/// This play session's frame and tick times, for opt-in performance statistics.
perf: horizon_client::telemetry::Session,
@@ -2084,7 +2117,14 @@ impl Game<'_> {
) {
continue;
}
let alive = item.tick(&self.world, self.res.facts(), self.collision);
// ItemEntity.tick's noPhysics (servers only): out of a block
// placed on it, towards the closest open side (#1252).
let no_physics = merging && item.inside_block(&self.world, self.collision);
if no_physics {
let speed = self.world.random.next_float() * 0.2 + 0.1;
item.move_towards_closest_space(&self.world, self.res.facts(), speed);
}
let alive = item.tick(&self.world, self.res.facts(), self.collision, no_physics);
cells.moved(i, item.previous, item.position);
// ItemEntity.tick: merge every 2 ticks while moving between
// blocks, every 40 otherwise, before the lifetime check.
@@ -2291,6 +2331,7 @@ impl Game<'_> {
pending: &mut self.pending,
spill: &mut self.spill,
tickets: self.tickets.levels(),
random_tick_sections: &mut self.random_tick_sections,
};
let result = f(self.res.behaviours(), &mut level, &mut self.block_ticks);
self.flush_spill();
@@ -2385,18 +2426,15 @@ impl Game<'_> {
self.sync_tick_chunks();
self.start_ticking_chunks();
let levels = self.tickets.levels();
let mut ticking = Vec::new();
let facts = self.res.facts();
for (&(x, z), chunk) in &self.world.chunks {
if !levels.random_ticking(x, z) || !ticking_chunk(&self.world, x, z) {
continue;
}
// Capture eligibility before inside-block and scheduled updates, as
// before. Keep the world map's chunk order, including empty chunks
// (tick_chunk_random still draws for precipitation in those).
let sections = self.random_tick_sections.get((x, z), chunk, facts);
ticking.push((x, z, sections));
}
// Keep the world map's chunk order, including empty chunks
// (tick_chunk_random still draws for precipitation in those).
let ticking: Vec<_> = self
.world
.chunks
.keys()
.copied()
.filter(|&(x, z)| levels.random_ticking(x, z) && ticking_chunk(&self.world, x, z))
.collect();
// Entity.checkInsideBlocks for the plates, tripwires and detector rails
// under the players (the host and the LAN guests) and the minecarts.
let mut inside = Vec::new();
@@ -2438,13 +2476,24 @@ impl Game<'_> {
self.process_level_events()?;
self.tick_scheduled()?;
self.with_level(|b, level, ticks| {
for (x, z, sections) in ticking {
b.tick_chunk_random(
level,
ticks,
horizon_core::position::ChunkPos::new(x, z),
sections,
);
for (x, z) in ticking {
// The sections are read again only after a block changed.
let mut read = None;
let mut sections = Default::default();
let pos = horizon_core::position::ChunkPos::new(x, z);
b.tick_chunk_random(level, ticks, pos, |level, from| {
if read != Some(level.world.revision) {
read = Some(level.world.revision);
sections = level
.world
.chunks
.get(&(x, z))
.map_or_else(Default::default, |chunk| {
level.random_tick_sections.get((x, z), chunk, level.facts)
});
}
sections.from(from)
});
}
b.run_block_events(level, ticks);
b.tick_block_entities(level, ticks);
@@ -3555,8 +3604,6 @@ impl Game<'_> {
}
}
fn tick_using(&mut self) {
// ItemCooldowns.tick for the shield.
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
let Some(hand) = self.used_hand() else {
return;
};
@@ -3772,6 +3819,9 @@ impl Game<'_> {
return Ok(());
}
if let Some((_, target)) = &self.travelling {
if self.win_screen_holds_arrival() {
return Ok(());
}
let (cx, cz) = (
(target.x.floor() as i32) >> 4,
(target.z.floor() as i32) >> 4,
@@ -3899,6 +3949,7 @@ impl Game<'_> {
None => 0.0,
};
self.ticks += 1;
self.track_experience_display();
// `/tick freeze` stops the world but not the player.
let running = self.clock.world_runs();
if running {
@@ -4257,7 +4308,10 @@ impl Game<'_> {
/// world's entities and blocks, and the network hub.
fn tick_world(&mut self, running: bool) -> Result<()> {
self.update_chunk_tickets();
horizon_client::spans::time("tick_effects", || self.tick_effects());
horizon_client::spans::time("tick_effects", || self.tick_effects(running));
// Player.tick's ItemCooldowns.tick for the window's player (a
// server's players count theirs down in `tick_guests`).
self.shield_cooldown = self.shield_cooldown.saturating_sub(1);
horizon_client::spans::time("tick_using", || self.tick_using());
horizon_client::spans::time("tick_hud", || self.tick_hud());
// The player's digging comes in with its packets, before the level
@@ -4458,7 +4512,8 @@ impl Game<'_> {
| Screen::WorldOptions
| Screen::Friends
| Screen::CommandBlock
) || self.book.is_some())
) || self.book.is_some()
|| self.win_screen_pauses())
}
/// `MouseHandler.grabMouse` / `releaseMouse`. Both put the pointer at
/// the window's centre, so a screen opened from gameplay (inventory,
@@ -4673,6 +4728,10 @@ impl Game<'_> {
fn hud_layers(&mut self, size: [u32; 2]) -> HudBatch {
let mut batch = HudBatch::new(size);
let (w, h) = (batch.width, batch.height);
if self.screen == Screen::Win {
self.draw_win_screen(&mut batch);
return batch;
}
if self.screen == Screen::Options {
self.draw_options(&mut batch);
return batch;
@@ -4753,12 +4812,41 @@ impl Game<'_> {
self.draw_extras(&mut batch);
}
if show_hud {
// The mount's hearts (and the jump bar that replaces the experience bar).
let (vehicle_hearts, jump_bar) = if spectator {
(0, false)
// Hud.nextContextualInfoState: the jump bar, the experience bar or
// the locator bar above the hotbar.
let has_experience = !self.creative && !spectator;
let contextual = horizon_client::locator_bar::ContextualBar::next(
self.waypoints.has_waypoints(),
!spectator && self.jumpable_mount(),
self.mount_jumping(),
has_experience,
self.experience_recent(),
);
use horizon_client::locator_bar::ContextualBar;
// The mount's hearts (and the jump bar).
let vehicle_hearts = if spectator {
0
} else {
self.draw_mount_hud(&mut batch, wi, hi)
self.draw_mount_hud(
&mut batch,
wi,
hi,
contextual == ContextualBar::JumpableVehicle,
)
};
if contextual == ContextualBar::Locator {
let (sprite, [x, y, w, h]) = horizon_client::locator_bar::background(wi, hi);
horizon_client::ui::nine_slice(
&mut batch,
&self.gui.atlas,
sprite,
x as f32,
y as f32,
w as f32,
h as f32,
u32::MAX,
);
}
if !self.creative
&& !spectator
&& let Some(p) = &mut self.player
@@ -4770,7 +4858,7 @@ impl Game<'_> {
hi,
self.experience.progress,
self.experience.level,
!jump_bar,
contextual == ContextualBar::Experience,
&width,
);
hud_layout::paint(&mut batch, atlas, &draws);
@@ -4820,6 +4908,10 @@ impl Game<'_> {
let draws = self.health_hud.layout(&inputs, wi, hi);
hud_layout::paint(&mut batch, atlas, &draws);
}
if contextual == ContextualBar::Locator {
let draws = self.locator_bar_draws(wi, hi);
hud_layout::paint(&mut batch, &self.gui.atlas, &draws);
}
if !spectator {
self.draw_selected_name(&mut batch, !self.creative);
}
@@ -4874,7 +4966,7 @@ impl Game<'_> {
self.draw_creative(&mut batch);
}
Screen::Chat => self.draw_chat_screen(&mut batch),
Screen::Playing | Screen::Options => {}
Screen::Playing | Screen::Options | Screen::Win => {}
}
self.draw_toasts(&mut batch);
self.remote_hud(&mut batch);
@@ -6461,6 +6553,7 @@ impl Game<'_> {
mut out: Vec<Vertex>,
) -> Vec<Vertex> {
self.prepare_entity_culling(camera);
self.prepare_trims();
let fire_yaw = if self.camera_type == perspective::CameraType::ThirdPersonFront {
self.yaw + 180.0
} else {
@@ -6946,10 +7039,16 @@ impl Game<'_> {
// Item.getAttackDamageBonus (a mace's smash), before the critical hit.
amount += self.smash_damage_bonus(target_kind);
let player = self.player.as_ref();
// Player.canCriticalAttack: falling, off the ground, not sprinting.
let falling =
player.is_some_and(|p| !p.ground.on_ground && p.fall_distance > 0.0 && !p.sprinting);
let crit = scale > 0.9 && falling;
// Player.canCriticalAttack: falling, off the ground, not in water,
// not blind (isMobilityRestricted), not riding, not sprinting.
let falling = player.is_some_and(|p| {
!p.ground.on_ground
&& p.fall_distance > 0.0
&& !p.fluids.was_touching_water
&& !p.sprinting
});
let restricted = self.potions.effects.get("blindness").is_some() || self.riding.is_some();
let crit = scale > 0.9 && falling && !restricted;
if crit {
amount *= 1.5;
}
@@ -7458,16 +7557,11 @@ impl Game<'_> {
self.sleep_timer = 0;
self.spawn = None;
self.respawn_travel = None;
self.respawn_config =
self.level
.respawn_elsewhere
.as_ref()
.map(|(dim, pos, yaw, forced)| respawn_anchor::RespawnConfig {
dim: Dim::from_id(dim),
pos: *pos,
yaw: *yaw,
forced: *forced,
});
self.respawn_config = self
.level
.respawn_elsewhere
.as_ref()
.map(respawn_anchor::RespawnConfig::from_saved);
self.spawn_origin = None;
self.spawn_spiral = Default::default();
self.player_spawn = None;
@@ -7563,6 +7657,8 @@ impl Game<'_> {
world.game_time = self.world.game_time;
world.day_time = self.world.day_time;
world.weather = self.world.weather;
// The game rules and the difficulty hold in every dimension.
world.health_rules = self.world.health_rules;
// An open hub keeps hearing the new level's block changes.
world.block_log = self.world.block_log.as_ref().map(|_| Vec::new());
world.light_log = self.world.light_log.as_ref().map(|_| Default::default());
@@ -7753,6 +7849,7 @@ impl Game<'_> {
Ok(())
}
fn close_world(&mut self) -> Result<()> {
self.clear_waypoints();
self.begin_closing_save(false)
}
fn finish_close_world(&mut self) -> Result<()> {
@@ -9068,31 +9165,41 @@ impl Game<'_> {
} else {
pos
};
self.spawn = Some(Vec3::new(
f64::from(head[0]) + 0.5,
f64::from(head[1]) + 0.6,
f64::from(head[2]) + 0.5,
));
// The bed replaces a respawn anchor.
self.respawn_config = None;
self.level.spawn = Some([
f64::from(head[0]),
f64::from(head[1]) + 1.0,
f64::from(head[2]),
]);
let dist = ((p.position.x - f64::from(head[0]) - 0.5).powi(2)
+ (p.position.z - f64::from(head[2]) - 0.5).powi(2))
.sqrt();
if !night {
if dist > 3.0 {
self.message = Some((
"You can sleep only at night or during thunderstorms".into(),
"You may not rest now; the bed is too far away".into(),
Instant::now(),
));
return;
}
if dist > 3.0 {
// In reach (bedInRange), day or night: setRespawnPosition(
// RespawnConfig(the head, the player's yaw), not forced). It replaces
// an anchor or a spawnpoint; respawning checks the bed is still there
// and finds where to stand up (findRespawnAndUseSpawnBlock).
// Straw beds never set it (`straw_bed_rule`).
if self.bed_sets_spawn(state) {
self.respawn_config = Some(respawn_anchor::RespawnConfig::wrapped(
self.dim, head, p.yaw, false,
));
// The fallback becomes the world spawn: a guest's is none of its
// own, so it follows `setworldspawn`.
self.spawn = if self.lan_acting.is_some() {
None
} else {
self.level
.world_spawn
.map(|[x, y, z]| {
Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5)
})
.or(self.spawn)
};
}
if !night {
self.message = Some((
"You may not rest now; the bed is too far away".into(),
"You can sleep only at night or during thunderstorms".into(),
Instant::now(),
));
return;
@@ -9444,9 +9551,7 @@ impl Game<'_> {
level.weather = self.world.weather;
level.day_time = self.world.day_time;
level.spawn = self.spawn.map(|s| [s.x, s.y, s.z]);
level.respawn_elsewhere = self
.respawn_config
.map(|c| (c.dim.id().to_owned(), c.pos, c.yaw, c.forced));
level.respawn_elsewhere = self.respawn_config.map(|c| c.saved());
level.rotation = [self.yaw, self.pitch];
level.xp_level = self.experience.level;
level.xp_progress = self.experience.progress;
@@ -9454,6 +9559,7 @@ impl Game<'_> {
if let Some(p) = &self.player {
level.position = Some([p.position.x, p.position.y, p.position.z]);
level.health = p.health.health();
level.absorption = p.health.absorption();
let extra = level
.player_extra
.get_or_insert_with(|| horizon_world::nbt::Tag::Compound(Default::default()));
@@ -9466,6 +9572,7 @@ impl Game<'_> {
);
map.insert("Air".into(), horizon_world::nbt::Tag::Short(p.air as i16));
}
level.fall_distance = p.fall_distance;
level.food = p.health.food.food;
level.saturation = p.health.food.saturation;
@@ -9771,6 +9878,8 @@ impl Game<'_> {
} else {
self.fov_degrees(camera_alpha as f32, camera.to_array())
};
self.locator_camera =
(!title).then_some((camera.to_array(), view_yaw, view_pitch, fov_degrees));
let bob = if title {
glam::Mat4::IDENTITY
} else {
@@ -10034,12 +10143,13 @@ impl Game<'_> {
let loading = self.loading_reason() == Some(horizon_client::level_loading::Reason::Other);
let blurred = loading
|| self.title.as_ref().is_some_and(|t| {
self.screen == Screen::Options
matches!(self.screen, Screen::Options | Screen::Win)
|| !matches!(t.mode, horizon_client::title::Mode::Main)
})
|| (self.title.is_none()
&& self.player.is_some()
&& self.screen.blurs_background(self.pause_menu));
&& (self.screen.blurs_background(self.pause_menu)
|| (self.screen == Screen::Win && self.win_screen_pauses())));
let radius = self.settings.menu_blur as f32;
(blurred && radius >= 1.0).then_some(radius)
}
@@ -10123,6 +10233,7 @@ impl Game<'_> {
Screen::WorldOptions => self.world_options_escape(),
Screen::CommandBlock => self.close_command_block(),
Screen::Chat => self.close_chat(),
Screen::Win => self.close_win_screen()?,
},
KeyCode::Digit1
| KeyCode::Digit2
@@ -10454,6 +10565,10 @@ impl Game<'_> {
if let (Some(lod), Some(player)) = (&mut self.lod, &self.player) {
let lod_distance = self.settings.horizon.lod_distance_on(&renderer.info);
lod.set_near_loading(self.streaming.busy());
lod.set_reduced_detail(
self.settings.horizon.far_detail
== horizon_client::graphics::FarDetail::Reduced,
);
// The covered columns change only with the complete ones and
// the render area: rebuilt then, not every frame.
let at = (
@@ -10498,6 +10613,7 @@ impl ApplicationHandler for Game<'_> {
}
// The Window constructor: created fullscreen when options.txt says so.
let fullscreen = self.settings.fullscreen;
let size = self.options.window_size.map(f64::from);
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
let attributes = Window::default_attributes()
.with_title("Horizon")
@@ -10509,7 +10625,7 @@ impl ApplicationHandler for Game<'_> {
);
let window = match event_loop.create_window(
attributes
.with_inner_size(winit::dpi::LogicalSize::new(1280.0, 720.0))
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
.with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None))),
) {
Ok(w) => Arc::new(w),
@@ -10624,6 +10740,9 @@ 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 {
@@ -10718,7 +10837,9 @@ impl ApplicationHandler for Game<'_> {
}) * wheel_sensitivity
};
let d = scaled(raw_y) as f32;
if let Some(title) = &mut self.title {
if let Some(title) = &mut self.title
&& self.screen != Screen::Win
{
let size = self
.window
.as_ref()
@@ -10776,6 +10897,7 @@ impl ApplicationHandler for Game<'_> {
self.advancements_scroll(scaled(raw_x) as f32, d);
}
}
WindowEvent::MouseInput { .. } if self.screen == Screen::Win => {}
WindowEvent::MouseInput { state, button, .. } if self.screen == Screen::Options => {
if self.options_waiting_for_key() && state == ElementState::Pressed {
self.options_bind_mouse(button)?;
@@ -11032,6 +11154,9 @@ impl ApplicationHandler for Game<'_> {
{
self.world_options_key(&event)
}
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::Win => {
self.win_key(&event)?
}
WindowEvent::KeyboardInput { event, .. } if self.screen == Screen::CommandBlock => {
self.command_block_key(&event)
}
@@ -11165,6 +11290,7 @@ impl ApplicationHandler for Game<'_> {
self.tick_ambience();
self.tick_fov();
}
self.tick_win_screen()?;
self.tick_music();
self.tick_sound_engine();
self.clock.record(started.elapsed());
@@ -11592,6 +11718,7 @@ impl<'a> Game<'a> {
respawn_config: None,
respawn_travel: None,
end_portal_travel: false,
win: None,
end: Default::default(),
block_ticks: Ticks::new(0),
tick_chunks: Default::default(),
@@ -11609,6 +11736,10 @@ impl<'a> Game<'a> {
armor_stands: Vec::new(),
others: Vec::new(),
armor_stand_assets: prepared.armor_stand_assets,
waypoint_styles: prepared.waypoint_styles,
waypoints: Default::default(),
locator_camera: None,
experience_display: Default::default(),
boats: Vec::new(),
boat_models: prepared.boat_models,
crystal_model: prepared.crystal_model,
@@ -11629,6 +11760,7 @@ impl<'a> Game<'a> {
.join("assets/skins"),
)),
skin_slots: Vec::new(),
trim_slots: Vec::new(),
cart_models: prepared.cart_models,
riding: None,
ender_items: vec![None; 27],
@@ -11650,6 +11782,8 @@ impl<'a> Game<'a> {
item_columns: None,
creative_screen: prepared.creative_screen,
control: None,
agent_control_on: false,
agent_last: None,
frame_stats: Default::default(),
perf: Default::default(),
pending_screenshot: None,
@@ -11733,6 +11867,7 @@ struct GameCpuResources {
map_frame_model: frames::FrameModel,
glow_map_frame_model: frames::FrameModel,
armor_stand_assets: armor_stands::ArmorStandAssets,
waypoint_styles: horizon_client::locator_bar::WaypointStyles,
boat_models: boats::BoatModels,
cart_models: minecarts::CartModels,
crystal_model: Option<horizon_client::mobs::Part>,
@@ -11752,6 +11887,7 @@ impl GameCpuResources {
map_frame_model: frames::bake_frame(root, "item_frame_map"),
glow_map_frame_model: frames::bake_frame(root, "glow_item_frame_map"),
armor_stand_assets: armor_stands::ArmorStandAssets::load(root, document.as_ref()),
waypoint_styles: horizon_client::locator_bar::WaypointStyles::load(root),
boat_models: boats::BoatModels::load(document.as_ref()),
cart_models: minecarts::CartModels::load(document.as_ref()),
crystal_model: model("minecraft:end_crystal#main"),
@@ -11975,7 +12111,20 @@ fn load_resources(
// loads; the synchronous launch instead carries it in `Loaded`.
let mut loaded_gui = None;
let mut ui_ready = false;
if let Ok(ui) = UiResources::load(&root) {
let mut pack_error = None;
let ui = match UiResources::load(&root, vanilla_root) {
Ok(ui) => Some(ui),
// Minecraft.rollbackResourcePacks: the packs' textures don't fit, start
// with the game's own resources.
Err(e) if root != vanilla_root => {
log::warn!("Horizon: resource packs failed to load ({e}); using the default resources");
pack_error = Some(e.to_string());
root = vanilla_root.to_path_buf();
None
}
Err(_) => None,
};
if let Some(ui) = ui {
if menu {
horizon_client::telemetry::mark_ui_ready();
log::info!(
@@ -11989,7 +12138,6 @@ fn load_resources(
ui_ready = true;
}
progress.set(0);
let mut pack_error = None;
let (assets, render) = match Assets::load(&root).and_then(|assets| {
let render = assets.reload(&root)?;
Ok((assets, render))
@@ -12016,7 +12164,7 @@ fn load_resources(
let render = Arc::new(render);
// The menu bundle must come from the same root as the world bundle.
if !ui_ready || root != requested_root {
let ui = UiResources::load(&root)?;
let ui = UiResources::load(&root, vanilla_root)?;
if menu {
horizon_client::telemetry::mark_ui_ready();
log::info!(
@@ -12427,6 +12575,10 @@ fn start_game<'a>(
game.rest_loader = rest_loader;
game.ui.vanilla_root = vanilla_root;
if let Some(error) = pack_error {
// Minecraft.clearResourcePacksOnError: the selection is cleared and
// saved, so the next launch starts without the packs.
game.settings.resource_packs.clear();
game.settings.save(&Game::options_path());
game.open_options_at(true, horizon_client::options_ui::Page::Packs);
game.ui.options.resource_error = Some(error);
}
@@ -12444,18 +12596,7 @@ fn start_game<'a>(
game.mods.dev = game.options.dev_mods.clone();
game.start_mods();
}
if game.options.control.is_some() && game.settings.extras.no_ai {
// No AI turns agent control off: the socket is never opened.
log::warn!(
"Horizon: control socket not opened: {}",
horizon_core::no_ai::REFUSAL
);
} else if let Some(path) = &game.options.control {
let server = horizon_client::control::Server::start(path)
.map_err(|e| format!("control socket {}: {e}", path.display()))?;
log::info!("Horizon: control socket {}", server.path().display());
game.control = Some(server);
}
game.start_agent_control()?;
if let Some(scenario) = game.options.benchmark.clone() {
let control = game
.control
@@ -12596,6 +12737,11 @@ impl Loading {
}
// The Window constructor: created fullscreen when options.txt says so.
let fullscreen = self.settings.fullscreen;
let size = self
.options
.as_ref()
.map_or([1280, 720], |o| o.window_size)
.map(f64::from);
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
let attributes = Window::default_attributes()
.with_title("Horizon")
@@ -12607,7 +12753,7 @@ impl Loading {
);
let window = match event_loop.create_window(
attributes
.with_inner_size(winit::dpi::LogicalSize::new(1280.0, 720.0))
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
.with_fullscreen(fullscreen.then_some(winit::window::Fullscreen::Borderless(None))),
) {
Ok(w) => Arc::new(w),
+6
View File
@@ -405,6 +405,7 @@ impl Game<'_> {
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
}
}
pub(super) fn inventory_counts(&self) -> BTreeMap<String, i64> {
@@ -459,8 +460,11 @@ impl Game<'_> {
"sneaking": self.keys.contains(&KeyCode::ShiftLeft),
"sprinting": self.keys.contains(&KeyCode::ControlLeft),
"health": p.health.health(),
"absorption": p.health.absorption(),
"air": p.air,
"food": p.health.food.food,
"saturation": p.health.food.saturation,
"shield_cooldown": self.shield_cooldown,
"xp": { "level": self.experience.level, "progress": self.experience.progress, "total": self.experience.total },
"effects": self.potions.effects.list.iter().map(|e| json!({ "id": e.id, "duration_ticks": e.duration, "amplifier": e.amplifier })).collect::<Vec<_>>(),
})
@@ -478,6 +482,8 @@ impl Game<'_> {
"sleeping": self.sleeping,
// The item in use: its slot and for how many ticks.
"using": self.using.map(|(slot, ticks)| json!({ "slot": slot, "ticks": ticks })),
// The block being dug: how far along, after how many ticks.
"mining": self.mining.as_ref().map(|m| json!({ "pos": m.pos, "progress": m.progress, "ticks": m.ticks })),
// The arm swings started (LivingEntity.swing).
"swings": self.swings_started,
"time": { "day_time": self.world.day_time, "day": self.world.day_time.div_euclid(24000), "time_of_day": self.world.day_time.rem_euclid(24000) },
@@ -0,0 +1,140 @@
//! Agent control as a setting (Horizon Settings > AI Agent): when the control socket is open, who
//! is connected, and the details an agent needs. The socket opens when the game was started for
//! an agent (`--control`, `--control-socket`: horizon-ctl launch, the launcher's MCP `play`) or
//! when the player turned Agent Control on, and never under No AI (`horizon_core::no_ai`).
//! Turning either switch in the game opens or closes it at once.
use super::Game;
use horizon_client::{Result, control};
use std::path::PathBuf;
impl Game<'_> {
/// The endpoint: the one the command line gave, else the default horizon-ctl looks for.
fn agent_socket_path(&self) -> PathBuf {
self.options
.control
.clone()
.unwrap_or_else(control::default_path)
}
/// At startup: open the socket if the game was started for an agent or Agent Control is on.
pub(super) fn start_agent_control(&mut self) -> Result<()> {
self.agent_control_on = self.settings.extras.agent_control;
if self.options.benchmark.is_some() && self.options.control.is_none() {
return Ok(());
}
if self.settings.extras.no_ai {
if self.options.control.is_some() {
log::warn!(
"Horizon: control socket not opened: {}",
horizon_core::no_ai::REFUSAL
);
}
return Ok(());
}
if let Some(path) = &self.options.control {
// Asked for on the command line: failing to listen is an error.
let server = control::Server::start(path)
.map_err(|e| format!("control socket {}: {e}", path.display()))?;
log::info!("Horizon: control socket {}", server.path().display());
self.control = Some(server);
} else if self.agent_control_on {
self.open_agent_socket();
}
Ok(())
}
/// The setting's socket: a failure (another game holds the default endpoint) is logged and
/// shown on the AI Agent page, not fatal.
fn open_agent_socket(&mut self) {
let path = self.agent_socket_path();
match control::Server::start(&path) {
Ok(server) => {
log::info!("Horizon: control socket {}", server.path().display());
self.control = Some(server);
}
Err(e) => log::warn!("Horizon: control socket {}: {e}", path.display()),
}
}
/// After a settings change: No AI closes the socket; Agent Control turned on or off opens or
/// closes it.
pub(super) fn sync_agent_control(&mut self) {
let wanted = self.settings.extras.agent_control;
let changed = wanted != self.agent_control_on;
self.agent_control_on = wanted;
let open = self
.control
.as_ref()
.is_some_and(|c| !c.path().as_os_str().is_empty());
if self.settings.extras.no_ai || (changed && !wanted) {
if open {
log::info!("Horizon: control socket closed");
self.control = None;
}
} else if changed && wanted && self.control.is_none() {
self.open_agent_socket();
}
}
/// An agent's command arrived.
pub(super) fn agent_command_seen(&mut self) {
self.agent_last = Some(std::time::Instant::now());
}
/// The AI Agent page's lines: the state, then where agents connect.
pub(super) fn agent_status_lines(&self) -> Vec<String> {
let server = self
.control
.as_ref()
.filter(|c| !c.path().as_os_str().is_empty());
let mut lines = vec![match server {
Some(s) if s.clients() > 0 => {
let ago = self
.agent_last
.map(|t| format!(", last command {} s ago", t.elapsed().as_secs()))
.unwrap_or_default();
format!(
"Connected: {} agent connection{}{ago}",
s.clients(),
if s.clients() == 1 { "" } else { "s" }
)
}
Some(_) => "Waiting for an agent.".into(),
None => "Off: agents can't reach this game.".into(),
}];
if let Some(s) = server {
if self.options.control.is_some() && !self.agent_control_on {
lines.push("Opened for this session by the program that started the game.".into());
}
// The full path is too wide for the page: Copy Connection Details has it.
let name = s.path().file_name().unwrap_or_default().to_string_lossy();
lines.push(format!(
"Socket: {name} (copy the details for its full path)"
));
}
lines.push("Local only: agents on this computer, never the network.".into());
lines
}
/// What Copy Connection Details puts on the clipboard: the socket, a horizon-ctl line to try,
/// and an MCP client's config for this game.
pub(super) fn agent_details(&self) -> String {
let socket = self.agent_socket_path();
let ctl = std::env::current_exe()
.unwrap_or_default()
.with_file_name(if cfg!(windows) {
"horizon-ctl.exe"
} else {
"horizon-ctl"
});
let config = serde_json::json!({ "mcpServers": { "horizon-game": {
"command": ctl, "args": ["--socket", socket, "mcp"] } } });
format!(
"Horizon agent control (this computer only)\nSocket: {}\nTry it: \"{}\" --socket \"{}\" state\nMCP config:\n{}\n",
socket.display(),
ctl.display(),
socket.display(),
serde_json::to_string_pretty(&config).unwrap_or_default()
)
}
}
+175 -6
View File
@@ -8,12 +8,13 @@ use horizon_client::{
items::ItemStack,
mesher::Vertex,
mobs::{Mob, Part, anim::Poses},
trims::{TrimOverride, TrimRegistry, TrimTexture},
};
use horizon_core::{
geometry::{Aabb, Vec3},
random::RandomSource,
};
use std::{collections::HashMap, sync::Arc};
use std::{collections::HashMap, path::PathBuf, sync::Arc};
/// Armor stand models and the humanoid layers of every equipment asset.
pub(super) struct ArmorStandAssets {
@@ -25,6 +26,11 @@ pub(super) struct ArmorStandAssets {
pub player: HashMap<&'static str, Part>,
/// asset id -> layer kind (humanoid / humanoid_leggings) -> (texture, undyed colour).
pub equipment: HashMap<String, HashMap<String, (String, Option<u32>)>>,
/// asset id -> `trim_overrides`.
pub trim_overrides: HashMap<String, Vec<TrimOverride>>,
pub trims: TrimRegistry,
/// The resources the trim textures are made from.
pub root: PathBuf,
}
impl ArmorStandAssets {
pub fn load(
@@ -48,6 +54,7 @@ impl ArmorStandAssets {
player.insert("elytra", part);
}
let mut equipment = HashMap::new();
let mut trim_overrides = HashMap::new();
if let Ok(entries) = std::fs::read_dir(root.join("assets/minecraft/equipment")) {
for e in entries.flatten() {
let path = e.path();
@@ -76,6 +83,7 @@ impl ArmorStandAssets {
);
}
}
trim_overrides.insert(format!("minecraft:{name}"), TrimOverride::list(&v));
equipment.insert(format!("minecraft:{name}"), kinds);
}
}
@@ -85,30 +93,67 @@ impl ArmorStandAssets {
layers,
player,
equipment,
trim_overrides,
trims: TrimRegistry::load(root),
root: root.to_owned(),
}
}
/// Trim data of reloaded resources (the models stay).
pub fn reload_trims(&mut self, root: &std::path::Path) {
let fresh = Self::load(root, None);
self.trim_overrides = fresh.trim_overrides;
self.trims = fresh.trims;
self.root = fresh.root;
}
/// `TrimTextureKey`: the trim texture of `stack` on its humanoid
/// layer (`humanoid` or `humanoid_leggings`), if it has a trim.
fn trim_texture(&self, asset: &str, kind: &str, stack: &ItemStack) -> Option<TrimTexture> {
let (material, pattern) = stack.extra.as_ref()?.trim.as_ref()?;
let overrides = self
.trim_overrides
.get(asset)
.map_or(&[][..], Vec::as_slice);
TrimTexture::resolve(&self.trims, overrides, material, pattern, kind)
}
}
impl Game<'_> {
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour) of
/// each worn piece, head to feet.
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32)> {
self.armor_layers(self.armor.each_ref().map(|s| s.as_ref().map(|s| &*s.item)))
self.stack_armor_layers(&self.armor)
}
/// `HumanoidArmorLayer` for worn stacks (head to feet), trims included.
pub(super) fn stack_armor_layers(
&self,
armor: &[Option<ItemStack>; 4],
) -> Vec<(&Part, [f32; 4], u32)> {
self.armor_layers(
armor
.each_ref()
.map(|s| s.as_ref().map(|s| (&*s.item, Some(s)))),
)
}
/// `HumanoidArmorLayer` for another player's model: the armour it wears
/// (`extra.head` .. `extra.feet`).
pub(super) fn worn_armor_layers(&self, m: &Mob) -> Vec<(&Part, [f32; 4], u32)> {
let e = &m.extra;
self.armor_layers([&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref()))
self.armor_layers(
[&e.head, &e.chest, &e.legs, &e.feet].map(|s| s.as_deref().map(|i| (i, None))),
)
}
/// The layers of the armour items `worn`, head to feet.
fn armor_layers(&self, worn: [Option<&str>; 4]) -> Vec<(&Part, [f32; 4], u32)> {
/// The layers of the armour items `worn` (with their stacks when known,
/// for trims), head to feet.
fn armor_layers(
&self,
worn: [Option<(&str, Option<&ItemStack>)>; 4],
) -> Vec<(&Part, [f32; 4], u32)> {
let assets = &self.armor_stand_assets;
let mut out = Vec::new();
for (item, layer) in worn
.into_iter()
.zip(["helmet", "chestplate", "leggings", "boots"])
{
let Some(item) = item else {
let Some((item, stack)) = item else {
continue;
};
let Some(asset) = self
@@ -138,9 +183,117 @@ impl Game<'_> {
0xFF00_0000 | (c & 0xFF) << 16 | (c & 0xFF00) | (c >> 16) & 0xFF
});
out.push((part, [r.u0, r.v0, r.u1, r.v1], color));
// EquipmentLayerRenderer: the trim layer over the armour.
if let Some(r) = stack
.and_then(|s| assets.trim_texture(&asset, kind, s))
.and_then(|t| self.gui.atlas.sprites.get(&t.sprite()))
{
out.push((part, [r.u0, r.v0, r.u1, r.v1], 0xFFFF_FFFF));
}
}
out
}
/// `PalettedTextureManager.getOrPrepare` for the trims drawn this frame
/// (the player's armour and the armour stands'): each generated texture
/// goes into an atlas trim slot, a free one, else the least recently
/// drawn.
pub(super) fn prepare_trims(&mut self) {
use horizon_client::hud::TRIM_SLOTS;
let assets = &self.armor_stand_assets;
let items = self.res.items();
let mut wanted: Vec<TrimTexture> = Vec::new();
let guests = self.lan.iter().flat_map(|host| host.hub.guests.iter());
let stacks = self
.armor
.iter()
.chain(guests.flat_map(|g| g.armor.iter()))
.zip(
["helmet", "chestplate", "leggings", "boots"]
.into_iter()
.cycle(),
)
.chain(self.armor_stands.iter().flat_map(|a| {
[5usize, 4, 3, 2]
.map(|slot| &a.equipment[slot])
.into_iter()
.zip(["helmet", "chestplate", "leggings", "boots"])
}));
for (stack, layer) in stacks {
let Some(stack) = stack.as_ref().filter(|s| s.extra.is_some()) else {
continue;
};
let Some(asset) = items
.get(&stack.item)
.and_then(|i| i.equip_asset.as_deref())
else {
continue;
};
let kind = if layer == "leggings" {
"humanoid_leggings"
} else {
"humanoid"
};
if let Some(t) = assets.trim_texture(asset, kind, stack)
&& !wanted.contains(&t)
{
wanted.push(t);
}
}
for t in wanted {
let name = t.sprite();
// Entries the atlas no longer has (rebuilt by a resource reload) are free.
let sprites = &self.gui.atlas.sprites;
self.trim_slots.retain(|(n, _)| sprites.contains_key(n));
if let Some(i) = self.trim_slots.iter().position(|e| e.0 == name) {
let e = self.trim_slots.remove(i);
self.trim_slots.push(e);
continue;
}
let Some((w, h, rgba)) = t.generate(&self.armor_stand_assets.root) else {
continue;
};
let used: Vec<usize> = self.trim_slots.iter().map(|e| e.1).collect();
let slot = match (0..TRIM_SLOTS).find(|i| !used.contains(i)) {
Some(free) => free,
None => {
let (old, slot) = self.trim_slots.remove(0);
self.gui.atlas.sprites.remove(&old);
slot
}
};
let Some(area) = self
.gui
.atlas
.sprites
.get(&format!("horizon/trim{slot}"))
.copied()
else {
continue;
};
if w > area.w || h > area.h {
continue;
}
let (aw, ah) = (self.gui.atlas.width as f32, self.gui.atlas.height as f32);
let (x, y) = ((area.u0 * aw).round(), (area.v0 * ah).round());
self.map_patches.push(horizon_client::font::Patch {
x: x as u32,
y: y as u32,
width: w,
height: h,
rgba,
});
let rect = horizon_client::hud::Rect {
u0: area.u0,
v0: area.v0,
u1: (x + w as f32) / aw,
v1: (y + h as f32) / ah,
w,
h,
};
self.gui.atlas.sprites.insert(name.clone(), rect);
self.trim_slots.push((name, slot));
}
}
/// `ArmorStandItem.useOn`.
pub(super) fn place_armor_stand(&mut self, hit: &super::Hit, stack: &ItemStack) -> bool {
if &*stack.item != "minecraft:armor_stand" || hit.face == [0, -1, 0] {
@@ -564,6 +717,22 @@ impl Game<'_> {
0,
out,
);
// EquipmentLayerRenderer: the trim layer over the armour.
if let Some(trim) = assets
.trim_texture(&asset, kind, stack)
.and_then(|t| uv_of(&t.sprite()))
{
horizon_client::mobs::render::draw(
part,
base,
&layer_poses,
trim,
0xFFFF_FFFF,
packed,
0,
out,
);
}
}
self.cull_entity_vertices(out, entity_start);
}
+49 -21
View File
@@ -294,6 +294,18 @@ impl Game<'_> {
}
// ---- entities -------------------------------------------------------
/// The level `t` is in (`Entity.level`): a server's players have their
/// own, which need not be the one in place; everything else is in the
/// level in place.
pub(super) fn target_level(&self, t: Target) -> Dim {
let conn = match t {
Target::Player => self.lan_acting,
Target::Guest(conn) => Some(conn),
_ => None,
};
conn.and_then(|c| self.command_guest(c))
.map_or(self.dim, |g| Dim::from_id(&g.dimension))
}
pub(super) fn target_position(&self, t: Target) -> Option<[f64; 3]> {
match t {
Target::Player => self
@@ -1191,15 +1203,12 @@ impl Game<'_> {
pos: [f64; 3],
rotation: Option<[f32; 2]>,
) -> bool {
let (conn, at) = match t {
Target::Player => (self.lan_acting, self.dim),
Target::Guest(conn) => (
Some(conn),
self.command_guest(conn)
.map_or(self.dim, |g| super::Dim::from_id(&g.dimension)),
),
_ => (None, self.dim),
let conn = match t {
Target::Player => self.lan_acting,
Target::Guest(conn) => Some(conn),
_ => None,
};
let at = self.target_level(t);
let Some(conn) = conn.filter(|_| at != level && self.options.dedicated.is_some()) else {
return false;
};
@@ -1222,12 +1231,7 @@ impl Game<'_> {
let dest = self.entities(ctx, "destination", false)?[0];
let pos = self.target_position(dest).unwrap_or(self.source_position());
// Into the destination's level.
let level = match dest {
Target::Guest(conn) => self
.command_guest(conn)
.map_or(self.dim, |g| super::Dim::from_id(&g.dimension)),
_ => self.dim,
};
let level = self.target_level(dest);
// TeleportCommand.teleportToEntity: the destination's rotation too.
let rotation = Some(self.target_rotation(dest));
for &t in &targets {
@@ -2281,12 +2285,23 @@ impl Game<'_> {
};
let yaw = ((rot[0] + 180.0).rem_euclid(360.0)) - 180.0;
let pitch = rot[1].clamp(-90.0, 90.0);
self.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
self.respawn_config = None;
// SetSpawnCommand: a forced point in the source's level.
if self.dim == Dim::Overworld {
self.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
self.respawn_config = None;
} else {
self.spawn = None;
self.respawn_config = Some(super::respawn_anchor::RespawnConfig {
dim: self.dim,
pos,
yaw,
forced: true,
});
}
let dim = Text::literal(self.dim.id());
let p = pos.map(|v| Text::literal(v.to_string()));
let mut args = vec![
@@ -2322,7 +2337,20 @@ impl Game<'_> {
Some(Arg::Coords(c)) => c.rotation(self.source_rotation()),
_ => [0.0, 0.0],
};
self.level.spawn = Some([f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])]);
// LevelData.setRespawnData: the world's spawn (level.dat `spawn`).
let old = self.level.world_spawn.replace(pos);
self.level.world_spawn_angle = rot;
// A player whose fallback is the world spawn (no spawnpoint of its
// own; a bed or an anchor that is gone falls back to it) respawns at
// the new one.
let floor = |s: Vec3| [s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32];
if self.spawn.map(floor) == old {
self.spawn = Some(Vec3::new(
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
));
}
let args = [
Text::literal(pos[0].to_string()),
Text::literal(pos[1].to_string()),
@@ -155,14 +155,20 @@ impl Game<'_> {
_ => s.pos,
}
}
/// Evaluate `f` with `source` as the command source.
/// Evaluate `f` with `source` as the command source, in the source's
/// level (`CommandSourceStack.getLevel`): a server puts that level in
/// place for it.
pub(in crate::game) fn with_source<T>(
&mut self,
source: Source,
f: impl FnOnce(&mut Self) -> T,
) -> T {
let previous = self.command_source.replace(source);
let back = self.enter_command_level(source.dimension);
let out = f(self);
if let Some(back) = back {
self.enter_command_level(back);
}
self.command_source = previous;
out
}
@@ -447,11 +453,13 @@ impl Game<'_> {
"at" => targets(self)?
.into_iter()
.filter_map(|t| {
// CommandSourceStack.withLevel(entity.level()).
let level = self.target_level(t);
Some(Source {
pos: self.target_position(t)?,
rot: self.target_rotation(t),
dimension: self.dim,
level: None,
dimension: level,
level: (level != self.dim).then_some(level),
..s
})
})
@@ -810,7 +810,10 @@ impl Game<'_> {
None => break,
},
};
if self.slot_get(slot) != item && self.slot_set(slot, item) {
// ItemStack.isEmpty: air (or a count of 0) is an empty slot.
// SlotAccess.set counts every slot it sets, the same item too.
let item = item.filter(|s| &*s.item != "minecraft:air" && s.count > 0);
if self.slot_set(slot, item) {
changed += 1;
}
}
+34
View File
@@ -42,6 +42,7 @@ const HELP: &[&str] = &[
"chat MESSAGE send chat or a /command to the server",
"disconnect leave the server for the server list",
"respawn respawn after death on a server",
"credits [poem] [SCROLL] roll the credits (with the end poem), or hold them SCROLL pixels in",
"quit save and exit",
];
@@ -117,6 +118,7 @@ impl Game<'_> {
self.answer(request, event_loop);
}
while let Some(request) = self.control.as_ref().and_then(|c| c.try_recv()) {
self.agent_command_seen();
self.answer(request, event_loop);
if self.pending_save.is_some() {
break;
@@ -125,6 +127,11 @@ impl Game<'_> {
if self.control.is_some() {
self.watch_events();
}
if self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::Agent
{
self.ui.options.agent_status = self.agent_status_lines();
}
}
fn answer(&mut self, request: horizon_client::control::Request, event_loop: &ActiveEventLoop) {
@@ -568,7 +575,9 @@ impl Game<'_> {
"graphics" => Page::Graphics,
"graphics-advanced" => Page::GraphicsAdvanced,
"extras" => Page::Extras,
"credits" => Page::Credits,
"horizon" => Page::Horizon,
"agent" => Page::Agent,
"shader-packs" => Page::ShaderPacks,
other => return Err(format!("unknown options page {other}").into()),
};
@@ -645,6 +654,25 @@ impl Game<'_> {
self.connect_server(arg(1)?, &name)?;
ok()
}
"credits" => {
let poem = words.get(1) == Some(&"poem");
let hold = words
.get(1 + usize::from(poem))
.map(|s| s.parse::<f32>())
.transpose()?;
if self.screen != Screen::Win {
let finish = if self.screen == Screen::Options {
super::win::Finish::Options
} else {
super::win::Finish::Closed
};
self.open_win_screen(poem, finish);
}
if let Some(scroll) = hold {
self.hold_win_screen(scroll);
}
ok()
}
"respawn" => {
if self.remote.is_none() || self.screen != Screen::Dead {
return Err("respawn needs the death screen on a server".into());
@@ -727,6 +755,9 @@ impl Game<'_> {
}
fn screen_name(&self) -> &'static str {
if self.screen == Screen::Win {
return "win";
}
if self.title.is_some() {
return "title";
}
@@ -744,6 +775,7 @@ impl Game<'_> {
Screen::WorldOptions => "world-options",
Screen::Friends => "friends",
Screen::CommandBlock => "command-block",
Screen::Win => "win",
}
}
@@ -854,6 +886,8 @@ impl Game<'_> {
"source_attempted": self.lod_source_attempted,
"active_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().0),
"cached_tiles": self.lod.as_ref().map_or(0, |lod| lod.stats().1),
"far_detail": self.settings.horizon.far_detail.name(),
"resident_quads": self.renderer.as_ref().map_or(0, |r| r.lod_quads()),
"pending_observations": self.lod_observe.len(),
"refined_samples": self.lod.as_ref().map_or(0, |lod| lod.refined_samples()),
});
@@ -343,8 +343,12 @@ impl Game<'_> {
"on_ground": m.on_ground,
"variant": m.extra.variant,
"color": m.extra.color,
"sheared": m.extra.sheared,
"burning": m.fire_ticks > 0,
"size": m.extra.size,
"leash": m.extra.leash.map(|h| format!("{h:?}")),
"tame": m.extra.tame,
"sitting": m.extra.sitting,
"temper": m.extra.variant3,
"chested": m.extra.chested,
"head": m.extra.head.as_deref(),
@@ -291,12 +291,12 @@ impl Game<'_> {
used = self.fixture_use_item(origin, op)?;
} else if let Some(op) = case.get("respawn_at") {
// ServerPlayer.findRespawnAndUseSpawnBlock for a respawn point at the origin.
let config = super::respawn_anchor::RespawnConfig {
dim: self.dim,
pos: [origin.x, origin.y, origin.z],
yaw: 0.0,
forced: op["forced"].as_bool().unwrap_or(false),
};
let config = super::respawn_anchor::RespawnConfig::wrapped(
self.dim,
[origin.x, origin.y, origin.z],
op["yaw"].as_f64().map_or(0.0, |v| v as f32),
op["forced"].as_bool().unwrap_or(false),
);
let found =
self.find_respawn_and_use(config, op["consume"].as_bool().unwrap_or(true))?;
used.push(match found {
+3
View File
@@ -2697,9 +2697,12 @@ impl Game<'_> {
velocity: [d.velocity.x, d.velocity.y, d.velocity.z],
data: 0,
meta,
defaults: Vec::new(),
equipment: Vec::new(),
attributes: Vec::new(),
precise: false,
needs_sync: false,
leash: None,
})
}
}
@@ -8,6 +8,15 @@ use super::Game;
use horizon_client::{enchant::EffectContext, items::ItemStack};
use horizon_core::random::RandomSource;
/// Where Mending found a damaged item (`EnchantedItemInUse.inSlot`).
#[derive(Clone, Copy)]
enum MendSlot {
Main,
Off,
/// An `armor` index (head first).
Armor(usize),
}
impl Game<'_> {
/// `Player.getDestroySpeed` after the tool speed: mining efficiency, haste
/// and mining fatigue.
@@ -314,36 +323,29 @@ impl Game<'_> {
pub(super) fn mending(&mut self, amount: u32) -> u32 {
let mut amount = amount;
loop {
// EnchantmentHelper.getRandomItemWith(REPAIR_WITH_XP, damaged): held and armor.
let mut candidates: Vec<(bool, usize)> = Vec::new();
if let Some(s) = &self.inventory[self.selected]
&& s.damage > 0
&& s.enchantment_level("minecraft:mending") > 0
{
candidates.push((false, self.selected));
}
for (i, s) in self.armor.iter().enumerate() {
if let Some(s) = s
&& s.damage > 0
&& s.enchantment_level("minecraft:mending") > 0
{
candidates.push((true, i));
}
}
// EnchantmentHelper.getRandomItemWith(REPAIR_WITH_XP, damaged), in
// EquipmentSlot order: the main hand, the off hand, then feet, legs,
// chest and head (`armor` holds head first).
let mending = |s: &Option<ItemStack>| {
s.as_ref()
.is_some_and(|s| s.damage > 0 && s.enchantment_level("minecraft:mending") > 0)
};
let candidates: Vec<MendSlot> = [MendSlot::Main, MendSlot::Off]
.into_iter()
.chain((0..self.armor.len()).rev().map(MendSlot::Armor))
.filter(|&slot| mending(self.mend_slot(slot)))
.collect();
if candidates.is_empty() {
return amount;
}
let (armor, i) = candidates[self
let slot = candidates[self
.world
.random
.next_int_bound(candidates.len() as i32)
.unwrap_or(0) as usize];
let stack = if armor {
self.armor[i].clone()
} else {
self.inventory[i].clone()
let Some(stack) = self.mend_slot(slot).clone() else {
return amount;
};
let Some(stack) = stack else { return amount };
let ctx = EffectContext {
items: Some(self.res.items()),
..Default::default()
@@ -356,12 +358,7 @@ impl Game<'_> {
amount as f32,
) as u32;
let repaired = to_repair_xp.min(stack.damage);
let slot = if armor {
&mut self.armor[i]
} else {
&mut self.inventory[i]
};
if let Some(s) = slot.as_mut() {
if let Some(s) = self.mend_slot_mut(slot).as_mut() {
s.damage -= repaired;
}
if repaired == 0 || to_repair_xp == 0 {
@@ -374,6 +371,20 @@ impl Game<'_> {
amount = remaining;
}
}
fn mend_slot(&self, slot: MendSlot) -> &Option<ItemStack> {
match slot {
MendSlot::Main => &self.inventory[self.selected],
MendSlot::Off => &self.offhand,
MendSlot::Armor(i) => &self.armor[i],
}
}
fn mend_slot_mut(&mut self, slot: MendSlot) -> &mut Option<ItemStack> {
match slot {
MendSlot::Main => &mut self.inventory[self.selected],
MendSlot::Off => &mut self.offhand,
MendSlot::Armor(i) => &mut self.armor[i],
}
}
/// Bow enchantments at release: (extra base damage, infinity keeps the arrow).
pub(super) fn bow_enchantments(&mut self) -> (f64, bool) {
let Some(bow) = self.inventory[self.selected].clone() else {
+40 -12
View File
@@ -23,7 +23,7 @@ const GATEWAY_FILE: &str = "horizon-end-gateways.json";
/// A gateway teleport waiting for its destination chunks.
#[derive(Clone, Copy, Debug)]
enum GatewayJob {
pub(super) enum GatewayJob {
/// TheEndGatewayBlockEntity.findOrCreateValidTeleportPos from this gateway.
Find { gateway: [i32; 3] },
/// A known exit (exact, or findExitPosition around it).
@@ -342,7 +342,10 @@ impl Game<'_> {
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
);
if portal {
if portal && self.dim == Dim::End && !self.level.seen_credits {
// EndPortalBlock.entityInside: the first exit shows the credits.
self.show_end_credits()?;
} else if portal {
if self.portals.player.cooldown == 0 {
let target = if self.dim == Dim::End {
Dim::Overworld
@@ -413,7 +416,15 @@ impl Game<'_> {
f64::from(END_SPAWN[2]) + 0.5,
));
}
// Leaving the End: the respawn position (the world spawn here).
// Leaving the End: the respawn position, a bed's found once it has
// loaded (findRespawnPositionAndUseSpawnBlock, not using it up).
if let Some(c) = self.respawn_config.filter(|c| c.dim == target) {
return Some(Vec3::new(
f64::from(c.pos[0]) + 0.5,
f64::from(c.pos[1]),
f64::from(c.pos[2]) + 0.5,
));
}
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
Some(Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5))
}
@@ -484,7 +495,16 @@ impl Game<'_> {
// Direction.WEST.toYRot().
return Ok((target, Some(90.0)));
}
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: onto the spawn column.
// ServerPlayer.findRespawnPositionAndUseSpawnBlock: where the bed or
// anchor puts the player, facing it; else onto the spawn column.
let mut target = target;
if let Some(c) = self.respawn_config.filter(|c| c.dim == self.dim) {
if let Some((at, yaw)) = self.find_respawn_and_use(c, false)? {
return Ok((at, Some(yaw)));
}
let s = self.level.spawn.unwrap_or([0.0, 64.0, 0.0]);
target = Vec3::new(s[0].floor() + 0.5, s[1], s[2].floor() + 0.5);
}
let y = self
.land_column(target.x.floor() as i32, target.z.floor() as i32)
.map_or(target.y, f64::from);
@@ -700,6 +720,20 @@ impl Game<'_> {
fn enter_gateway(&mut self, gateway: [i32; 3]) -> Result<()> {
// TheEndGatewayBlockEntity.triggerCooldown: 40 ticks.
self.end.gateway_cooldown = 40;
let (job, destination) = self.gateway_job(gateway);
let Some(player) = self.player.take() else {
return Ok(());
};
self.end.job = Some(job);
self.end_travel = true;
self.travelling = Some((player, destination));
Ok(())
}
/// Where `gateway` leads (TheEndGatewayBlockEntity.getPortalPosition):
/// its exit, or the search for one, and the spot to load before either
/// is resolved ([`Game::resolve_gateway`]).
pub(super) fn gateway_job(&mut self, gateway: [i32; 3]) -> (GatewayJob, Vec3) {
let known = self.gateway_exits().get(&gateway).copied();
let job = match known {
Some((exit, exact)) => GatewayJob::Known { exit, exact },
@@ -725,13 +759,7 @@ impl Game<'_> {
Vec3::new(d.x, 75.0, d.y)
}
};
let Some(player) = self.player.take() else {
return Ok(());
};
self.end.job = Some(job);
self.end_travel = true;
self.travelling = Some((player, destination));
Ok(())
(job, destination)
}
fn chunk_empty(&self, x: f64, z: f64) -> Option<bool> {
@@ -768,7 +796,7 @@ impl Game<'_> {
/// The teleport position of a gateway job, creating the exit gateway (and
/// an island when the chunk has no landing spot) for new destinations.
fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
pub(super) fn resolve_gateway(&mut self, job: GatewayJob, target: Vec3) -> Result<Vec3> {
let bottom_center = |p: [i32; 3]| {
Vec3::new(
f64::from(p[0]) + 0.5,
+29 -1
View File
@@ -224,7 +224,7 @@ impl Game<'_> {
}
set.entries().map(|(k, _)| k.0).collect()
}
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT);
/// `ServerExplosion.hurtEntities` (player, mobs, items, primed TNT, minecarts);
/// the player takes `damage_type`.
/// `ServerExplosion.hurtEntities` for the player: damage scaled by
/// distance and exposure, through a raised shield (`hurtServer` blocks
@@ -460,6 +460,34 @@ impl Game<'_> {
);
}
self.tnts = tnts;
// Minecarts: VehicleEntity.hurtServer (explosions ignite TNT carts),
// then the push from their eyes (0.85 of their height).
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
for id in ids {
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
continue;
};
let bounds = self.carts[i].bounds();
let feet = self.carts[i].position;
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
if dist > 1.0 {
continue;
}
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
self.hurt_cart(i, damage(dist, exposure), false, true);
let Some(c) = self.carts.iter_mut().find(|c| c.id == id) else {
continue;
};
let eye_height = f64::from((hi.y - lo.y) as f32 * 0.85f32);
let dir = direction([feet.x, feet.y + eye_height, feet.z]);
let power = (1.0 - dist) * f64::from(exposure);
c.velocity = Vec3::new(
c.velocity.x + dir[0] * power,
c.velocity.y + dir[1] * power,
c.velocity.z + dir[2] * power,
);
}
}
/// `Level.explode` on the server: positions, entities, blocks, fire; then
/// the client's explosion sound.
+6 -3
View File
@@ -6,9 +6,12 @@
//! the world's options) with [`Integrated::call`], as vanilla's client calls
//! into its `IntegratedServer`.
//!
//! On by default. `--no-server-thread` or `HORIZON_SERVER_THREAD=0` runs
//! the world in the frame instead (the escape hatch while the network
//! player's side catches up with the in-frame one).
//! 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.
use super::{
Game, Options, PackResources, Resources, UiResources,
lan::{LanSettings, Owner},
+62 -13
View File
@@ -14,7 +14,7 @@ use horizon_client::{
hub::{HostEvent, HostState, Hub, HubConfig, TickCtx},
playerdata,
},
mobs::Mob,
mobs::{Mob, leash::Holder},
text::Text,
};
use horizon_core::geometry::Vec3;
@@ -91,7 +91,7 @@ pub(super) fn entity_uuid(id: u32) -> u128 {
/// The SynchedEntityData index of the baby flag: AgeableMob.DATA_BABY_ID and
/// Zombie.DATA_BABY_ID are both the first entry after Mob's (16); Piglin's
/// comes after AbstractPiglin's zombification flag (17).
fn baby_index(kind: &str) -> Option<u8> {
pub(super) fn baby_index(kind: &str) -> Option<u8> {
match kind {
"minecraft:piglin" => Some(17),
"minecraft:cow"
@@ -139,7 +139,13 @@ fn baby_index(kind: &str) -> Option<u8> {
/// A mob's synched data as ServerEntity sends it (only the values that differ
/// from the defaults the client constructs), with vanilla's entry indices:
/// Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses.
fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
/// `defaults` gets the defaults of the entries that can go back to theirs.
fn mob_meta(
m: &Mob,
hub: &Hub,
meta: &mut Vec<(u8, i32, Value)>,
defaults: &mut Vec<(u8, i32, Value)>,
) {
let kind = m.kind.id.as_str();
if m.fire_ticks > 0 {
meta.push((0, serializer::BYTE, Value::Byte(1)));
@@ -159,15 +165,18 @@ fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
if health != 1.0 {
meta.push((9, serializer::FLOAT, Value::Float(health)));
}
defaults.push((9, serializer::FLOAT, Value::Float(1.0)));
// Mob.DATA_MOB_FLAGS_ID: no AI 1, left-handed 2.
let flags = i8::from(m.no_ai) | i8::from(m.extra.left_handed) << 1;
if flags != 0 {
meta.push((15, serializer::BYTE, Value::Byte(flags)));
}
if m.is_baby()
&& let Some(index) = baby_index(kind)
{
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
// AgeableMob.DATA_BABY_ID (and Zombie's, Piglin's).
if let Some(index) = baby_index(kind) {
if m.is_baby() {
meta.push((index, serializer::BOOLEAN, Value::Bool(true)));
}
defaults.push((index, serializer::BOOLEAN, Value::Bool(false)));
}
let e = &m.extra;
let id = |registry: &str, name: &str| hub.registry_id(registry, &format!("minecraft:{name}"));
@@ -178,6 +187,7 @@ fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
if value != 0 {
meta.push((18, serializer::BYTE, Value::Byte(value as i8)));
}
defaults.push((18, serializer::BYTE, Value::Byte(0)));
}
"minecraft:cat" | "minecraft:wolf" => {
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
@@ -200,6 +210,13 @@ fn mob_meta(m: &Mob, hub: &Hub, meta: &mut Vec<(u8, i32, Value)>) {
if let Some(v) = id(registry, name) {
meta.push((index, serializer, Value::Id(v)));
}
// Wolf.DATA_COLLAR_COLOR and Cat's, red by default.
let collar = if kind == "minecraft:cat" { 23 } else { 21 };
let red = i32::from(horizon_client::mobs::tame::RED);
if i32::from(e.color & 15) != red {
meta.push((collar, serializer::INT, Value::Int(i32::from(e.color & 15))));
}
defaults.push((collar, serializer::INT, Value::Int(red)));
}
"minecraft:horse"
| "minecraft:donkey"
@@ -390,9 +407,14 @@ impl Game<'_> {
"placed": g.placed,
"loaded": g.loaded,
"game_mode": g.game_mode,
// ItemCooldowns: the ticks the shield an axe disabled stays down.
"shield_cooldown": h.play.get(&g.conn).map_or(0, |p| p.shield_cooldown),
"sneaking": g.sneaking,
"sleeping": g.bed,
"health": g.health,
// The server's body: its air supply and golden hearts.
"air": g.motion.as_ref().map(|m| m.air),
"absorption": g.motion.as_ref().map(|m| m.health.absorption()),
// FoodData: food level, saturation and exhaustion.
"food": [
f64::from(g.food),
@@ -752,17 +774,37 @@ impl Game<'_> {
}
/// The entities guests track: mobs, items and the rest (game/lan_entities.rs).
fn lan_entities(&self, host_entity: Option<i32>, hub: &mut Hub, out: &mut States) {
/// `mob_players` is the guest each mob sees as its player (game/lan_mobs.rs).
fn lan_entities(
&self,
host_entity: Option<i32>,
mob_players: &HashMap<u32, u32>,
hub: &mut Hub,
out: &mut States,
) {
out.clear();
for m in self.mobs.iter().filter(|m| !m.removed) {
// The lead's holder: the player the mob sees (a guest, else the
// host) or another mob. A fence knot isn't an entity here.
let leash = m.extra.leash.and_then(|h| match h {
Holder::Player => mob_players
.get(&m.id)
.and_then(|conn| hub.guest(*conn))
.map(|g| g.entity_id)
.or(host_entity),
Holder::Mob(id) => Some(id as i32),
Holder::Knot(_) => None,
});
let e = out.add(m.id as i32, entity_uuid(m.id), m.kind.id.as_str());
e.position = [m.position.x, m.position.y, m.position.z];
(e.yaw, e.pitch, e.head_yaw) = (m.yaw, m.head_pitch, m.head_yaw);
e.on_ground = m.on_ground;
e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z];
mob_meta(m, hub, &mut e.meta);
e.needs_sync = m.needs_sync;
mob_meta(m, hub, &mut e.meta, &mut e.defaults);
mob_equipment(m, hub, &mut e.equipment);
self.mob_attributes(m, &mut e.attributes);
e.leash = leash;
}
for d in &self.dropped {
let stack = hub.stack(&Some(d.stack.clone()));
@@ -824,10 +866,14 @@ impl Game<'_> {
.filter_map(|g| Some((g.vehicle?, g.entity_id)))
.collect();
self.lan_riding(host_entity, &guests, &mut host.hub.riding);
self.lan_entities(host_entity, &mut host.hub, &mut states);
self.lan_entities(host_entity, &host.mob_players, &mut host.hub, &mut states);
} else {
states.clear();
}
// ServerEntity.sendChanges clears needsSync once it has looked.
for m in &mut self.mobs {
m.needs_sync = false;
}
let entities = states.as_slice();
let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]);
let events = {
@@ -1008,7 +1054,7 @@ impl Game<'_> {
return;
}
// A dedicated server has no host player.
self.lan_entities(None, &mut host.hub, &mut theirs);
self.lan_entities(None, &host.mob_players, &mut host.hub, &mut theirs);
theirs.as_slice()
} else {
entities
@@ -1243,7 +1289,7 @@ impl Game<'_> {
};
let atlas = &self.gui.atlas;
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
for m in host.models.values() {
for (conn, m) in &host.models {
let entity_start = out.len();
let light = self.world.light(
m.position.x.floor() as i32,
@@ -1258,7 +1304,10 @@ impl Game<'_> {
&lookup,
packed,
self.res.mob_data(),
&self.worn_armor_layers(m),
&host.hub.guest(*conn).map_or_else(
|| self.worn_armor_layers(m),
|g| self.stack_armor_layers(&g.armor),
),
out,
);
self.held_item_geometry(m, camera, alpha, packed, true, out);
+129 -11
View File
@@ -5,7 +5,7 @@
//! blocks, attacking and the commands all behave exactly as they do for the
//! host, and every world change they cause reaches all players through the
//! block log, the level events and the entity tracker.
use super::{Game, GuestUse, Hit, Mining, Screen, lan::LanHost};
use super::{Game, GuestUse, Hit, Mining, Screen, lan::LanHost, respawn_anchor::RespawnConfig};
use horizon_client::{
command::vanilla,
containers::BlockEntity,
@@ -34,6 +34,8 @@ pub(super) struct GuestPlay {
destroy_delay: u32,
using: Option<(usize, u32)>,
use_delay: u32,
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
pub(super) shield_cooldown: u32,
attack_ticks: u32,
/// The main hand's item at the guest's last tick (`Game::attack_item`).
attack_item: Option<std::sync::Arc<str>>,
@@ -117,6 +119,7 @@ pub(super) struct GuestSwap {
destroy_delay: u32,
using: Option<(usize, u32)>,
use_delay: u32,
pub(super) shield_cooldown: u32,
attack_ticks: u32,
attack_item: Option<std::sync::Arc<str>>,
swing: horizon_client::first_person::SwingState,
@@ -217,6 +220,18 @@ impl Game<'_> {
host.hub.guests[i].position[2],
);
host.hub.guests[i].motion = PlayerMotion::new(self.motion, p).ok().map(Box::new);
// The first body after joining burns, breathes and falls on as saved.
if let Some(saved) = host.hub.guests[i].loaded_body.take()
&& let Some(m) = host.hub.guests[i].motion.as_mut()
{
saved.apply(m);
}
// The saved golden hearts go on the first body of a player who
// has been here before.
let saved = host.hub.guests[i].saved_absorption.take();
if let (Some(m), Some(amount)) = (host.hub.guests[i].motion.as_mut(), saved) {
let _ = m.internal_set_absorption(amount);
}
let kept = host.hub.guests[i].logs_kept.take();
if let (Some(m), Some((hurts, attacks))) = (host.hub.guests[i].motion.as_mut(), kept) {
m.hurt_log = hurts;
@@ -290,6 +305,7 @@ impl Game<'_> {
let destroy_delay = std::mem::replace(&mut self.destroy_delay, play.destroy_delay);
let using = std::mem::replace(&mut self.using, play.using.take());
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
let shield_cooldown = std::mem::replace(&mut self.shield_cooldown, play.shield_cooldown);
let attack_ticks = std::mem::replace(&mut self.attack_ticks, play.attack_ticks);
let attack_item = std::mem::replace(&mut self.attack_item, play.attack_item.take());
let swing = std::mem::replace(&mut self.swing, play.swing);
@@ -309,6 +325,7 @@ impl Game<'_> {
g.spawn.map(|s| Vec3::new(s[0], s[1], s[2])),
);
let respawn = (self.respawn_config.take(), self.level.spawn);
self.respawn_config = g.respawn_elsewhere.as_ref().map(RespawnConfig::from_saved);
let message = self.message.take();
// The guest's own ender inventory; one the host has open goes back
// into the host's while the guest acts.
@@ -369,6 +386,7 @@ impl Game<'_> {
destroy_delay,
using,
use_delay,
shield_cooldown,
attack_ticks,
attack_item,
swing,
@@ -421,6 +439,7 @@ impl Game<'_> {
destroy_delay,
using,
use_delay,
shield_cooldown,
attack_ticks,
attack_item,
swing,
@@ -470,7 +489,8 @@ impl Game<'_> {
let bed = std::mem::replace(&mut self.sleeping, sleeping);
let guest_sleep_timer = std::mem::replace(&mut self.sleep_timer, sleep_timer);
let guest_spawn = std::mem::replace(&mut self.spawn, spawn).map(|s| [s.x, s.y, s.z]);
(self.respawn_config, self.level.spawn) = respawn;
let guest_respawn = std::mem::replace(&mut self.respawn_config, respawn.0);
self.level.spawn = respawn.1;
// ServerPlayer.displayClientMessage(_, true): the guest's action bar.
let guest_message = std::mem::replace(&mut self.message, message);
let guest_vehicle = std::mem::replace(&mut self.riding, riding);
@@ -480,6 +500,7 @@ impl Game<'_> {
play.destroy_delay = std::mem::replace(&mut self.destroy_delay, destroy_delay);
play.using = std::mem::replace(&mut self.using, using);
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
play.shield_cooldown = std::mem::replace(&mut self.shield_cooldown, shield_cooldown);
play.attack_ticks = std::mem::replace(&mut self.attack_ticks, attack_ticks);
play.attack_item = std::mem::replace(&mut self.attack_item, attack_item);
play.swing = std::mem::replace(&mut self.swing, swing);
@@ -507,6 +528,7 @@ impl Game<'_> {
g.vehicle = guest_vehicle;
let fell_asleep = bed.is_some() && g.bed.is_none();
(g.bed, g.sleep_timer, g.spawn) = (bed, guest_sleep_timer, guest_spawn);
g.respawn_elsewhere = guest_respawn.map(|c| c.saved());
g.sleeping = g.bed.is_some();
if let Some((text, _)) = guest_message {
g.send(&C::SystemChat(play::SystemChat {
@@ -1486,17 +1508,43 @@ impl Game<'_> {
if !host.hub.guests[i].dead {
return;
}
// PlayerList.respawn: a server's player comes back in the Overworld,
// where the world spawn is (respawn anchors and beds aside).
// PlayerList.respawn: a server's player comes back in its respawn
// point's level: the Overworld, where the world spawn and beds are,
// or another one's anchor or forced point, which its block resolves
// once the level has loaded around it.
let server = self.options.dedicated.is_some();
let own = host.hub.guests[i]
.respawn_elsewhere
.as_ref()
.map(RespawnConfig::from_saved);
let elsewhere = own.filter(|_| server);
// In the window's world a guest has no level of its own to wait in:
// its bed is resolved at once when its chunk is there, else it
// comes back on the bed.
let in_frame_bed = own.filter(|c| !server && c.dim == super::Dim::Overworld);
let spawn = in_frame_bed.map_or(spawn, |c| {
[
f64::from(c.pos[0]) + 0.5,
f64::from(c.pos[1]) + 0.6,
f64::from(c.pos[2]) + 0.5,
]
});
let level = elsewhere.map_or(super::Dim::Overworld, |c| c.dim);
let spawn = elsewhere.map_or(spawn, |c| {
[
f64::from(c.pos[0]) + 0.5,
f64::from(c.pos[1]),
f64::from(c.pos[2]) + 0.5,
]
});
let back = self.dim;
let away = self.options.dedicated.is_some()
&& host.hub.guests[i].dimension != super::Dim::Overworld.id();
if away {
let away = server && host.hub.guests[i].dimension != level.id();
if server {
self.server_trips.retain(|t| !t.is(conn));
if let Err(e) = self.enter_level(Some(host), super::Dim::Overworld) {
log::error!("could not open the Overworld: {e}");
return;
}
}
if away && let Err(e) = self.enter_level(Some(host), level) {
log::error!("could not open {}: {e}", level.id());
return;
}
let g = &mut host.hub.guests[i];
g.dead = false;
@@ -1535,6 +1583,28 @@ impl Game<'_> {
collision: Some(self.collision),
};
host.hub.respawn(i, spawn, &ctx);
if let Some(config) = elsewhere {
self.respawn_trip(conn, config);
}
if let Some(c) = in_frame_bed
&& self.dim == super::Dim::Overworld
&& self.world.loaded(c.pos[0], c.pos[2])
{
let found = self
.as_guest(host, conn, |g| g.resolve_server_respawn(c))
.flatten();
let world = self
.level
.world_spawn
.map_or([0.5, 80.0, 0.5], |[x, y, z]| {
[f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5]
});
let at = found.map_or(world, |(at, yaw)| {
host.hub.guests[i].yaw = yaw;
[at.x, at.y, at.z]
});
host.hub.teleport(i, Some(at));
}
if away && let Err(e) = self.enter_level(Some(host), back) {
log::error!("could not reopen {}: {e}", back.id());
}
@@ -1562,6 +1632,9 @@ impl Game<'_> {
continue;
}
let play = host.play.entry(conn).or_default();
// Player.tick's ItemCooldowns.tick, once a tick for each player
// (the shield an axe disabled).
play.shield_cooldown = play.shield_cooldown.saturating_sub(1);
let digging = play.digging;
let using = play.using.is_some();
let delayed = play.destroy_delay > 0 || play.use_delay > 0 || play.attack_ticks < 100;
@@ -1616,6 +1689,9 @@ impl Game<'_> {
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);
}
let g = &mut host.hub.guests[gi];
if g.health <= 0.0 && !g.dead {
g.dead = true;
@@ -1765,6 +1841,7 @@ impl Game<'_> {
}
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
let at = g.position;
let sneaking = g.sneaking;
let sounds = self.as_guest(host, conn, |game| {
let (rules, motion) = (game.world.health_rules, game.motion);
let health = &motion.health;
@@ -1783,6 +1860,26 @@ impl Game<'_> {
let _ = w.fluid_base_tick();
w.player.ground.on_ground = from_ground;
let travelled: Vec<[f64; 3]> = moves.iter().map(|m| m.0).collect();
// Entity.move's movementThisTick: each move, from where the
// tick's first one started.
let end = w.player.position;
let start = travelled
.iter()
.fold(end, |p, d| p.subtract(Vec3::new(d[0], d[1], d[2])));
w.player.history.clear();
let mut from = start;
for d in &travelled {
let delta = Vec3::new(d[0], d[1], d[2]);
let to = from.add_vector(delta);
w.player
.history
.add(horizon_world::entity_move::MovementRecord {
from,
to,
original: Some(delta),
});
from = to;
}
for (moved, on_ground) in moves {
let movement = Vec3::new(moved[0], moved[1], moved[2]);
if w.player.ground.on_ground && !on_ground && moved[1] > 0.0 {
@@ -1808,6 +1905,10 @@ impl Game<'_> {
if w.climbable().unwrap_or(false) {
w.player.fall_distance = 0.0;
}
// Entity.applyEffectsFromBlocks along those moves: magma, cactus,
// lit campfires and grown sweet berry bushes hurt (#1305).
w.player.shift_key_down = sneaking;
let _ = w.server_block_damage(start);
let _ = w.tick_environment_damage();
let _ = w.apply_fire_contact();
let _ = w.server_hazards_tick(in_wall);
@@ -1968,6 +2069,23 @@ impl Game<'_> {
/// `level.destroyBlockProgress(id, pos, -1)` for a guest: its crack on `pos` is gone, for the
/// host and the other guests.
/// A player who dies digs and uses nothing any more: the block it was
/// breaking, or had left to break (`hasDelayedDestroy`), stays, and its
/// crack goes. Respawning gives it a new ServerPlayerGameMode (#1315).
fn guest_forget_digging(&mut self, host: &mut LanHost, conn: u32) {
let Some(play) = host.play.get_mut(&conn) else {
return;
};
let cracked = play.digging.take().filter(|_| play.last_progress >= 0);
play.mining = None;
play.delayed = None;
play.destroy_delay = 0;
play.using = None;
play.last_progress = -1;
if let Some((pos, _)) = cracked {
self.guest_crack_gone(host, conn, pos);
}
}
fn guest_crack_gone(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
let Some(id) = host.hub.guest(conn).map(|g| g.entity_id) else {
return;
@@ -36,9 +36,12 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) ->
velocity: [0.0; 3],
data: 0,
meta: Vec::new(),
defaults: Vec::new(),
equipment: Vec::new(),
attributes: Vec::new(),
precise: false,
needs_sync: false,
leash: None,
}
}
@@ -9,6 +9,10 @@ use horizon_client::{
},
};
/// The host player's connection id in the waypoint managers: its packets
/// go to this client's locator bar rather than to a guest.
const HOST_CONN: u32 = u32::MAX;
/// `waypoint_transmit_range`: hidden by crouching, invisibility or a head worn.
fn transmit_range(crouching: bool, invisible: bool, head: Option<&ItemStack>) -> f64 {
let hiding_head = head.is_some_and(|s| HIDING_HEADS.contains(&&*s.item));
@@ -47,7 +51,7 @@ impl Game<'_> {
self.dim.id(),
waypoints::Player {
uuid: h.uuid,
conn: None,
conn: Some(HOST_CONN),
position: h.position,
spectator: self.spectator(),
transmit_range: transmit_range(
@@ -123,7 +127,12 @@ impl Game<'_> {
}
}
for (conn, packet) in out {
if let Some(g) = host.hub.guest(conn) {
if conn == HOST_CONN {
// The host's own connection: its locator bar.
if let horizon_protocol::packets::play::Clientbound::TrackedWaypoint(w) = &packet {
self.handle_waypoint(w);
}
} else if let Some(g) = host.hub.guest(conn) {
g.send(&packet);
}
}
+21
View File
@@ -232,6 +232,9 @@ impl Game<'_> {
p.world.game_time = self.world.game_time;
p.world.day_time = self.world.day_time;
p.world.weather = self.world.weather;
// The game rules and the difficulty are the server's, not a
// level's: natural regeneration holds in every dimension.
p.world.health_rules = self.world.health_rules;
// The sculk tables' watched blocks are built once per game.
p.world.watched = std::mem::take(&mut self.world.watched);
self.swap_level(&mut p);
@@ -253,6 +256,24 @@ impl Game<'_> {
Ok(())
}
/// Put `dim`'s level in place for a command whose source is there
/// (`execute in`, `execute at` a player in another level), opening it if
/// nobody is in it; returns the level to put back afterwards. Only a
/// server holds other levels.
pub(super) fn enter_command_level(&mut self, dim: Dim) -> Option<Dim> {
if self.options.dedicated.is_none() || dim == self.dim {
return None;
}
let back = self.dim;
match self.enter_level(None, dim) {
Ok(()) => Some(back),
Err(e) => {
log::error!("could not open {}: {e}", dim.id());
None
}
}
}
/// One server tick (`MinecraftServer.tickServer`): each level with
/// players in turn (`tickChildren`), its chunks streamed and loaded and
/// its world ticked. The first pass reads the network and runs the
@@ -0,0 +1,126 @@
//! The locator bar on this client (horizon_client::locator_bar): the
//! waypoints a server tracks for the player (`handleWaypoint`; on a served
//! world the host's own connection in lan/waypoints.rs), the experience
//! bar's priority after a change, and the dots drawn from the last frame's
//! camera.
use super::Game;
use horizon_client::{
hud_layout::GuiDraw,
locator_bar::{ClientWaypoints, View},
};
use horizon_protocol::packets::play::TrackedWaypoint;
/// `Player.getEyeHeight` while standing.
const PLAYER_EYES: f64 = 1.62;
fn uuid(ints: [i32; 4]) -> u128 {
ints.iter()
.fold(0u128, |acc, &i| acc << 32 | u128::from(i as u32))
}
impl Game<'_> {
/// `ClientPacketListener.handleWaypoint`.
pub(super) fn handle_waypoint(&mut self, packet: &TrackedWaypoint) {
self.waypoints.apply(packet);
}
/// A new connection or world starts with no waypoints.
pub(super) fn clear_waypoints(&mut self) {
self.waypoints = ClientWaypoints::default();
}
/// `LocalPlayer.setExperienceValues`: a change of progress shows the
/// experience bar for 100 ticks, except the first one
/// (`experienceDisplayStartTick` starts at `Integer.MIN_VALUE`).
pub(super) fn track_experience_display(&mut self) {
let progress = self.experience.progress;
let (last, seen, _) = &mut self.experience_display;
if progress != *last {
*last = progress;
if *seen {
self.experience_display.2 = self.ticks;
} else {
*seen = true;
}
}
// EnchantmentScreen and AnvilScreen.containerTick keep it up.
if self.station.as_ref().is_some_and(|s| {
matches!(
s.kind,
super::stations::Kind::Enchanting | super::stations::Kind::Anvil
)
}) {
self.experience_display.2 = self.ticks;
}
}
/// `Hud.willPrioritizeExperienceInfo`.
pub(super) fn experience_recent(&self) -> bool {
let (_, _, start) = self.experience_display;
start != 0 && start + 100 > self.ticks
}
/// The local player's UUID, as servers know it.
fn own_uuid(&self) -> u128 {
match &self.remote {
Some(r) => r.profile(),
None => self.level.player_uuid.map_or(0, uuid),
}
}
/// An entity's block position and (interpolated) eyes, by UUID.
fn waypoint_entity(&self, id: u128, alpha: f64) -> Option<([i32; 3], [f64; 3])> {
let at = |p: [f64; 3], q: [f64; 3], eyes: f64| {
let pos: [f64; 3] = std::array::from_fn(|i| q[i] + (p[i] - q[i]) * alpha);
(p.map(|c| c.floor() as i32), [pos[0], pos[1] + eyes, pos[2]])
};
if let Some(r) = &self.remote {
let entity = r.player_entity(id)?;
let m = self.mobs.iter().find(|m| m.id == entity)?;
let v = |v: horizon_core::geometry::Vec3| [v.x, v.y, v.z];
return Some(at(v(m.position), v(m.previous), m.eye_height()));
}
let host = self.lan.as_ref()?;
let g = host.hub.guests.iter().find(|g| g.uuid == id && g.placed)?;
Some(at(g.position, g.previous_position, PLAYER_EYES))
}
/// `LocatorBar.extractRenderState` from the last frame's camera.
pub(super) fn locator_bar_draws(&self, gui_width: i32, gui_height: i32) -> Vec<GuiDraw> {
let (Some((camera_pos, yaw, pitch, fov)), Some(p)) = (self.locator_camera, &self.player)
else {
return Vec::new();
};
let alpha = self.clock.camera_fraction();
// Camera.getViewRotationProjectionMatrix, through view space: the
// depth along the look direction and the height on screen.
let (sy, cy) = (
f64::from(yaw.to_radians()).sin(),
f64::from(yaw.to_radians()).cos(),
);
let (sp, cp) = (
f64::from(pitch.to_radians()).sin(),
f64::from(pitch.to_radians()).cos(),
);
let forward = [-sy * cp, -sp, cy * cp];
let up = [-sy * sp, cp, cy * sp];
let focal = 1.0 / f64::from((fov / 2.0).to_radians()).tan();
let project = |o: [f64; 3]| {
let dot = |a: [f64; 3]| a[0] * o[0] + a[1] * o[1] + a[2] * o[2];
let depth = dot(forward);
let y = dot(up) * focal / depth;
// Points behind the camera land past the far plane (z > 1).
[0.0, y, if depth < 0.0 { 2.0 } else { 0.5 }]
};
let entity = |id: u128| self.waypoint_entity(id, alpha);
let view = View {
gui_width,
gui_height,
camera_uuid: self.own_uuid(),
camera_entity_pos: [p.position.x, p.position.y, p.position.z],
camera_pos,
yaw,
pitch,
fov,
project: &project,
entity: &entity,
styles: &self.waypoint_styles,
tick: self.ticks,
};
self.waypoints.draw(&view)
}
}
+89 -60
View File
@@ -16,7 +16,7 @@ use horizon_client::{
sound_engine::{CartSound, SoundEngine, SoundInstance, Sounds},
};
use horizon_core::{
geometry::{Aabb, Vec3},
geometry::{Aabb, Vec3, trig},
random::RandomSource,
};
use horizon_world::registry::BlockStateId;
@@ -637,6 +637,9 @@ impl Game<'_> {
let r = self.world.random.next_double();
let power = (4.0 + r * 1.5 * speed) as f32;
let at = [c.position.x, c.position.y, c.position.z];
// The cart is the explosion's source: it is gone before the blast
// looks for entities, so it can't hit itself.
self.carts.retain(|x| x.id != c.id);
let _ = self.server_explode(at, power, BlockInteraction::Destroy, false);
}
/// `MinecartHopper.suckInItems`: items above, into the five slots.
@@ -683,8 +686,7 @@ impl Game<'_> {
}
}
}
/// `AbstractMinecart.push(player)` when the player walks into a cart,
/// and carts pushing each other (`pushOtherMinecart`).
/// `AbstractMinecart.push(player)` when the player walks into a cart.
fn push_carts(&mut self) {
let player = self.player.as_ref().map(|p| p.position);
let riding = self.riding;
@@ -719,55 +721,71 @@ impl Game<'_> {
}
}
}
for j in i + 1..self.carts.len() {
let (a, b) = (&self.carts[i], &self.carts[j]);
if !a
.bounds()
.inflate(Vec3::new(0.2, 0.0, 0.2))
.intersects(b.bounds())
{
continue;
}
let (mut xa, mut za) = (b.position.x - a.position.x, b.position.z - a.position.z);
let dd = xa * xa + za * za;
if dd < 1.0e-4 {
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;
let dir =
glam::DVec2::new(b.position.x - a.position.x, b.position.z - a.position.z)
.normalize_or_zero();
let r = a.yaw.to_radians();
let facing =
glam::DVec2::new(f64::from(r.cos()), f64::from(r.sin())).normalize_or_zero();
if dir.dot(facing).abs() < f64::from(0.8f32) {
continue;
}
let (ma, mb) = (a.velocity, b.velocity);
let (fa, fb) = (a.kind == CartKind::Furnace, b.kind == CartKind::Furnace);
let (na, nb) = if fb && !fa {
(
Vec3::new(ma.x * 0.2 + mb.x - xa, ma.y, ma.z * 0.2 + mb.z - za),
Vec3::new(mb.x * 0.95, mb.y, mb.z * 0.95),
)
} else if fa && !fb {
(
Vec3::new(ma.x * 0.95, ma.y, ma.z * 0.95),
Vec3::new(mb.x * 0.2 + ma.x + xa, mb.y, mb.z * 0.2 + ma.z + za),
)
} else {
let (xdd, zdd) = ((mb.x + ma.x) / 2.0, (mb.z + ma.z) / 2.0);
(
Vec3::new(ma.x * 0.2 + xdd - xa, ma.y, ma.z * 0.2 + zdd - za),
Vec3::new(mb.x * 0.2 + xdd + xa, mb.y, mb.z * 0.2 + zdd + za),
)
};
self.carts[i].velocity = na;
self.carts[j].velocity = nb;
}
}
/// `OldMinecartBehavior.pushAndPickupEntities` for carts, at the end of
/// cart `i`'s own tick: each other cart in its box (0.2 wider on each
/// side) gets `push(cart i)`. Running it per cart, after that cart moved
/// and before the next one does, is what splits stacked carts apart.
fn push_from_cart(&mut self, i: usize) {
let reach = f64::from(0.2f32);
let hitbox = self.carts[i].bounds().inflate(Vec3::new(reach, 0.0, reach));
for j in 0..self.carts.len() {
if j == i || !hitbox.intersects(self.carts[j].bounds()) {
continue;
}
// AbstractMinecart.push(entity): `this` is cart j, `entity` cart i.
let (this, other) = (&self.carts[j], &self.carts[i]);
let (mut xa, mut za) = (
other.position.x - this.position.x,
other.position.z - this.position.z,
);
let dd = xa * xa + za * za;
if dd < f64::from(1.0e-4f32) {
continue;
}
let d = dd.sqrt();
xa /= d;
za /= d;
let pow = (1.0 / d).min(1.0);
xa = xa * pow * f64::from(0.1f32) * 0.5;
za = za * pow * f64::from(0.1f32) * 0.5;
// pushOtherMinecart (old movement): only along this cart's facing.
let dir = glam::DVec2::new(
other.position.x - this.position.x,
other.position.z - this.position.z,
)
.normalize_or_zero();
let r = f64::from(this.yaw * 0.017453292f32);
let facing = glam::DVec2::new(f64::from(trig::cos(r)), f64::from(trig::sin(r)))
.normalize_or_zero();
if dir.dot(facing).abs() < f64::from(0.8f32) {
continue;
}
let (mt, mo) = (this.velocity, other.velocity);
let (ft, fo) = (
this.kind == CartKind::Furnace,
other.kind == CartKind::Furnace,
);
let (nt, no) = if fo && !ft {
(
Vec3::new(mt.x * 0.2 + (mo.x - xa), mt.y, mt.z * 0.2 + (mo.z - za)),
Vec3::new(mo.x * 0.95, mo.y, mo.z * 0.95),
)
} else if !fo && ft {
(
Vec3::new(mt.x * 0.95, mt.y, mt.z * 0.95),
Vec3::new(mo.x * 0.2 + (mt.x + xa), mo.y, mo.z * 0.2 + (mt.z + za)),
)
} else {
let (xdd, zdd) = ((mo.x + mt.x) / 2.0, (mo.z + mt.z) / 2.0);
(
Vec3::new(mt.x * 0.2 + (xdd - xa), mt.y, mt.z * 0.2 + (zdd - za)),
Vec3::new(mo.x * 0.2 + (xdd + xa), mo.y, mo.z * 0.2 + (zdd + za)),
)
};
self.carts[j].velocity = nt;
self.carts[i].velocity = no;
}
}
/// Every cart but the ridden one (which ticks with its rider).
@@ -777,14 +795,17 @@ impl Game<'_> {
let ticking = self.entity_ticking_area();
// A server player's minecart moves with its rider (lan_vehicles.rs).
let ridden = self.guest_vehicles();
let mut i = 0;
while i < self.carts.len() {
// 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 {
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
continue;
};
let p = self.carts[i].position;
if Some(self.carts[i].id) == self.riding
|| ridden.contains(&self.carts[i].id)
if Some(id) == self.riding
|| ridden.contains(&id)
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
{
i += 1;
continue;
}
let mut c = self.carts[i].clone();
@@ -792,9 +813,12 @@ impl Game<'_> {
if c.kind == CartKind::Hopper {
self.hopper_cart_pickup(&mut c);
}
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
continue;
};
if alive {
self.carts[i] = c;
i += 1;
self.push_from_cart(i);
} else {
self.carts.remove(i);
}
@@ -865,8 +889,13 @@ impl Game<'_> {
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
let Some(i) = self.carts.iter().position(|x| x.id == c.id) else {
self.riding = None;
return true;
};
if alive {
self.carts[i] = c;
self.push_from_cart(i);
} else {
self.carts.remove(i);
self.riding = None;
@@ -1121,8 +1150,8 @@ impl Game<'_> {
}
/// `VehicleEntity.hurtServer`: shake, add the damage, break past 40 (at
/// once for creative players); TNT minecarts prime instead when moving
/// or hurt by fire.
fn hurt_cart(&mut self, i: usize, damage: f32, creative: bool, fire: bool) {
/// or hurt by fire or an explosion (`damageSourceIgnitesTnt`).
pub(super) fn hurt_cart(&mut self, i: usize, damage: f32, creative: bool, fire: bool) {
let c = &mut self.carts[i];
c.hurt_dir = -c.hurt_dir;
c.hurt_time = 10;
@@ -1131,7 +1160,7 @@ impl Game<'_> {
if destroyed {
let mut c = self.carts.remove(i);
let speed = c.velocity.x * c.velocity.x + c.velocity.z * c.velocity.z;
if c.kind == CartKind::Tnt && (fire || speed >= 0.01) {
if c.kind == CartKind::Tnt && (fire || speed >= f64::from(0.01f32)) {
// MinecartTNT.destroy: prime instead of breaking.
if c.fuse < 0 {
self.prime_cart(&mut c);
+22 -7
View File
@@ -463,25 +463,40 @@ impl Game<'_> {
_ => {}
}
}
/// `Hud.extractVehicleHealth` and the jump bar that replaces the
/// experience bar on a saddled jumper. Returns the mount's heart count
/// (`getVehicleMaxHearts`) and whether the jump bar shows.
pub(super) fn draw_mount_hud(&self, batch: &mut HudBatch, w: i32, h: i32) -> (i32, bool) {
/// `Player.jumpableVehicle`: a saddled mount that jumps.
pub(super) fn jumpable_mount(&self) -> bool {
self.mount_index()
.is_some_and(|i| self.mobs[i].extra.saddled)
}
/// `willPrioritizeJumpInfo`: charging a jump or recharging.
pub(super) fn mount_jumping(&self) -> bool {
self.mount.jump_scale > 0.0 || self.mount.jump_ticks < 0
}
/// `Hud.extractVehicleHealth`, and the jump bar when it is the
/// contextual bar (`jump_bar`). Returns the mount's heart count
/// (`getVehicleMaxHearts`).
pub(super) fn draw_mount_hud(
&self,
batch: &mut HudBatch,
w: i32,
h: i32,
jump_bar: bool,
) -> i32 {
let Some(i) = self.mount_index() else {
return (0, false);
return 0;
};
let m = &self.mobs[i];
let atlas = &self.gui.atlas;
let hearts = horizon_client::hud_layout::vehicle_max_hearts(m.attrs.max_health);
let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health);
horizon_client::hud_layout::paint(batch, atlas, &draws);
if m.extra.saddled {
if jump_bar {
// JumpableVehicleBar: the cooldown shows while the jump recharges.
let cooldown = i32::from(self.mount.jump_ticks < 0);
let draws =
horizon_client::hud_overlays::jump_bar(w, h, cooldown, self.mount.jump_scale);
horizon_client::hud_layout::paint(batch, atlas, &draws);
}
(hearts, m.extra.saddled)
hearts
}
}
+6 -2
View File
@@ -12,7 +12,7 @@ impl Game<'_> {
pub(super) fn tick_music(&mut self) {
let (music, volume) = self.situational_music();
// LevelLoadingScreen: no new track before the player is in the world.
let loading = self.title.is_none() && self.player.is_none();
let loading = self.title.is_none() && self.player.is_none() && self.win.is_none();
self.music
.tick(music.as_ref(), volume, loading, &mut self.audio);
}
@@ -59,7 +59,11 @@ impl Game<'_> {
None => (empty, 1.0),
};
let situation = Situation {
screen_music: None,
// WinScreen.getBackgroundMusic.
screen_music: self
.win
.is_some()
.then(horizon_client::music::Music::credits),
in_world: eye.is_some(),
in_end: self.dim == super::Dim::End,
boss_music: self.boss_music(),
@@ -72,6 +72,8 @@ pub(super) struct GameLevel<'g, 'a> {
pub(super) w: MobWorld<'g>,
/// Every player in the level ([`Game::level_players`]).
pub(super) players: Vec<LevelPlayer>,
/// Where the non-spectators among them are (`getNearestPlayer`).
pub(super) nearest: Vec<[f64; 3]>,
/// Boxes of the mobs this pass created, not yet in the game's list.
pub(super) pending: std::cell::RefCell<Vec<Aabb>>,
/// The types and boxes of what a spawner created this tick.
@@ -255,14 +257,8 @@ impl NaturalLevel for GameLevel<'_, '_> {
fn structure_spawns(&self) -> &StructureSpawns {
StructureSpawns::shared(&self.game.options.resources)
}
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
// The nearest player's distance (`nearest_player`), without finding
// which player it is: every spawn attempt asks.
self.players
.iter()
.filter(|p| !p.spectator)
.map(|p| p.distance_sqr(x, y, z))
.reduce(f64::min)
fn nearest_player_positions(&self) -> &[[f64; 3]] {
&self.nearest
}
fn respawn(&self) -> Option<[i32; 3]> {
// The world spawn point lives in the overworld.
@@ -363,10 +359,17 @@ impl horizon_client::mobs::spawner::SpawnerLevel for GameLevel<'_, '_> {
}
impl<'a> Game<'a> {
pub(super) fn natural_level(&self) -> GameLevel<'_, 'a> {
let players = self.level_players();
let nearest = players
.iter()
.filter(|p| !p.spectator)
.map(|p| [p.position.x, p.position.y, p.position.z])
.collect();
GameLevel {
game: self,
w: self.mob_world_base(&[]),
players: self.level_players(),
players,
nearest,
pending: Default::default(),
pending_ids: Default::default(),
boxes: Default::default(),
+11
View File
@@ -200,6 +200,13 @@ impl Game<'_> {
self.settings.save(&Self::options_path());
self.apply_settings();
self.sync_mods();
self.sync_agent_control();
self.ui.options.agent_status = self.agent_status_lines();
}
Event::CopyAgentDetails => {
use horizon_client::clipboard::Clipboard;
let text = self.agent_details();
self.clipboard.set(&text);
}
Event::Close if self.ui.options.resource_error.is_some() => {}
Event::Close => {
@@ -235,6 +242,10 @@ impl Game<'_> {
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(())
+3 -1
View File
@@ -133,7 +133,7 @@ impl Game<'_> {
}
fn reload_resources(&mut self, root: &Path) -> horizon_client::Result<()> {
let render = std::sync::Arc::new(self.pack.assets.reload(root)?);
let mut atlas = HudAtlas::build(root)?;
let mut atlas = HudAtlas::build_over(root, &self.vanilla_root())?;
atlas.font.uniform = self.settings.force_unicode;
atlas.font.japanese = self.settings.japanese_glyphs;
let gui = super::UiResources {
@@ -171,6 +171,8 @@ impl Game<'_> {
}
};
// Everything loaded: switch over.
self.waypoint_styles = horizon_client::locator_bar::WaypointStyles::load(root);
self.armor_stand_assets.reload_trims(root);
self.random_tick_sections.clear();
self.portals.sections.clear();
self.options.resources = root.to_path_buf();
+7 -3
View File
@@ -297,10 +297,14 @@ impl Game<'_> {
}
}
/// The local player's effects (`tick_mob_effects`), then the potions
/// in flight, which a server without a local player still has.
pub(super) fn tick_effects(&mut self) {
/// in flight, which a server without a local player still has. A frozen
/// tick (`/tick freeze`) leaves the potions where they are
/// (`TickRateManager.isEntityFrozen`); players' effects still run.
pub(super) fn tick_effects(&mut self, running: bool) {
self.tick_mob_effects();
self.tick_thrown_potions();
if running {
self.tick_thrown_potions();
}
}
/// `LivingEntity.tickEffects` (server side) for `self.player`, the local
/// player or a guest acting (`as_guest`), then its attribute modifiers
+187 -50
View File
@@ -40,7 +40,11 @@ use horizon_protocol::{
registry::{Builtin, DimensionType, Dynamic, ceil_log2},
text::{Language, format_java, plain},
};
use horizon_world::{nbt::Tag, registry::BlockStateId};
use horizon_world::{
nbt::Tag,
registry::BlockStateId,
stepped_interpolation::{Path, PosRot, Start, SteppedInterpolation},
};
use std::{
collections::{HashMap, VecDeque},
sync::Arc,
@@ -85,6 +89,15 @@ struct Lerp {
steps: u32,
}
/// `SteppedInterpolationHandler.doInterpolate`'s `setPos` and `setRot` on a
/// mob: its yaw kept continuous (the same turn as vanilla's, without the
/// wrap that would spin it in the frame between ticks), its pitch clamped.
fn place_interpolated(m: &mut Mob, at: PosRot) {
m.position = Vec3::new(at.position[0], at.position[1], at.position[2]);
m.yaw += wrap_degrees(at.yaw - m.yaw);
m.head_pitch = (at.pitch % 360.0).clamp(-90.0, 90.0);
}
pub(super) struct Remote {
pub session: Session,
pub brand: String,
@@ -123,6 +136,8 @@ pub(super) struct Remote {
use_target: Option<[i32; 3]>,
entities: HashMap<i32, Tracked>,
lerps: HashMap<i32, Lerp>,
/// Living entities' `SteppedInterpolationHandler`s.
stepped: HashMap<i32, SteppedInterpolation>,
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
head_lerps: HashMap<i32, (f32, u32)>,
/// Item entities waiting for their item data.
@@ -351,6 +366,7 @@ impl Game<'_> {
r.clear_sections();
}
self.apply_settings();
self.clear_waypoints();
self.remote = Some(Remote {
session,
brand: "unknown".into(),
@@ -379,6 +395,7 @@ impl Game<'_> {
use_target: None,
entities: HashMap::new(),
lerps: HashMap::new(),
stepped: HashMap::new(),
head_lerps: HashMap::new(),
pending_items: HashMap::new(),
health: (20.0, 20, 5.0),
@@ -429,6 +446,7 @@ impl Game<'_> {
// Leaving on purpose: nothing for auto-reconnect to retry.
self.extras_left();
}
self.clear_waypoints();
if let Some(mut r) = self.remote.take() {
r.session.disconnect();
}
@@ -929,6 +947,7 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.entities.clear();
r.lerps.clear();
r.stepped.clear();
r.head_lerps.clear();
r.pending_items.clear();
// A new ClientLevel: a new chunk storage, centred on 0, 0.
@@ -1315,11 +1334,15 @@ impl Game<'_> {
}
if let Some(t) = mob_type {
let mut mob = Mob::new(id as u32, t, at, head);
super::remote_mob_data::client_defaults(&mut mob);
(mob.yaw, mob.previous_yaw) = (yaw, yaw);
(mob.head_pitch, mob.previous_head_pitch) = (pitch, pitch);
mob.persistent = true;
self.mobs.retain(|m| m.id != id as u32);
self.mobs.push(mob);
if let Some(r) = self.remote.as_mut() {
r.stepped.remove(&id);
}
}
if let Some(r) = self.remote.as_mut() {
if kind == "minecraft:item" {
@@ -1334,23 +1357,77 @@ impl Game<'_> {
);
}
}
/// A move packet for entity `id`: to the end of `path` (None: a
/// rotation alone), turned to `rotation` if it has one. `far_snaps`:
/// an `EntityPositionSync`, which puts an entity more than 64 blocks
/// away there at once.
fn move_entity(
&mut self,
id: i32,
position: [f64; 3],
path: Option<Path>,
rotation: Option<(f32, f32)>,
on_ground: bool,
far_snaps: bool,
) {
let mob = self.mobs.iter_mut().find(|m| m.id == id as u32);
let tracked = self
.remote
.as_ref()
.and_then(|r| r.entities.get(&id))
.map(|t| t.position);
let position = path
.as_ref()
.map(Path::end)
.or(tracked)
.or_else(|| {
mob.as_ref()
.map(|m| [m.position.x, m.position.y, m.position.z])
})
.unwrap_or([0.0; 3]);
if let Some(r) = self.remote.as_mut() {
if let Some(t) = r.entities.get_mut(&id) {
t.position = position;
}
if let Some(m) = mob {
// A living entity: LivingEntity.createInterpolationHandler's
// SteppedInterpolationHandler, over its type's update interval.
let entity = PosRot {
position: [m.position.x, m.position.y, m.position.z],
yaw: m.yaw,
pitch: m.head_pitch,
};
let far = far_snaps && {
let d = [0, 1, 2].map(|i| position[i] - entity.position[i]);
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 4096.0
};
let handler = r.stepped.entry(id).or_insert_with(|| {
let interval = horizon_client::lan::tracking::settings(&m.kind.id).1;
SteppedInterpolation::new(interval as i32)
});
let snap = if far {
handler.cancel();
let (yaw, pitch) = rotation.unwrap_or((m.yaw, m.head_pitch));
Some(PosRot {
position,
yaw,
pitch,
})
} else {
match handler.interpolate_to(entity, path, rotation) {
Start::Snap(at) => Some(at),
Start::Interpolating | Start::Unchanged => None,
}
};
if let Some(at) = snap {
place_interpolated(m, at);
m.previous = m.position;
}
m.on_ground = on_ground;
return;
}
// AbstractInterpolationHandler.interpolateTo: without a rotation,
// the one it was heading for (or has).
let rotation = rotation
.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation))
.or_else(|| mob.as_ref().map(|m| (m.yaw, m.head_pitch)));
// the one it was heading for.
let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation));
r.lerps.insert(
id,
Lerp {
@@ -1363,9 +1440,6 @@ impl Game<'_> {
*p = position;
}
}
if let Some(m) = mob {
m.on_ground = on_ground;
}
self.turn_remote_object(id, rotation.map(|(yaw, _)| yaw), on_ground);
self.turn_remote_projectile(id, rotation);
}
@@ -1382,6 +1456,7 @@ impl Game<'_> {
if let Some(r) = self.remote.as_mut() {
r.entities.remove(&id);
r.lerps.remove(&id);
r.stepped.remove(&id);
r.head_lerps.remove(&id);
r.pending_items.remove(&id);
}
@@ -1741,17 +1816,36 @@ impl Game<'_> {
.and_then(|r| r.entities.get(&m.id))
.map(|t| t.position);
let Some(base) = base else { return Ok(()) };
let position = m.delta.as_ref().map_or(base, |d| d.end(base));
// VecDeltaCodec.decode: each step's position from the last.
let path = m.delta.as_ref().map(|d| match d {
play::VecDelta::Linear(_) => Path::Linear(d.end(base)),
play::VecDelta::Stepped(steps) => {
let mut at = base;
Path::Stepped(
steps
.iter()
.map(|(delta, ticks)| {
at = play::VecDelta::apply(at, *delta);
(at, *ticks)
})
.collect(),
)
}
});
let rotation = m.rotation.map(|(y, x)| {
(
horizon_protocol::buf::unpack_degrees(y),
horizon_protocol::buf::unpack_degrees(x),
)
});
self.move_entity(m.id, position, rotation, m.on_ground);
self.move_entity(m.id, path, rotation, m.on_ground, false);
}
C::EntityPositionSync(p) => {
self.move_entity(p.id, p.path.end(), Some((p.yaw, p.pitch)), p.on_ground)
let path = match p.path {
play::PositionPath::Linear(at) => Path::Linear(at),
play::PositionPath::Stepped(steps) => Path::Stepped(steps),
};
self.move_entity(p.id, Some(path), Some((p.yaw, p.pitch)), p.on_ground, true)
}
C::TeleportEntity(p) => {
let base = self
@@ -1771,9 +1865,28 @@ impl Game<'_> {
rel(play::relative::Y, 1),
rel(play::relative::Z, 2),
];
self.move_entity(p.id, position, Some((p.yaw, p.pitch)), p.on_ground);
self.move_entity(
p.id,
Some(Path::Linear(position)),
Some((p.yaw, p.pitch)),
p.on_ground,
false,
);
}
C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers),
// ClientPacketListener.handleEntityLinkPacket: who holds a mob's
// lead (this player, another player or a mob), for the rope.
C::SetEntityLink(p) => {
let me = self.remote.as_ref().map(|r| r.entity_id);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) {
use horizon_client::mobs::leash::Holder;
m.extra.leash = match p.dest {
0 => None,
d if Some(d) == me => Some(Holder::Player),
d => Some(Holder::Mob(d as u32)),
};
}
}
C::MoveVehicle(p) => self.remote_move_vehicle(p.position, p.yaw),
// ClientPacketListener.handleRotateMob: lerpHeadTo(yHeadRot, 3).
C::RotateHead(p) => {
@@ -1810,13 +1923,12 @@ impl Game<'_> {
&& m.species() == horizon_client::mobs::Species::Rabbit
{
for (index, _, value) in &p.values {
match (index, value) {
(15, Value::Byte(flags)) => m.no_ai = flags & 1 != 0,
(16, Value::Bool(baby)) => m.extra.age = if *baby { -1 } else { 0 },
_ => {}
if let (15, Value::Byte(flags)) = (index, value) {
m.no_ai = flags & 1 != 0;
}
}
}
self.remote_mob_data(p.id, &p.values);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& m.species() == horizon_client::mobs::Species::Creeper
{
@@ -1865,11 +1977,27 @@ impl Game<'_> {
// player lies down in the bed, or gets up.
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
for (index, _, value) in &p.values {
if let (14, Value::OptPos(bed)) = (index, value) {
if bed.is_some() && self.sleeping.is_none() {
self.sleep_timer = 0;
match (index, value) {
(14, Value::OptPos(bed)) => {
if bed.is_some() && self.sleeping.is_none() {
self.sleep_timer = 0;
}
self.sleeping = *bed;
}
self.sleeping = *bed;
// Entity.DATA_AIR_SUPPLY_ID and
// Player.DATA_PLAYER_ABSORPTION_ID: the bubbles
// and golden hearts the server's player has.
(1, Value::Int(air)) => {
if let Some(pl) = self.player.as_mut() {
pl.air = *air;
}
}
(17, Value::Float(amount)) => {
if let Some(pl) = self.player.as_mut() {
let _ = pl.internal_set_absorption(*amount);
}
}
_ => {}
}
}
}
@@ -2061,6 +2189,7 @@ impl Game<'_> {
self.capture(false);
}
}
C::TrackedWaypoint(p) => self.handle_waypoint(&p),
C::SetExperience(p) => {
self.experience = Experience {
level: p.level,
@@ -2114,6 +2243,10 @@ impl Game<'_> {
);
}
}
// WIN_GAME: the poem and credits, then PERFORM_RESPAWN.
4 if self.screen != Screen::Win => {
self.open_win_screen(true, super::win::Finish::RemoteRespawn);
}
1 => {
self.world.weather.raining = true;
self.world.weather.rain_level = (0.0, 0.0);
@@ -2884,6 +3017,7 @@ impl Game<'_> {
fn tick_remote_level(&mut self) -> Result<()> {
self.ticks += 1;
self.track_experience_display();
// Options.broadcastOptions: a changed render distance is sent again.
let view = client_view_distance(self.settings.render_distance);
if let Some(r) = self.remote.as_mut()
@@ -2993,6 +3127,13 @@ impl Game<'_> {
);
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 dead = self.screen == Screen::Dead;
// LivingEntity.isImmobile: a sleeper lies still in the bed.
if let Some(head) = self.sleeping {
@@ -3145,6 +3286,7 @@ impl Game<'_> {
return;
};
let mut lerps = std::mem::take(&mut r.lerps);
let stepped = &mut r.stepped;
let head_lerps = &mut r.head_lerps;
let players = &r.level_players;
let mut using = Vec::new();
@@ -3175,22 +3317,12 @@ impl Game<'_> {
// LivingEntity.updateSwingTime.
m.anim.attack_anim_o = m.anim.attack_anim;
horizon_client::mobs::ai::tick_swing(m);
// Entity.commonTick: LinearInterpolationHandler.interpolate.
if let Some(l) = lerps.get_mut(&(m.id as i32))
&& l.steps > 0
// Entity.tick: the SteppedInterpolationHandler's step.
if let Some(at) = stepped
.get_mut(&(m.id as i32))
.and_then(|h| h.interpolate())
{
let n = f64::from(l.steps);
m.position = Vec3::new(
m.position.x + (l.target[0] - m.position.x) / n,
m.position.y + (l.target[1] - m.position.y) / n,
m.position.z + (l.target[2] - m.position.z) / n,
);
if let Some((yaw, pitch)) = l.rotation {
let alpha = 1.0 / l.steps as f32;
m.yaw += wrap_degrees(yaw - m.yaw) * alpha;
m.head_pitch += (pitch - m.head_pitch) * alpha;
}
l.steps -= 1;
place_interpolated(m, at);
}
// LivingEntity.aiStep: the head's own interpolation.
if let Some((yaw, steps)) = head_lerps.get_mut(&(m.id as i32))
@@ -3465,22 +3597,13 @@ impl Game<'_> {
return;
}
let facts = self.res.facts().get(state).clone();
let held = self.held().cloned();
let mut speed = self.res.items().mining_speed(held.as_ref(), &facts.name);
if self.player.as_ref().is_some_and(|p| !p.ground.on_ground) {
speed /= 5.0;
}
let harvest = !facts.requires_tool
|| self
.res
.items()
.correct_for_drops(held.as_ref(), &facts.name);
let progress = if facts.hardness < 0.0 {
0.0
} else if facts.hardness == 0.0 {
// The server's own getDestroyProgress: under water, off the ground,
// with haste, fatigue and efficiency, so the client stops digging
// when the server is done too (#1254).
let progress = if facts.hardness == 0.0 {
1.0
} else {
speed / facts.hardness / if harvest { 30.0 } else { 100.0 }
self.destroy_progress(hit.pos)
};
let same = self
.remote
@@ -5023,6 +5146,20 @@ impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_
}
}
impl Remote {
/// The local player's profile UUID.
pub(super) fn profile(&self) -> u128 {
self.profile
}
/// The entity id of a player in the level, by profile UUID.
pub(super) fn player_entity(&self, uuid: u128) -> Option<u32> {
self.level_players
.iter()
.find(|(_, u)| **u == uuid)
.map(|(id, _)| *id as u32)
}
}
#[cfg(test)]
mod tests {
use super::Game;
@@ -0,0 +1,136 @@
//! A server's mobs as their synched data has them
//! (ClientPacketListener.handleSetEntityData, then each entity's
//! `onSyncedDataUpdated`): what a client draws from it rather than from its
//! own simulation, which a client of a server doesn't run.
use super::Game;
use horizon_client::mobs::{Mob, Species, tame};
use horizon_protocol::entity_data::Value;
/// The values `apply` takes from one SetEntityData packet, by vanilla's entry
/// indices (Entity 0..=7, LivingEntity 8..=14, Mob 15, then the subclasses).
fn apply(m: &mut Mob, values: &[(u8, i32, Value)]) {
let species = m.species();
let baby = super::lan::baby_index(&m.kind.id);
for (index, _, value) in values {
match (*index, value) {
// Entity.DATA_SHARED_FLAGS_ID's FLAG_ONFIRE: Entity.isOnFire on a
// client, which draws the flames. A client doesn't count the
// fire down: the flag goes off when the server's fire is out.
(0, Value::Byte(flags)) => m.fire_ticks = i32::from(flags & 1),
// LivingEntity.DATA_HEALTH_ID: the boss bars and the death
// animation follow it.
(9, Value::Float(health)) => m.health = *health,
(i, Value::Bool(b)) if Some(i) == baby => m.extra.age = if *b { -1 } else { 0 },
_ => {}
}
match (species, *index, value) {
// Sheep.DATA_WOOL_ID: the colour in the low nibble, 16 sheared.
(Species::Sheep, 18, Value::Byte(wool)) => {
m.extra.color = (*wool & 15) as u8;
m.extra.sheared = wool & 16 != 0;
}
// TamableAnimal.DATA_FLAGS_ID: sitting 1, tame 4.
(Species::Wolf | Species::Cat | Species::Parrot, 18, Value::Byte(flags)) => {
m.extra.sitting = flags & 1 != 0;
m.extra.tame = flags & 4 != 0;
}
// Wolf.DATA_COLLAR_COLOR and Cat's.
(Species::Wolf, 21, Value::Int(c)) | (Species::Cat, 23, Value::Int(c)) => {
m.extra.color = (*c & 15) as u8;
}
// AbstractCubeMob.ID_SIZE: its size and box (refreshDimensions).
(Species::Slime | Species::MagmaCube, 18, Value::Int(size)) => {
m.extra.size = (*size).clamp(1, 127);
}
_ => {}
}
}
}
impl Game<'_> {
/// SetEntityData for a mob of the server's.
pub(super) fn remote_mob_data(&mut self, id: i32, values: &[(u8, i32, Value)]) {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
apply(m, values);
}
}
}
/// A mob as a client constructs it before its data arrives: the synched
/// defaults the server leaves out (`getNonDefaultValues`) that differ from
/// what `Mob::new` gives, such as a wolf's and a cat's red collar.
pub(super) fn client_defaults(m: &mut Mob) {
if matches!(m.species(), Species::Wolf | Species::Cat) {
m.extra.color = tame::RED;
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_protocol::entity_data::serializer as s;
/// A mob of the prepared resources' type `kind`, as a client spawns it
/// (None without the resources).
fn mob(kind: &str) -> Option<Mob> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
let types = horizon_client::mobs::MobTypes::load(&root).ok()?;
let t = std::sync::Arc::new(types.types.get(kind)?.clone());
let mut m = Mob::new(1, t, horizon_core::geometry::Vec3::ZERO, 0.0);
client_defaults(&mut m);
Some(m)
}
#[test]
fn a_sheep_takes_its_wool_and_a_calf_its_size() {
let Some(mut m) = mob("minecraft:sheep") else {
return;
};
apply(&mut m, &[(18, s::BYTE, Value::Byte(14 | 16))]);
assert_eq!((m.extra.color, m.extra.sheared), (14, true));
apply(&mut m, &[(18, s::BYTE, Value::Byte(0))]);
assert_eq!((m.extra.color, m.extra.sheared), (0, false));
let mut calf = mob("minecraft:cow").unwrap();
apply(&mut calf, &[(16, s::BOOLEAN, Value::Bool(true))]);
assert!(calf.is_baby());
apply(&mut calf, &[(16, s::BOOLEAN, Value::Bool(false))]);
assert!(!calf.is_baby());
}
#[test]
fn a_wolf_takes_its_owner_flags_and_collar() {
let Some(mut m) = mob("minecraft:wolf") else {
return;
};
assert_eq!(m.extra.color, tame::RED);
apply(
&mut m,
&[
(18, s::BYTE, Value::Byte(4 | 1)),
(21, s::INT, Value::Int(11)),
],
);
assert!(m.extra.tame && m.extra.sitting);
assert_eq!(m.extra.color, 11);
}
#[test]
fn fire_health_and_size_follow_the_server() {
let Some(mut m) = mob("minecraft:slime") else {
return;
};
apply(
&mut m,
&[
(0, s::BYTE, Value::Byte(1)),
(9, s::FLOAT, Value::Float(12.5)),
(18, s::INT, Value::Int(4)),
],
);
assert_eq!((m.fire_ticks, m.health, m.extra.size), (1, 12.5, 4));
apply(&mut m, &[(0, s::BYTE, Value::Byte(0))]);
assert_eq!(m.fire_ticks, 0);
}
}
@@ -222,6 +222,7 @@ impl Game<'_> {
hud_overlays::camera_overlays(size[0] as i32, size[1] as i32, &overlays, &mut scope);
self.screen_fx.build(&ScreenInputs {
sprites: &self.gui.atlas.sprites,
flags: &self.gui.atlas.flags,
aspect: size[0] as f32 / size[1].max(1) as f32,
partial,
block_overlay,
@@ -31,6 +31,33 @@ pub(super) struct RespawnConfig {
pub forced: bool,
}
/// A respawn point as a player's save keeps it (`respawn`): dimension,
/// block, yaw and whether it is forced.
pub(super) type SavedRespawn = (String, [i32; 3], f32, bool);
impl RespawnConfig {
/// `RespawnData.of`: the yaw wrapped to [-180, 180).
pub(super) fn wrapped(dim: Dim, pos: [i32; 3], yaw: f32, forced: bool) -> Self {
Self {
dim,
pos,
yaw: wrap_degrees(f64::from(yaw)) as f32,
forced,
}
}
pub(super) fn from_saved((dim, pos, yaw, forced): &SavedRespawn) -> Self {
Self {
dim: Dim::from_id(dim),
pos: *pos,
yaw: *yaw,
forced: *forced,
}
}
pub(super) fn saved(&self) -> SavedRespawn {
(self.dim.id().to_owned(), self.pos, self.yaw, self.forced)
}
}
/// `RespawnAnchorBlock.RESPAWN_HORIZONTAL_OFFSETS`, then the same a block
/// below, a block above, and straight above.
const HORIZONTAL: [[i32; 3]; 8] = [
@@ -62,7 +89,7 @@ pub(super) enum AnchorUse {
}
impl Game<'_> {
fn is_anchor(&self, state: BlockStateId) -> bool {
pub(super) fn is_anchor(&self, state: BlockStateId) -> bool {
&*self.res.facts().get(state).name == "minecraft:respawn_anchor"
}
fn anchor_charges(&self, state: BlockStateId) -> i32 {
@@ -415,6 +442,16 @@ impl Game<'_> {
let (lz, hz) = (bounds.min(Axis::Z), bounds.max(Axis::Z) - 1.0e-5);
(self.border.contains(lo, lz) && self.border.contains(hi, hz)).then_some(position)
}
/// `Block.isPossibleToRespawnInThis`: neither solid nor liquid; banners,
/// signs and pressure plates override it to always.
fn possible_to_respawn_in(&self, state: BlockStateId) -> bool {
let f = self.res.facts().get(state);
let name = &*f.name;
name.ends_with("_banner")
|| name.ends_with("_sign")
|| name.ends_with("_pressure_plate")
|| !(f.legacy_solid || matches!(name, "minecraft:water" | "minecraft:lava"))
}
/// `RespawnAnchorBlock.findStandUpPosition`: a safe spot around the
/// anchor, else any spot.
pub(super) fn anchor_stand_up(&self, pos: [i32; 3]) -> Option<Vec3> {
@@ -428,8 +465,87 @@ impl Game<'_> {
}
None
}
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for an anchor config in the
/// loaded dimension: the position and yaw, using a charge.
/// `AbstractBedBlock.findStandUpPosition` for a bed whose head is at
/// `pos`, facing `forward`, set from `yaw`: beside the bed on the side
/// the player faced away from, around it, then on it; a safe spot
/// first, else any spot (a bunk bed: around it, a block lower, then on
/// it).
pub(super) fn bed_stand_up(&self, pos: [i32; 3], forward: [i32; 3], yaw: f32) -> Option<Vec3> {
// Direction.getClockWise.
let right = [-forward[2], 0, forward[0]];
// Direction.isFacingAngle.
let radians = yaw * 0.017_453_292;
let (dx, dz) = (
-horizon_core::geometry::trig::sin(f64::from(radians)),
horizon_core::geometry::trig::cos(f64::from(radians)),
);
let facing = right[0] as f32 * dx + right[2] as f32 * dz > 0.0;
let side = if facing {
[-right[0], 0, -right[2]]
} else {
right
};
let (f, s) = ([forward[0], forward[2]], [side[0], side[2]]);
// bedSurroundStandUpOffsets and bedAboveStandUpOffsets.
let surround = [
[s[0], s[1]],
[s[0] - f[0], s[1] - f[1]],
[s[0] - f[0] * 2, s[1] - f[1] * 2],
[-f[0] * 2, -f[1] * 2],
[-s[0] - f[0] * 2, -s[1] - f[1] * 2],
[-s[0] - f[0], -s[1] - f[1]],
[-s[0], -s[1]],
[-s[0] + f[0], -s[1] + f[1]],
[f[0], f[1]],
[s[0] + f[0], s[1] + f[1]],
];
let above = [[0, 0], [-f[0], -f[1]]];
let at = |base: [i32; 3], offsets: &[[i32; 2]], check: bool| {
offsets.iter().find_map(|o| {
self.safe_dismount_location([base[0] + o[0], base[1], base[2] + o[1]], check)
})
};
let below = [pos[0], pos[1] - 1, pos[2]];
// isBunkBed: a bed below.
if self.is_bed(self.world.block(below[0], below[1], below[2])) {
return at(pos, &surround, true)
.or_else(|| at(below, &surround, true))
.or_else(|| at(pos, &above, true))
.or_else(|| at(pos, &surround, false))
.or_else(|| at(below, &surround, false))
.or_else(|| at(pos, &above, false));
}
let all: Vec<[i32; 2]> = surround.into_iter().chain(above).collect();
at(pos, &all, true).or_else(|| at(pos, &all, false))
}
pub(super) fn is_bed(&self, state: BlockStateId) -> bool {
self.res.facts().get(state).name.ends_with("_bed")
}
/// The bed's `facing`, as a step.
fn bed_forward(&self, state: BlockStateId) -> [i32; 3] {
let facing = self
.pack
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("facing").cloned());
match facing.as_deref() {
Some("south") => [0, 0, 1],
Some("west") => [-1, 0, 0],
Some("east") => [1, 0, 0],
_ => [0, 0, -1],
}
}
/// `BedRule.canSetSpawn` of the bed's rule attribute (`bed_rule`,
/// `straw_bed_rule`): only the Overworld's beds set the spawn, straw
/// beds never.
pub(super) fn bed_sets_spawn(&self, state: BlockStateId) -> bool {
self.dim == Dim::Overworld && &*self.res.facts().get(state).name != "minecraft:straw_bed"
}
/// `ServerPlayer.findRespawnAndUseSpawnBlock` for a config in the loaded
/// dimension: the position and yaw, using an anchor's charge; a bed's
/// stand-up spot. A forced point (`spawnpoint`) that is no anchor or bed
/// holds if a player fits in it; anything else is gone.
pub(super) fn find_respawn_and_use(
&mut self,
c: RespawnConfig,
@@ -440,7 +556,19 @@ impl Game<'_> {
&& (c.forced || self.anchor_charges(state) > 0)
&& self.anchor_works())
{
return Ok(None);
// A bed where its bed rule lets it set the spawn.
if self.is_bed(state) && self.bed_sets_spawn(state) {
let facing = self.bed_forward(state);
let stand = self.bed_stand_up(c.pos, facing, c.yaw);
return Ok(stand.map(|p| (p, look_at_yaw(p, c.pos))));
}
let [x, y, z] = c.pos;
let free = c.forced
&& !self.is_anchor(state)
&& self.possible_to_respawn_in(state)
&& self.possible_to_respawn_in(self.world.block(x, y + 1, z));
let at = Vec3::new(f64::from(x) + 0.5, f64::from(y) + 0.1, f64::from(z) + 0.5);
return Ok(free.then_some((at, c.yaw)));
}
let stand = self.anchor_stand_up(c.pos);
if !c.forced
@@ -25,6 +25,9 @@ impl Screen {
| Self::Inventory
| Self::Dead
| Self::CommandBlock => false,
// WinScreen.extractBackground: the poem fills the screen with the End portal, the
// credits alone blur. Game::menu_blur knows which one rolls.
Self::Win => false,
}
}
}
@@ -359,6 +362,7 @@ mod tests {
Screen::Inventory,
Screen::Dead,
Screen::CommandBlock,
Screen::Win,
] {
assert!(!screen.blurs_background(true));
assert!(!screen.blurs_background(false));
+146 -9
View File
@@ -7,9 +7,11 @@
//! another level: a Respawn packet keeping all data, then a teleport to the
//! exit once the destination has loaded around them.
use super::end::GatewayJob;
use super::nether_portal::{PLAYER_DELAY, Plan};
use super::respawn_anchor::RespawnConfig;
use super::{Dim, Game, lan::LanHost};
use horizon_client::lan::hub::TickCtx;
use horizon_client::{lan::hub::TickCtx, text::Text};
use horizon_core::{geometry::Vec3, position::BlockPos};
/// A trip under way: the player is in the destination's level, whose
@@ -22,6 +24,12 @@ pub(super) struct Trip {
end: bool,
/// The Nether portal plan, whose exit is found or built on arrival.
plan: Option<Plan>,
/// Through an End gateway, within the level: its exit, found or built
/// on arrival.
gateway: Option<GatewayJob>,
/// A respawn at this point outside the Overworld, resolved through its
/// block on arrival.
respawn: Option<RespawnConfig>,
/// Sent by a command: the player is already where it goes.
placed: bool,
}
@@ -95,7 +103,7 @@ impl Game<'_> {
if let Some(pos) = self.nether_portal_inside(at) {
self.portals.player.set_inside(pos, PLAYER_DELAY);
}
let (end_portal, _) = self.inside_end_portals(at, WIDTH, HEIGHT);
let (end_portal, gateway) = self.inside_end_portals(at, WIDTH, HEIGHT);
if end_portal {
if self.portals.player.cooldown == 0 {
let target = if self.dim == Dim::End {
@@ -109,6 +117,19 @@ impl Game<'_> {
self.portals.player.cooldown = 10;
return;
}
// EndGatewayBlock.entityInside: a gateway that is not cooling down
// takes the player at once (no transition time) and cools down
// (triggerCooldown), even for a player still on its own cooldown.
if let Some(g) = gateway
&& self.gateway_enter(g)
{
if self.portals.player.cooldown == 0 {
self.start_gateway_trip(host, conn, g);
}
// setPortalCooldown: after the trip, or again while cooling down.
self.portals.player.cooldown = 10;
return;
}
let rule = if invulnerable {
"players_nether_portal_creative_delay"
} else {
@@ -172,11 +193,16 @@ impl Game<'_> {
_ => 1.0,
};
self.end_portal_travel = end;
let scaled = self.end_destination(target).unwrap_or(Vec3::new(
from.x * scale,
from.y,
from.z * scale,
));
// Leaving the End: the player's own respawn point.
let own = if end {
self.as_guest(host, conn, |g| g.end_destination(target))
.flatten()
} else {
None
};
let scaled = own
.or_else(|| self.end_destination(target))
.unwrap_or(Vec3::new(from.x * scale, from.y, from.z * scale));
self.end_portal_travel = false;
if let Err(e) = self.enter_level(Some(host), target) {
log::error!("could not open {}: {e}", target.id());
@@ -197,6 +223,8 @@ impl Game<'_> {
target: destination,
end,
plan,
gateway: None,
respawn: None,
placed: false,
});
if let Err(e) = self.enter_level(Some(host), source) {
@@ -204,6 +232,93 @@ impl Game<'_> {
}
}
/// A respawned player waits at its point outside the Overworld for the
/// level to load around it.
pub(super) fn respawn_trip(&mut self, conn: u32, config: RespawnConfig) {
let [x, y, z] = config.pos;
self.server_trips.push(Trip {
conn,
dim: config.dim,
target: Vec3::new(f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5),
end: false,
plan: None,
gateway: None,
respawn: Some(config),
placed: false,
});
}
/// `findRespawnAndUseSpawnBlock` for the player in place, at its point
/// in the level in place: where it stands up, facing the anchor (with
/// the deplete sound) or the bed; else the point is forgotten
/// (`block.minecraft.spawn.not_valid`).
pub(super) fn resolve_server_respawn(&mut self, config: RespawnConfig) -> Option<(Vec3, f32)> {
let [x, y, z] = config.pos;
let anchor = self.is_anchor(self.world.block(x, y, z));
match self.find_respawn_and_use(config, true) {
Ok(Some((at, yaw))) => {
if !config.forced && anchor {
let [x, y, z] = config.pos.map(f64::from);
self.audio.play(
"minecraft:block.respawn_anchor.deplete",
Some([x, y, z]),
1.0,
1.0,
);
}
Some((at, yaw))
}
Ok(None) | Err(_) => {
self.respawn_config = None;
self.system_message(Text::translate("block.minecraft.spawn.not_valid", &[]));
None
}
}
}
/// TeleportTransition.missingRespawnBlock: the world spawn, in the
/// Overworld.
fn respawn_at_world_spawn(&mut self, host: &mut LanHost, conn: u32) {
let at = self.level.world_spawn.map_or(
self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]),
|[x, y, z]| [f64::from(x) + 0.5, f64::from(y), f64::from(z) + 0.5],
);
self.level_teleports.push(LevelTeleport {
conn,
dim: Dim::Overworld,
at,
rotation: None,
});
self.run_level_teleports(host);
}
/// `TheEndGatewayBlockEntity.getPortalPosition` and the teleport within
/// the End (keeping the player's motion and look): straight to an exact
/// exit, else once the destination has loaded around the player, who
/// waits there (its exit is found among those blocks, or built).
fn start_gateway_trip(&mut self, host: &mut LanHost, conn: u32, gateway: [i32; 3]) {
let Some(i) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
let (job, destination) = self.gateway_job(gateway);
let at = [destination.x, destination.y, destination.z];
// Relative.union(DELTA, ROTATION).
host.hub.teleport_keeping_motion(i, at);
if let GatewayJob::Known { exact: true, .. } = job {
return;
}
self.server_trips.push(Trip {
conn,
dim: self.dim,
target: destination,
end: false,
plan: None,
gateway: Some(job),
respawn: None,
placed: false,
});
}
/// The trips into the level in place whose destination has loaded
/// around their target: the exit (the Nether portal found or built, the
/// End platform, the Overworld's spawn column) and the teleport there.
@@ -232,8 +347,28 @@ impl Game<'_> {
}
self.resolve_arrivals();
let yaw = host.hub.guests[i].yaw;
let (position, yaw) = if trip.end {
match self.end_arrival(trip.target) {
let (position, yaw) = if let Some(config) = trip.respawn {
match self.as_guest(host, trip.conn, |g| g.resolve_server_respawn(config)) {
Some(Some(found)) => found,
_ => {
self.respawn_at_world_spawn(host, trip.conn);
continue;
}
}
} else if let Some(job) = trip.gateway {
match self.resolve_gateway(job, trip.target) {
Ok(at) => (at, yaw),
Err(e) => {
log::error!("End gateway exit: {e}");
(trip.target, yaw)
}
}
} else if trip.end {
let arrival = match self.as_guest(host, trip.conn, |g| g.end_arrival(trip.target)) {
Some(arrival) => arrival,
None => self.end_arrival(trip.target),
};
match arrival {
Ok((at, turned)) => (at, turned.unwrap_or(yaw)),
Err(e) => {
log::error!("End arrival: {e}");
@@ -293,6 +428,8 @@ impl Game<'_> {
target: Vec3::new(t.at[0], t.at[1], t.at[2]),
end: false,
plan: None,
gateway: None,
respawn: None,
placed: true,
});
if let Err(e) = self.enter_level(Some(host), source) {
@@ -101,6 +101,21 @@ impl Game<'_> {
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,
+56 -1
View File
@@ -12,6 +12,8 @@ pub(super) struct StartupMenu {
title: Option<Title>,
options: OptionsUi,
options_open: bool,
/// The credits from Credits & Attribution, and their last frame.
win: Option<(horizon_client::win_screen::WinScreen, Instant)>,
root: PathBuf,
cursor: (f32, f32),
pointer: (f64, f64),
@@ -370,7 +372,26 @@ impl Loading {
let _ = std::fs::create_dir_all(&dir);
packs::open_folder(&dir);
}
Event::ButtonClick => {}
Event::Credits => {
if let Some(gui) = &self.gui {
let name = self.options.as_ref().map_or_else(
|| horizon_client::offline_name::generate(0),
Options::player_name,
);
let screen = horizon_client::win_screen::WinScreen::new(
false,
&self.menu.root,
&name,
&gui.atlas.font,
);
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 => {}
}
}
}
@@ -434,6 +455,17 @@ 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) => {
@@ -456,6 +488,20 @@ impl Loading {
}
}
_ if self.gui.is_none() || self.menu.pending.is_some() => {}
WindowEvent::KeyboardInput { event, .. } if self.menu.win.is_some() => {
if let PhysicalKey::Code(code) = event.physical_key {
let pressed = event.state == ElementState::Pressed;
if code == KeyCode::Escape && pressed {
self.menu.win = None;
} else if let Some(key) = horizon_client::win_screen::Key::from_code(code)
&& let Some((win, _)) = &mut self.menu.win
{
win.key(key, pressed);
}
}
}
WindowEvent::MouseInput { .. } | WindowEvent::MouseWheel { .. }
if self.menu.win.is_some() => {}
WindowEvent::CursorMoved { position, .. } => {
self.menu.pointer = (position.x, position.y);
self.update_menu_cursor();
@@ -575,6 +621,15 @@ impl Loading {
self.progress.line(),
self.progress.fraction(),
);
} else if let Some((win, frame)) = &mut self.menu.win {
let now = Instant::now();
// 20 ticks a second: the startup menu runs no tick rate.
let delta_ticks = now.duration_since(*frame).as_secs_f32() * 20.0;
*frame = now;
win.draw(batch, &gui.atlas, delta_ticks, Default::default());
if win.finished(batch.height) {
self.menu.win = None;
}
} else if self.menu.options_open {
self.menu
.options
+51 -16
View File
@@ -306,9 +306,18 @@ impl Game<'_> {
}
}
/// `PhantomSpawner.tick`: tired players under an open night sky draw
/// phantoms.
/// phantoms. A custom spawner ticks only while `spawn_mobs` is on
/// (`ServerChunkCache.tickChunks`), and this one only spawns enemies
/// (`ServerLevel.isSpawningMonsters`: `spawn_monsters` too). Every
/// player of the level counts: the game's own and the hosted ones (a
/// server thread's players are all hosted).
pub(super) fn spawn_phantoms(&mut self) {
if !self.ui.rules.bool("spawn_phantoms") || self.dim != Dim::Overworld {
let rules = &self.ui.rules;
if !rules.bool("spawn_mobs")
|| !rules.bool("spawn_monsters")
|| !rules.bool("spawn_phantoms")
|| self.dim != Dim::Overworld
{
return;
}
self.stats.phantom_tick -= 1;
@@ -317,16 +326,42 @@ impl Game<'_> {
}
let r = &mut self.world.random;
self.stats.phantom_tick += (60 + r.next_int_bound(60).unwrap_or(0)) * 20;
if self.sky_darken() < 5 || self.spectator() {
if self.sky_darken() < 5 {
return;
}
let Some(p) = self.player.as_ref() else {
return;
};
let rest = |s: &Stats| s.get("custom", "minecraft:time_since_rest");
// `level.players()`: (block position, time since rest) of each player
// who isn't a spectator.
let mut players = Vec::new();
if let Some(p) = self.player.as_ref()
&& !self.spectator()
{
players.push((p.position, rest(&self.stats)));
}
if let Some(host) = self.lan.as_ref() {
let level = self.dim.id();
for g in host.hub.guests.iter().filter(|g| {
g.placed
&& g.dimension == level
&& g.game_mode != horizon_client::lan::guest::SPECTATOR
}) {
let since = host.play.get(&g.conn).map_or(0, |p| rest(&p.stats));
players.push((
Vec3::new(g.position[0], g.position[1], g.position[2]),
since,
));
}
}
for (position, since) in players {
self.spawn_phantoms_near(position, since);
}
}
/// One player's turn of `PhantomSpawner.tick`.
fn spawn_phantoms_near(&mut self, position: Vec3, since_rest: i32) {
let pos = [
p.position.x.floor() as i32,
p.position.y.floor() as i32,
p.position.z.floor() as i32,
position.x.floor() as i32,
position.y.floor() as i32,
position.z.floor() as i32,
];
if pos[1] < 63 || !self.can_see_sky(pos) {
return;
@@ -336,15 +371,15 @@ impl Game<'_> {
if r.next_float() * 3.0 >= 2.0 {
return;
}
let rest = self.stats.get("custom", "minecraft:time_since_rest").max(1);
if r.next_int_bound(rest).unwrap_or(0) < 72000 {
if r.next_int_bound(since_rest.max(1)).unwrap_or(0) < 72000 {
return;
}
let at = [
pos[0] - 10 + r.next_int_bound(21).unwrap_or(10),
pos[1] + 20 + r.next_int_bound(15).unwrap_or(0),
pos[2] - 10 + r.next_int_bound(21).unwrap_or(10),
];
// `playerPos.above(20 + nextInt(15)).east(-10 + nextInt(21)).south(...)`:
// the height is drawn first.
let up = 20 + r.next_int_bound(15).unwrap_or(0);
let east = -10 + r.next_int_bound(21).unwrap_or(10);
let south = -10 + r.next_int_bound(21).unwrap_or(10);
let at = [pos[0] + east, pos[1] + up, pos[2] + south];
if !self
.res
.facts()
+150
View File
@@ -0,0 +1,150 @@
//! `WinScreen` in the game: the poem and credits after leaving the End for
//! the first time (`EndPortalBlock.entityInside` -> `ServerPlayer.showEndCredits`,
//! or a server's `WIN_GAME` event), and the credits from Credits &
//! Attribution.
use super::{Game, Screen};
use horizon_client::{
Result,
hud::{EndPortalFill, HudBatch},
win_screen::{Key, WinScreen},
};
use std::time::Instant;
use winit::{
event::{ElementState, KeyEvent},
keyboard::{KeyCode, PhysicalKey},
};
/// What closing the screen does (`WinScreen.onFinished`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Finish {
/// `PERFORM_RESPAWN` in single player: the player, out of the End since
/// the credits began, arrives in the Overworld.
Respawn,
/// `PERFORM_RESPAWN` sent to the server.
RemoteRespawn,
/// Back to Credits & Attribution.
Options,
/// Back to the game (`horizon-ctl credits`).
Closed,
}
pub(super) struct Win {
screen: WinScreen,
finish: Finish,
/// The last drawn frame, for the scroll's game-time delta.
frame: Instant,
}
impl Game<'_> {
pub(super) fn open_win_screen(&mut self, poem: bool, finish: Finish) {
let screen = WinScreen::new(
poem,
&self.options.resources,
&self.options.player_name(),
&self.gui.atlas.font,
);
self.win = Some(Win {
screen,
finish,
frame: Instant::now(),
});
self.keys.clear();
self.screen = Screen::Win;
self.capture(false);
}
/// `ServerPlayer.showEndCredits`: the player leaves the End now (no
/// longer in any level until the credits end) and has seen the credits.
pub(super) fn show_end_credits(&mut self) -> Result<()> {
self.level.seen_credits = true;
self.open_win_screen(true, Finish::Respawn);
self.end_portal_travel = true;
let result = self.enter_dimension(super::Dim::Overworld);
self.end_portal_travel = false;
result
}
/// Stop the scroll `scroll` pixels in (`horizon-ctl credits ... SCROLL`).
pub(super) fn hold_win_screen(&mut self, scroll: f32) {
if let Some(win) = self.win.as_mut() {
win.screen.hold(scroll);
}
}
/// `WinScreen.isPauseScreen`: only the credits without the poem pause.
pub(super) fn win_screen_pauses(&self) -> bool {
self.win.as_ref().is_some_and(|w| !w.screen.poem)
}
/// The player waits out of the world while the poem rolls.
pub(super) fn win_screen_holds_arrival(&self) -> bool {
self.win
.as_ref()
.is_some_and(|w| w.finish == Finish::Respawn)
}
pub(super) fn draw_win_screen(&mut self, batch: &mut HudBatch) {
let Some(win) = self.win.as_mut() else {
return;
};
let now = Instant::now();
// DeltaTracker.getGameTimeDeltaTicks: the frame's length in ticks.
let delta_ticks =
now.duration_since(win.frame).as_secs_f32() / self.clock.tick_duration().as_secs_f32();
win.frame = now;
let sprite = |n: &str| {
self.gui
.atlas
.sprites
.get(n)
.map_or([0.0; 4], |r| [r.u0, r.v0, r.u1 - r.u0, r.v1 - r.v0])
};
let fill = EndPortalFill {
sprites: [
sprite("environment/end_sky"),
sprite("entity/end_portal/end_portal"),
],
game_time: (self.world.game_time % 24000) as f32 / 24000.0,
};
win.screen.draw(batch, &self.gui.atlas, delta_ticks, fill);
}
/// `WinScreen.keyPressed` / `keyReleased`; Escape closes it (`onClose`).
pub(super) fn win_key(&mut self, event: &KeyEvent) -> Result<()> {
let PhysicalKey::Code(code) = event.physical_key else {
return Ok(());
};
let pressed = event.state == ElementState::Pressed;
if code == KeyCode::Escape && pressed {
return self.close_win_screen();
}
let Some(key) = Key::from_code(code) else {
return Ok(());
};
if let Some(win) = self.win.as_mut() {
win.screen.key(key, pressed);
}
Ok(())
}
/// `WinScreen.tick`: done once everything has rolled past the top.
pub(super) fn tick_win_screen(&mut self) -> Result<()> {
let height = self.gui_size().1;
if self.win.as_ref().is_some_and(|w| w.screen.finished(height)) {
self.close_win_screen()?;
}
Ok(())
}
/// `WinScreen.respawn` (`onFinished`) and `removed`: the credits music stops.
pub(super) fn close_win_screen(&mut self) -> Result<()> {
let Some(win) = self.win.take() else {
return Ok(());
};
let credits = horizon_client::music::Music::credits();
self.music
.stop_playing_music(&credits, None, &mut self.audio);
match win.finish {
Finish::Options => self.screen = Screen::Options,
Finish::Closed if self.title.is_some() => self.screen = Screen::Playing,
Finish::Respawn | Finish::RemoteRespawn | Finish::Closed => {
if win.finish == Finish::RemoteRespawn {
self.remote_respawn();
}
self.screen = Screen::Playing;
self.capture(true);
}
}
Ok(())
}
}
File diff suppressed because it is too large. Load diff
+590
View File
@@ -0,0 +1,590 @@
//! Animated block sprites, drawn on the GPU as vanilla 26.3 draws them:
//! `SpriteContents.AnimatedTexture` keeps every frame in video memory, and
//! each tick `animate_sprite_blit` or `animate_sprite_interpolate` draws the
//! frame shown (mixed with the next one for `interpolate`) into the atlas.
//!
//! Every frame's mip chain is uploaded once, at the sprite's own size. A tick
//! that changes sprites writes one small job per changed sprite and runs one
//! compute dispatch per mip level (`animate.wgsl`), which repeats the chain
//! into the array's larger layers by whole pixels and mixes interpolated
//! frames. Blending on the CPU at the array's size and copying every changed
//! sprite level by level (a copy command each, a blit each in Mesa) was about
//! a fifth of the render thread on an integrated GPU.
use super::upload::Uploads;
use crate::mipmap::Settings;
use crate::sprites::{FrameBlend, SpriteAnimation};
use wgpu::util::DeviceExt;
/// Bytes of `animate.wgsl`'s `Job`.
const JOB_BYTES: usize = 20;
/// `animate.wgsl`'s workgroup edge.
const WORKGROUP: u32 = 8;
struct Sprite {
animation: SpriteAnimation,
/// What its layer shows (`None`: the frame the array was created with,
/// rewritten on the first tick).
shown: Option<FrameBlend>,
/// Its layer in its sprite array.
layer: u32,
/// Each frame's mip chain: its offset in words in the group's frames.
frames: Vec<u32>,
/// Edge of the chains' first level.
edge: u32,
group: usize,
}
/// The animated sprites of one sprite array whose frames share one buffer.
struct Group {
/// Where its jobs start in `SpriteAnimator::jobs`, in bytes.
offset: u64,
/// One bind group per mip level of the array.
levels: Vec<wgpu::BindGroup>,
/// This tick's jobs.
jobs: Vec<u8>,
}
pub(super) struct SpriteAnimator {
sprites: Vec<Sprite>,
groups: Vec<Group>,
jobs: wgpu::Buffer,
pipeline: wgpu::ComputePipeline,
/// Edge of the arrays' layers.
size: u32,
}
impl SpriteAnimator {
/// `textures` are the sprite arrays, `split` layers each, `size` pixels
/// wide; `animations` hold their frames at that size.
pub fn new(
device: &wgpu::Device,
animations: &[SpriteAnimation],
size: u32,
settings: &[Settings],
textures: &[wgpu::Texture],
split: u32,
) -> Self {
let limits = device.limits();
let limit = limits
.max_storage_buffer_binding_size
.min(limits.max_buffer_size);
let mut frames: Vec<Vec<u8>> = Vec::new();
// Each group's sprite array and frames buffer.
let mut keys: Vec<(u32, usize)> = Vec::new();
let mut counts: Vec<usize> = Vec::new();
let mut sprites = Vec::with_capacity(animations.len());
for animation in animations {
let chains: Vec<_> = (0..animation.frame_count())
.map(|frame| source_chain(animation.pixels(frame), size, settings, animation.layer))
.collect();
let bytes: u64 = chains.iter().map(|(chain, _)| chain.len() as u64).sum();
if bytes > limit {
log::warn!(
"Horizon: layer {}'s animation ({bytes} bytes) does not fit in a storage buffer; it stays still",
animation.layer
);
continue;
}
if frames
.last()
.is_none_or(|data| data.len() as u64 + bytes > limit)
{
frames.push(Vec::new());
}
let buffer = frames.len() - 1;
let data = &mut frames[buffer];
let offsets = chains
.iter()
.map(|(chain, _)| {
let offset = (data.len() / 4) as u32;
data.extend_from_slice(chain);
offset
})
.collect();
let key = (animation.layer / split, buffer);
let group = keys.iter().position(|&k| k == key).unwrap_or_else(|| {
keys.push(key);
counts.push(0);
keys.len() - 1
});
counts[group] += 1;
sprites.push(Sprite {
animation: animation.clone(),
shown: None,
layer: animation.layer % split,
frames: offsets,
edge: chains[0].1,
group,
});
}
let frames: Vec<_> = frames
.iter()
.map(|data| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Animated sprite frames"),
contents: data,
usage: wgpu::BufferUsages::STORAGE,
})
})
.collect();
let align = u64::from(limits.min_storage_buffer_offset_alignment);
let mut offsets = Vec::with_capacity(counts.len());
let mut total = 0;
for &count in &counts {
offsets.push(total);
total = (total + (count * JOB_BYTES) as u64).next_multiple_of(align);
}
let jobs = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Animated sprite jobs"),
size: total.max(align),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Animated sprites"),
source: wgpu::ShaderSource::Wgsl(include_str!("animate.wgsl").into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Animated sprites"),
layout: None,
module: &module,
entry_point: Some("animate"),
compilation_options: Default::default(),
cache: None,
});
let layout = pipeline.get_bind_group_layout(0);
let groups = keys
.iter()
.zip(&counts)
.zip(offsets)
.map(|((&(array, buffer), &count), offset)| {
let texture = &textures[array as usize];
let levels = (0..texture.mip_level_count())
.map(|level| {
let view = texture.create_view(&wgpu::TextureViewDescriptor {
label: Some("Animated sprites"),
dimension: Some(wgpu::TextureViewDimension::D2Array),
base_mip_level: level,
mip_level_count: Some(1),
..Default::default()
});
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Animated sprites"),
layout: &layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: frames[buffer].as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &jobs,
offset,
size: wgpu::BufferSize::new((count * JOB_BYTES) as u64),
}),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&view),
},
],
})
})
.collect();
Group {
offset,
levels,
jobs: Vec::with_capacity(count * JOB_BYTES),
}
})
.collect();
Self {
sprites,
groups,
jobs,
pipeline,
size,
}
}
/// Show every sprite's frame at `tick`, rewriting only the sprites whose
/// frame or mix changed (`SpriteContents.AnimationState.tick`).
pub fn animate(
&mut self,
encoder: &mut wgpu::CommandEncoder,
uploads: &mut Uploads,
tick: u64,
) {
if self.collect(tick) == 0 {
return;
}
for group in &self.groups {
uploads.buffer(encoder, &self.jobs, group.offset, &group.jobs);
}
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Animated sprites"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
for group in &mut self.groups {
let count = (group.jobs.len() / JOB_BYTES) as u32;
if count == 0 {
continue;
}
for (level, bind) in group.levels.iter().enumerate() {
let groups = (self.size >> level).div_ceil(WORKGROUP);
pass.set_bind_group(0, bind, &[]);
pass.dispatch_workgroups(groups, groups, count);
}
group.jobs.clear();
}
}
/// Queue a job for every sprite whose blend at `tick` differs from what
/// its layer shows; the number of jobs.
fn collect(&mut self, tick: u64) -> usize {
let mut count = 0;
for sprite in &mut self.sprites {
let blend = sprite.animation.blend(tick);
if sprite.shown == Some(blend) {
continue;
}
sprite.shown = Some(blend);
let job = [
sprite.layer,
sprite.frames[blend.frame],
sprite.frames[blend.next],
blend.millis,
sprite.edge,
];
let jobs = &mut self.groups[sprite.group].jobs;
for word in job {
jobs.extend_from_slice(&word.to_le_bytes());
}
count += 1;
}
count
}
}
/// The mip chain a frame's layer is made of, and its first level's edge.
/// `mipmap::generate` makes a sprite scaled up by whole pixels to the
/// array's size from its own size's chain, its first level repeated into the
/// larger levels: that chain, which the shader repeats. Any other sprite: its
/// chain at the array's size.
fn source_chain(pixels: &[u8], size: u32, settings: &[Settings], layer: u32) -> (Vec<u8>, u32) {
let native = settings.get(layer as usize).map_or(0, |s| s.native_size);
let edge = if native.is_power_of_two() && native < size {
native
} else {
size
};
let frame = crate::sprites::resample(pixels, size, edge);
(super::mip_chain(&frame, edge, settings, layer, 1).0, edge)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mipmap::Strategy;
/// `animate.wgsl` on the CPU: `chain` (first level `edge` wide) as the
/// full chain of a `size` layer, mixed `millis` thousandths of the way
/// towards `next`.
fn reference(chain: &[u8], next: &[u8], millis: u32, edge: u32, size: u32) -> Vec<u8> {
let levels = size.trailing_zeros() + 1;
let mut out = Vec::new();
for level in 0..levels {
let width = size >> level;
for y in 0..width {
for x in 0..width {
let texel = if width >= edge {
let scale = width / edge;
y / scale * edge + x / scale
} else {
4 * (edge * edge - width * width) / 3 + y * width + x
} as usize;
for c in 0..4 {
let (a, b) = (
u32::from(chain[texel * 4 + c]),
u32::from(next[texel * 4 + c]),
);
out.push(((a * (1000 - millis) + b * millis + 500) / 1000) as u8);
}
}
}
}
out
}
fn frame(size: u32, seed: u32) -> Vec<u8> {
(0..size * size)
.flat_map(|i| {
let v = i
.wrapping_mul(2654435761)
.wrapping_add(seed.wrapping_mul(40503));
let alpha = match v >> 29 {
0 => 0,
1 => 77,
_ => 255,
};
[(v >> 3) as u8, (v >> 11) as u8, (v >> 19) as u8, alpha]
})
.collect()
}
fn settings(native: u32, strategy: Strategy) -> Settings {
Settings {
native_size: native,
strategy,
alpha_cutoff_bias: -0.1,
..Default::default()
}
}
#[test]
fn source_chains_repeat_into_the_array_chain() {
// Sprites scaled up by whole pixels, at the array's size, and of a
// size that is not a power of two (scaled up to the next one).
for (native, size) in [(16, 16), (16, 64), (8, 32), (32, 64), (1, 16), (24, 32)] {
for strategy in [
Strategy::Auto,
Strategy::Mean,
Strategy::Cutout,
Strategy::StrictCutout,
Strategy::DarkCutout,
] {
for item in [false, true] {
let settings = [
Settings::default(),
Settings {
item,
..settings(native, strategy)
},
];
let pixels = crate::sprites::resample(&frame(native, 3), native, size);
let (expected, _) = super::super::mip_chain(&pixels, size, &settings, 1, 1);
let (chain, edge) = source_chain(&pixels, size, &settings, 1);
let fits = native.is_power_of_two() && native < size;
assert_eq!(edge, if fits { native } else { size });
assert_eq!(
reference(&chain, &chain, 0, edge, size),
expected,
"{native} in {size}, {strategy:?}, item {item}"
);
}
}
}
}
#[test]
fn real_animation_chains_repeat_into_the_array_chain() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root
.join("assets/minecraft/textures/block/water_still.png")
.exists()
{
return;
}
let mut sprites = crate::sprites::Sprites::default();
for name in [
"water_still",
"water_flow",
"lava_still",
"fire_0",
"nether_portal",
"sea_lantern",
"prismarine",
"magma",
"kelp",
"straw_bed",
] {
sprites
.insert(&root, &format!("minecraft:block/{name}"))
.unwrap();
}
sprites.finish();
assert_eq!(sprites.size, 64);
assert_eq!(sprites.animations.len(), 9);
for animation in &sprites.animations {
for frame in 0..animation.frame_count() {
let pixels = animation.pixels(frame);
let (expected, _) =
super::super::mip_chain(pixels, 64, &sprites.mips, animation.layer, 1);
let (chain, edge) = source_chain(pixels, 64, &sprites.mips, animation.layer);
assert!(edge < 64, "layer {}", animation.layer);
assert_eq!(reference(&chain, &chain, 0, edge, 64), expected);
}
}
}
#[test]
fn rounding_matches_f32_mix_but_for_exact_halves() {
// The CPU blend this replaced: f32 mix, then rounded. They differ
// only where the exact mix is a half, which f32 rounded either way.
for millis in 1..1000u32 {
let t = millis as f32 / 1000.0;
for a in 0..=255u32 {
for b in (0..=255u32).step_by(3) {
let f = ((a as f32 / 255.0) * (1.0 - t) + (b as f32 / 255.0) * t) * 255.0;
let exact = a * (1000 - millis) + b * millis;
let ours = (exact + 500) / 1000;
if exact % 1000 != 500 {
assert_eq!(ours, f.round() as u32, "{a} {b} {millis}");
}
}
}
}
}
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// Every layer's full mip chain, layer major.
fn read_back(
device: &wgpu::Device,
queue: &wgpu::Queue,
texture: &wgpu::Texture,
) -> Vec<Vec<u8>> {
let layers = texture.depth_or_array_layers();
let mut out = vec![Vec::new(); layers as usize];
for level in 0..texture.mip_level_count() {
let edge = texture.width() >> level;
let padded = (edge * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT);
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(padded * edge * layers),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture,
mip_level: level,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded),
rows_per_image: Some(edge),
},
},
wgpu::Extent3d {
width: edge,
height: edge,
depth_or_array_layers: layers,
},
);
queue.submit([encoder.finish()]);
buffer
.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = buffer.slice(..).get_mapped_range().unwrap();
for (layer, image) in data.chunks((padded * edge) as usize).enumerate() {
for row in image.chunks(padded as usize) {
out[layer].extend_from_slice(&row[..edge as usize * 4]);
}
}
}
out
}
#[test]
fn gpu_layers_match_the_cpu_chains_every_tick() {
let Some((device, queue)) = device() else {
return;
};
let size = 32;
// Layers 1 and 3: 8-pixel sprites with and without interpolation;
// layer 4: one at the array's size. Two layers per array.
let clips = [
(
1,
8,
r#"{"frametime":3,"interpolate":true,"frames":[0,2,1]}"#,
),
(3, 8, r#"{"frametime":2}"#),
(4, 32, r#"{"frametime":5,"interpolate":true}"#),
];
let mut mips = vec![Settings::default(); 6];
let animations: Vec<_> = clips
.iter()
.map(|&(layer, native, metadata)| {
mips[layer as usize] = settings(native, Strategy::Cutout);
let frames = (0..3)
.map(|f| crate::sprites::resample(&frame(native, layer * 7 + f), native, size))
.collect();
crate::sprites::test_frames(layer, frames, metadata)
})
.collect();
let split = 2;
let textures: Vec<_> = (0..3)
.map(|_| {
device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: split,
},
mip_level_count: size.trailing_zeros() + 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
})
.collect();
let mut animator = SpriteAnimator::new(&device, &animations, size, &mips, &textures, split);
let mut uploads = Uploads::new(&device);
let mut writes = Vec::new();
for tick in [0, 0, 1, 2, 3, 4, 7, 7, 12, 30] {
let mut encoder = device.create_command_encoder(&Default::default());
let shown: Vec<_> = animator.sprites.iter().map(|s| s.shown).collect();
animator.animate(&mut encoder, &mut uploads, tick);
writes.push(
animator
.sprites
.iter()
.zip(shown)
.filter(|(s, shown)| s.shown != *shown)
.count(),
);
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
for animation in &animations {
let blend = animation.blend(tick);
let chain = |f| source_chain(animation.pixels(f), size, &mips, animation.layer);
let ((current, edge), (next, _)) = (chain(blend.frame), chain(blend.next));
let layers = read_back(
&device,
&queue,
&textures[(animation.layer / split) as usize],
);
assert_eq!(
layers[(animation.layer % split) as usize],
reference(&current, &next, blend.millis, edge, size),
"layer {} at tick {tick}",
animation.layer
);
}
}
// Every sprite on the first tick, then only those whose frame or mix
// changed since they were written: the interpolated ones every tick
// but layer 1 at tick 30 (two 9-tick loops after 12), layer 3 when
// its frame turns over (every 2 ticks, but 12 shows 7's frame).
assert_eq!(writes, [3, 0, 2, 3, 2, 3, 3, 0, 2, 1]);
}
}
@@ -0,0 +1,48 @@
// Animated block sprites (gfx/animate.rs), as vanilla's animate_sprite_blit
// and animate_sprite_interpolate shaders draw them: each job writes its
// frame, or the frame mixed with the next one, into one mip level of its
// sprite array layer. One invocation per texel; the job is the z coordinate.
struct Job {
layer: u32,
// Word offsets of the two frames' mip chains in `frames`.
current: u32,
next: u32,
// Thousandths of the way from `current` to `next` (0: `current` alone).
millis: u32,
// Edge of the chains' first level.
edge: u32,
}
@group(0) @binding(0) var<storage, read> frames: array<u32>;
@group(0) @binding(1) var<storage, read> jobs: array<Job>;
@group(0) @binding(2) var level: texture_storage_2d_array<rgba8unorm, write>;
fn channels(rgba: u32) -> vec4<u32> {
return vec4(rgba & 0xffu, (rgba >> 8u) & 0xffu, (rgba >> 16u) & 0xffu, rgba >> 24u);
}
@compute @workgroup_size(8, 8)
fn animate(@builtin(global_invocation_id) id: vec3<u32>) {
let size = textureDimensions(level).x;
if id.x >= size || id.y >= size {
return;
}
let job = jobs[id.z];
// mipmap::generate: the levels at least as large as the chain's first
// repeat it by whole pixels, the smaller ones are the chain's own.
var texel: u32;
if size >= job.edge {
let scale = size / job.edge;
texel = id.y / scale * job.edge + id.x / scale;
} else {
// Every level above this one, each a quarter of the one before.
texel = 4u * (job.edge * job.edge - size * size) / 3u + id.y * size + id.x;
}
var color = channels(frames[job.current + texel]);
if job.millis > 0u {
// mix(current, next, millis / 1000), rounded to the nearest 8-bit
// value (halves up) in integers: the same on every GPU.
let next = channels(frames[job.next + texel]);
color = (color * (1000u - job.millis) + next * job.millis + 500u) / 1000u;
}
textureStore(level, id.xy, job.layer, vec4<f32>(color) / 255.0);
}
+187 -30
View File
@@ -22,8 +22,16 @@
//! Every solid draw is an entry of one of the two lists, so the CPU records
//! as many draws as without culling: drivers that unroll an indirect
//! multi-draw on the CPU (Mesa's on Intel Gen9) pay per entry. Phase 2's
//! counts come from the GPU, which needs a native multi-draw and forced
//! early depth tests: Vulkan. Translucent draws are not culled.
//! counts come from the GPU, which reads them from the draws themselves:
//! Vulkan's native multi-draw, and Metal's, which wgpu replays as one
//! indirect draw per entry. Translucent draws are not culled.
//!
//! Metal cannot force the depth test before a fragment shader that writes
//! storage (naga writes no `[[early_fragment_tests]]`), so there the boxes
//! are drawn in a pass of their own between the phases, which reads phase
//! 1's depth as a texture and makes the same comparison in the shader: the
//! fragment's depth against the stored one, greater or equal passing. The
//! same fragments pass, so the same sections are seen.
use std::num::NonZeroU64;
use std::sync::{
Arc,
@@ -38,6 +46,13 @@ const RETEST: u32 = 8;
/// more than they save. Else one frame in `PROBE` tests every section.
const MIN_SHARE: f32 = 0.1;
const PROBE: u32 = 64;
/// On Metal, section draws from which a frame culls. Apple's GPUs store and
/// reload the whole frame at each of the passes the test adds, a fixed cost
/// that the sections it hides repay only at high render distances: on an M4
/// Pro at 1080p and render distance 12 (1,000–1,600 draws) culling added
/// about 0.05 ms of GPU time while hiding 15–30% of the solid indices; at
/// 32 (drawn flat, over this many draws) it saved 0.25–1.6 ms.
pub const TILED_MIN_DRAWS: usize = 4096;
/// Boxes over more than this share of the screen count as seen untested.
const BIG: f32 = 0.25;
/// Results read back at once, some frames late so the CPU never waits.
@@ -74,10 +89,40 @@ struct Group {
group: wgpu::BindGroup,
}
/// Where the boxes' depth test runs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum DepthTest {
/// Forced before the fragment shader, in phase 1's pass (Vulkan).
Early,
/// In the fragment shader, against phase 1's depth read as a texture,
/// in a pass of its own (Metal).
InShader,
}
/// The Vulkan entry point: the depth test forced early, which naga cannot
/// write for Metal (so it is not in `cull.wgsl`, which every backend takes).
const EARLY_TEST: &str = "
@fragment
@early_depth_test(force)
fn fs_box_early(in: Box) -> @location(0) vec4<f32> {
see(in.test);
return vec4(0.0);
}
";
/// The test's depth texture binding (`InShader`), for the view it was made with.
struct DepthGroup {
view: wgpu::TextureView,
group: wgpu::BindGroup,
}
pub struct Cull {
pipeline: wgpu::RenderPipeline,
layout: wgpu::BindGroupLayout,
group: Option<Group>,
depth_test: DepthTest,
depth_layout: Option<wgpu::BindGroupLayout>,
depth_group: Option<DepthGroup>,
params: wgpu::Buffer,
/// Phase 2's draws: the solid draws after phase 1's in the frame's list.
pub phase2: wgpu::Buffer,
@@ -103,6 +148,8 @@ pub struct Cull {
pub hidden: ([usize; 2], f32),
/// Cull every frame, whatever the hidden share (A/B checks).
pub always: bool,
/// Frames with fewer section draws than this do not cull.
pub min_draws: usize,
}
fn buffer(
@@ -134,20 +181,33 @@ impl Cull {
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
adapter.features() & wgpu::Features::SHADER_EARLY_DEPTH_TEST
}
/// Whether the device draws an indirect multi-draw natively and runs
/// fragment shaders that write storage only past an early depth test.
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> bool {
adapter.get_info().backend == wgpu::Backend::Vulkan
&& device.features().contains(
wgpu::Features::SHADER_EARLY_DEPTH_TEST | wgpu::Features::INDIRECT_FIRST_INSTANCE,
)
/// Where the device can run the test, if it can: Vulkan with early
/// depth tests, or Metal, both drawing an indirect multi-draw's entries
/// with the instance counts the GPU wrote, with fragment shaders that
/// write storage.
pub fn supported(adapter: &wgpu::Adapter, device: &wgpu::Device) -> Option<DepthTest> {
let depth_test = match adapter.get_info().backend {
wgpu::Backend::Vulkan
if device
.features()
.contains(wgpu::Features::SHADER_EARLY_DEPTH_TEST) =>
{
DepthTest::Early
}
wgpu::Backend::Metal => DepthTest::InShader,
_ => return None,
};
(device
.features()
.contains(wgpu::Features::INDIRECT_FIRST_INSTANCE)
&& adapter.get_downlevel_capabilities().flags.contains(
wgpu::DownlevelFlags::VERTEX_STORAGE
| wgpu::DownlevelFlags::FRAGMENT_WRITABLE_STORAGE,
)
))
.then_some(depth_test)
}
/// `color` is the world pass's colour format.
pub fn new(device: &wgpu::Device, color: wgpu::TextureFormat) -> Self {
pub fn new(device: &wgpu::Device, color: wgpu::TextureFormat, depth_test: DepthTest) -> Self {
let entry = |binding, visibility, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility,
@@ -180,13 +240,41 @@ impl Cull {
});
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Section culling"),
source: wgpu::ShaderSource::Wgsl(include_str!("cull.wgsl").into()),
source: wgpu::ShaderSource::Wgsl(source(depth_test).into()),
});
let depth_layout = (depth_test == DepthTest::InShader).then(|| {
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Section culling depth"),
entries: &[entry(
0,
fragment,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Depth,
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
)],
})
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Section culling"),
bind_group_layouts: &[Some(&layout)],
bind_group_layouts: &[Some(&layout), depth_layout.as_ref()],
..Default::default()
});
// In the shader: no colour target, and phase 1's depth attached read
// only (a pass needs an attachment), its test left to the shader.
let (fragment_entry, targets, depth_compare) = match depth_test {
DepthTest::Early => (
"fs_box_early",
&[Some(wgpu::ColorTargetState {
format: color,
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})][..],
wgpu::CompareFunction::GreaterEqual,
),
DepthTest::InShader => ("fs_box", &[][..], wgpu::CompareFunction::Always),
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Section culling"),
layout: Some(&pipeline_layout),
@@ -204,20 +292,16 @@ impl Cull {
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
depth_compare: Some(depth_compare),
stencil: Default::default(),
bias: Default::default(),
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_box"),
entry_point: Some(fragment_entry),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format: color,
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})],
targets,
}),
multiview_mask: None,
cache: None,
@@ -226,6 +310,9 @@ impl Cull {
pipeline,
layout,
group: None,
depth_test,
depth_layout,
depth_group: None,
params: buffer(
device,
"Section culling parameters",
@@ -259,6 +346,7 @@ impl Cull {
stamp: 0,
hidden: ([0; 2], 1.0),
always: false,
min_draws: 0,
}
}
@@ -399,17 +487,69 @@ impl Cull {
}
}
/// In the world pass after phase 1: draw the tested boxes against its
/// depth. Leaves the pass's pipeline and group 0 changed.
pub fn depth_test(&self) -> DepthTest {
self.depth_test
}
/// In the world pass after phase 1 (`Early`): draw the tested boxes
/// against its depth. Leaves the pass's pipeline and group 0 changed.
pub fn test(&self, pass: &mut wgpu::RenderPass<'_>) {
let Some(Group { group, .. }) = &self.group else {
return;
};
if self.depth_test != DepthTest::Early || self.test_data.is_empty() {
return;
}
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.draw(0..36, 0..self.test_data.len() as u32);
}
/// Before the world pass (`InShader`): the binding of the depth the
/// world is drawn with this frame.
pub fn bind_depth(&mut self, device: &wgpu::Device, depth: &wgpu::TextureView) {
let Some(layout) = &self.depth_layout else {
return;
};
if self.depth_group.as_ref().is_none_or(|g| g.view != *depth) {
self.depth_group = Some(DepthGroup {
view: depth.clone(),
group: device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Section culling depth"),
layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(depth),
}],
}),
});
}
}
/// Between the phases (`InShader`): draw the tested boxes in a pass of
/// their own that reads phase 1's depth, bound by `bind_depth`.
pub fn test_pass(&self, encoder: &mut wgpu::CommandEncoder) {
let (Some(Group { group, .. }), Some(DepthGroup { view, group: depth })) =
(&self.group, &self.depth_group)
else {
return;
};
if self.test_data.is_empty() {
return;
}
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Section culling"),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view,
depth_ops: None,
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.set_bind_group(1, depth, &[]);
pass.draw(0..36, 0..self.test_data.len() as u32);
}
@@ -498,16 +638,33 @@ impl Cull {
}
}
/// The test's shader for `depth_test`.
fn source(depth_test: DepthTest) -> String {
let common = include_str!("cull.wgsl");
match depth_test {
DepthTest::Early => format!("{common}{EARLY_TEST}"),
DepthTest::InShader => common.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("cull.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap();
fn shaders_validate() {
for (depth_test, capabilities) in [
(DepthTest::Early, naga::valid::Capabilities::all()),
// What wgpu's Metal backend allows: no early depth test.
(
DepthTest::InShader,
naga::valid::Capabilities::all() - naga::valid::Capabilities::EARLY_DEPTH_TEST,
),
] {
let module = naga::front::wgsl::parse_str(&source(depth_test)).unwrap();
naga::valid::Validator::new(naga::valid::ValidationFlags::all(), capabilities)
.validate(&module)
.unwrap_or_else(|e| panic!("{depth_test:?}: {e:?}"));
}
}
}
+20 -8
View File
@@ -86,16 +86,28 @@ fn vs_box(@builtin(vertex_index) v: u32, @builtin(instance_index) t: u32) -> Box
return out;
}
// Runs only for fragments that pass the depth test (forced early).
@fragment
@early_depth_test(force)
fn fs_box(in: Box) -> @location(0) vec4<f32> {
if results[in.test] != params.stamp {
results[in.test] = params.stamp;
let test = tests[in.test];
// The box is seen at this fragment: store the stamp, and give its phase 2
// draws their instance.
fn see(t: u32) {
if results[t] != params.stamp {
results[t] = params.stamp;
let test = tests[t];
for (var k = 0u; k < test.z; k++) {
phase2[(test.y + k) * 5u + 1u] = 1u;
}
}
return vec4(0.0);
}
// Phase 1's depth, where the test runs in the shader (gfx/cull.rs).
@group(1) @binding(0) var depth: texture_depth_2d;
// The world pass's reversed depth test, greater or equal passing, made here:
// where it cannot be forced before a shader that writes storage, a fragment
// that fails it would still write. Where it can, `fs_box_early` (cull.rs)
// runs only for the fragments that pass it.
@fragment
fn fs_box(in: Box) {
if in.position.z >= textureLoad(depth, vec2<u32>(in.position.xy), 0) {
see(in.test);
}
}
+455 -132
View File
@@ -11,11 +11,37 @@
//! so each block names one section: its id here, which indexes the frame's
//! camera-relative origins (computed on the CPU exactly as for the instance
//! attribute).
//!
//! With occlusion culling (`cull`), phase 1's draws and the translucent ones
//! are copied before the world pass, as without it. Phase 2's draws are
//! copied after the test, which tells which ones it saw: each run of a page
//! reserves room for all of its draws, the copy packs the seen ones at its
//! start, and the run is drawn indirectly with the count the copy reached.
use super::arena::GRAIN;
/// One section draw for `flatten.wgsl`: index count, first sorted index (or
/// `QUADS` for a solid draw), base vertex, first index in the list.
/// `QUADS` for a solid draw; phase 2: its entry in phase 2's draws), base
/// vertex, first index in the list (phase 2: its draw call in phase 2).
type Range = [u32; 4];
/// Where a group of ranges takes its indices.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Source {
/// Solid draws: quads.
Quads,
/// Occlusion culling's phase 2: quads, of the sections its test saw.
Seen,
/// Translucent draws: this sorted index page.
Sorted(u32),
}
/// The frame's draw calls, as `plan` lays them out.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Plan {
phase1_calls: usize,
solid_calls: usize,
total: u64,
}
const QUADS: u32 = u32::MAX;
const GROUP_LIMIT: u32 = 65535;
@@ -38,6 +64,15 @@ pub struct Flat {
pub translucent: wgpu::RenderPipeline,
flatten: wgpu::ComputePipeline,
compute_layout: wgpu::BindGroupLayout,
flatten_seen: wgpu::ComputePipeline,
seen_layout: wgpu::BindGroupLayout,
/// Phase 2's draw calls (`DrawIndexedIndirectArgs`), whose index counts
/// `flatten_seen` makes.
phase2_calls: wgpu::Buffer,
phase2_call_capacity: u64,
phase2_call_data: Vec<[u32; 5]>,
/// The copy of phase 2's draws, and its ranges.
phase2_group: Option<(wgpu::BindGroup, u32)>,
draw_layout: wgpu::BindGroupLayout,
blocks_per_page: u64,
pages: Vec<Option<PageTable>>,
@@ -52,12 +87,14 @@ pub struct Flat {
ranges: wgpu::Buffer,
range_capacity: u64,
range_data: Vec<Range>,
/// Segments of `range_data` by index source: (sorted index page or
/// `None` for solid draws, first range, ranges).
segments: Vec<(Option<u32>, u64, u64)>,
/// Segments of `range_data` by index source: (source, first range,
/// ranges).
segments: Vec<(Source, u64, u64)>,
empty: wgpu::Buffer,
/// This frame's draw calls: solid ones, then translucent ones.
/// This frame's draw calls: solid ones (phase 1's, then phase 2's when
/// culling), then translucent ones.
pub calls: Vec<Call>,
pub phase1_calls: usize,
pub solid_calls: usize,
/// Frames with fewer section draws keep the per-section draws.
pub min_draws: usize,
@@ -121,42 +158,60 @@ fn buffer(
/// Lays out the frame's index list: each draw's range (destinations in draw
/// order) grouped by index source into `ranges`, every group padded to
/// `align` entries for binding, with `segments` naming each group; and the
/// draw calls, one per run of draws in the same vertex page, solid and
/// translucent apart. Returns the first translucent call and the list's
/// length.
/// draw calls, one per run of draws in the same vertex page, phase 1's solid
/// ones, phase 2's (draws `phase1_draws..solid_draws`) and translucent ones
/// apart. A phase 2 range names its draw in phase 2 and its call in phase 2
/// instead of a destination: the call's room holds all of its draws.
fn plan(
draws: &[[u32; 5]],
pages: &[(u32, Option<u32>)],
phase1_draws: usize,
solid_draws: usize,
align: u64,
calls: &mut Vec<Call>,
ranges: &mut Vec<Range>,
segments: &mut Vec<(Option<u32>, u64, u64)>,
) -> (usize, u64) {
segments: &mut Vec<(Source, u64, u64)>,
) -> Plan {
calls.clear();
ranges.clear();
segments.clear();
let mut solid_calls = None;
let (mut phase1_calls, mut solid_calls) = (None, None);
let mut total = 0u64;
let mut by_source: Vec<(Option<u32>, Vec<Range>)> = Vec::new();
let mut by_source: Vec<(Source, Vec<Range>)> = Vec::new();
for (i, (d, &(page, sorted))) in draws.iter().zip(pages).enumerate() {
let first = if sorted.is_some() { d[2] } else { QUADS };
let range = [d[0], first, d[3], total as u32];
match by_source.iter_mut().find(|(s, _)| *s == sorted) {
Some((_, list)) => list.push(range),
None => by_source.push((sorted, vec![range])),
if i == phase1_draws {
phase1_calls.get_or_insert(calls.len());
}
if i == solid_draws {
solid_calls = Some(calls.len());
}
match calls.last_mut() {
Some(last) if last.page == page && i != solid_draws => last.count += d[0],
Some(last) if last.page == page && i != phase1_draws && i != solid_draws => {
last.count += d[0]
}
_ => calls.push(Call {
page,
first: total as u32,
count: d[0],
}),
}
let (source, range) = match sorted {
Some(p) => (Source::Sorted(p), [d[0], d[2], d[3], total as u32]),
None if (phase1_draws..solid_draws).contains(&i) => (
Source::Seen,
[
d[0],
(i - phase1_draws) as u32,
d[3],
(calls.len() - 1 - phase1_calls.unwrap_or(0)) as u32,
],
),
None => (Source::Quads, [d[0], QUADS, d[3], total as u32]),
};
match by_source.iter_mut().find(|(s, _)| *s == source) {
Some((_, list)) => list.push(range),
None => by_source.push((source, vec![range])),
}
total += u64::from(d[0]);
}
for (source, list) in by_source {
@@ -165,7 +220,12 @@ fn plan(
let padded = (ranges.len() as u64).next_multiple_of(align);
ranges.resize(padded as usize, [0; 4]);
}
(solid_calls.unwrap_or(calls.len()), total)
let solid_calls = solid_calls.unwrap_or(calls.len());
Plan {
phase1_calls: phase1_calls.unwrap_or(calls.len()).min(solid_calls),
solid_calls,
total,
}
}
impl Flat {
@@ -201,24 +261,37 @@ impl Flat {
storage(2, compute, false),
],
});
let seen_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Flatten phase 2 section draws"),
entries: &[
storage(0, compute, true),
storage(2, compute, false),
storage(3, compute, true),
storage(4, compute, false),
],
});
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Flatten section draws"),
source: wgpu::ShaderSource::Wgsl(include_str!("flatten.wgsl").into()),
});
let flatten = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Flatten section draws"),
layout: Some(
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Flatten section draws"),
bind_group_layouts: &[Some(&compute_layout)],
..Default::default()
}),
),
module: &module,
entry_point: Some("flatten"),
compilation_options: Default::default(),
cache: None,
});
let compute_pipeline = |layout: &wgpu::BindGroupLayout, entry| {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Flatten section draws"),
layout: Some(
&device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Flatten section draws"),
bind_group_layouts: &[Some(layout)],
..Default::default()
}),
),
module: &module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
})
};
let flatten = compute_pipeline(&compute_layout, "flatten");
let flatten_seen = compute_pipeline(&seen_layout, "flatten_seen");
let storage_usage = wgpu::BufferUsages::STORAGE;
Self {
solid: pipeline(&layout, "Terrain solid (flat)", "fs_solid", None),
@@ -230,6 +303,17 @@ impl Flat {
),
flatten,
compute_layout,
flatten_seen,
seen_layout,
phase2_calls: buffer(
device,
"Flat section draw calls, phase 2",
20,
wgpu::BufferUsages::INDIRECT | storage_usage,
),
phase2_call_capacity: 1,
phase2_call_data: Vec::new(),
phase2_group: None,
draw_layout,
blocks_per_page: page_vertices.div_ceil(GRAIN),
pages: Vec::new(),
@@ -251,6 +335,7 @@ impl Flat {
segments: Vec::new(),
empty: buffer(device, "No sorted indices", 16, storage_usage),
calls: Vec::new(),
phase1_calls: 0,
solid_calls: 0,
min_draws: if std::env::var("HORIZON_FLAT_DRAWS").as_deref() == Ok("1") {
0
@@ -321,6 +406,9 @@ impl Flat {
/// the origins of the sections they draw by id. Records the copy into
/// `encoder`; `calls` then lists the frame's draw calls. False when the
/// frame cannot be drawn flat (the per-section draws then apply).
///
/// When culling, the solid draws from `phase1_draws` are phase 2's, which
/// `phase2` holds as the test leaves them: `flatten_phase2` copies them.
#[allow(clippy::too_many_arguments)]
pub fn prepare(
&mut self,
@@ -329,10 +417,18 @@ impl Flat {
uploads: &mut super::upload::Uploads,
draws: &[[u32; 5]],
pages: &[(u32, Option<u32>)],
phase1_draws: usize,
solid_draws: usize,
phase2: Option<&wgpu::Buffer>,
origins: impl Iterator<Item = (u32, [f32; 4])>,
sorted_page: impl Fn(u32) -> wgpu::Buffer,
) -> bool {
self.phase2_group = None;
let phase1_draws = if phase2.is_some() {
phase1_draws
} else {
solid_draws
};
// Origins by section id.
self.origin_data.clear();
for (id, o) in origins {
@@ -362,18 +458,25 @@ impl Flat {
bytemuck::cast_slice(&self.origin_data),
);
}
let (solid_calls, total) = plan(
let Plan {
phase1_calls,
solid_calls,
total,
} = plan(
draws,
pages,
phase1_draws,
solid_draws,
self.range_align,
&mut self.calls,
&mut self.range_data,
&mut self.segments,
);
self.phase1_calls = phase1_calls;
self.solid_calls = solid_calls;
if total == 0 || total * 4 > device.limits().max_storage_buffer_binding_size {
self.calls.clear();
self.phase1_calls = 0;
self.solid_calls = 0;
return false;
}
@@ -401,22 +504,82 @@ impl Flat {
0,
bytemuck::cast_slice(&self.range_data),
);
// Phase 2's calls start empty, each at the start of its room.
self.phase2_call_data.clear();
self.phase2_call_data.extend(
self.calls[phase1_calls..solid_calls]
.iter()
.map(|c| [0, 1, c.first, 0, 0]),
);
let phase2_calls = self.phase2_call_data.len() as u64;
if phase2_calls > self.phase2_call_capacity {
self.phase2_call_capacity = phase2_calls.next_power_of_two();
self.phase2_calls = buffer(
device,
"Flat section draw calls, phase 2",
self.phase2_call_capacity * 20,
wgpu::BufferUsages::INDIRECT | wgpu::BufferUsages::STORAGE,
);
}
if phase2_calls > 0 {
uploads.buffer(
encoder,
&self.phase2_calls,
0,
bytemuck::cast_slice(&self.phase2_call_data),
);
}
let ranges_at = |first: u64, count: u64| {
wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &self.ranges,
offset: first * 16,
size: wgpu::BufferSize::new(count * 16),
})
};
if let (Some(phase2), Some(&(_, first, count))) = (
phase2,
self.segments.iter().find(|(s, ..)| *s == Source::Seen),
) {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Flatten phase 2 section draws"),
layout: &self.seen_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: ranges_at(first, count),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.indices.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: phase2.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: self.phase2_calls.as_entire_binding(),
},
],
});
self.phase2_group = Some((group, count as u32));
}
let groups: Vec<(wgpu::BindGroup, u32)> = self
.segments
.iter()
.map(|&(source, first, count)| {
let sorted = source.map(&sorted_page);
.filter_map(|&(source, first, count)| {
let sorted = match source {
Source::Quads => None,
Source::Seen => return None,
Source::Sorted(page) => Some(sorted_page(page)),
};
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Flatten section draws"),
layout: &self.compute_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &self.ranges,
offset: first * 16,
size: wgpu::BufferSize::new(count * 16),
}),
resource: ranges_at(first, count),
},
wgpu::BindGroupEntry {
binding: 1,
@@ -428,7 +591,7 @@ impl Flat {
},
],
});
(group, count as u32)
Some((group, count as u32))
})
.collect();
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
@@ -464,6 +627,19 @@ impl Flat {
}
true
}
/// After occlusion culling's test: copies the phase 2 draws it saw.
pub fn flatten_phase2(&self, encoder: &mut wgpu::CommandEncoder) {
let Some((group, count)) = &self.phase2_group else {
return;
};
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Flatten phase 2 section draws"),
timestamp_writes: None,
});
pass.set_pipeline(&self.flatten_seen);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups((*count).min(GROUP_LIMIT), count.div_ceil(GROUP_LIMIT), 1);
}
/// Draws `calls` with a flat pipeline set and `vertices` giving each
/// page's vertex buffer.
pub fn draw<'a>(
@@ -474,15 +650,38 @@ impl Flat {
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for call in calls {
let table = self.pages[call.page as usize]
.as_ref()
.and_then(|t| t.group.as_ref())
.expect("prepared page");
pass.set_vertex_buffer(0, vertices(call.page).slice(..));
pass.set_bind_group(1, table, &[]);
self.bind_page(pass, call.page, &vertices);
pass.draw_indexed(call.first..call.first + call.count, 0, 0..1);
}
}
/// 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,
) {
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
for (k, call) in self.calls[self.phase1_calls..self.solid_calls]
.iter()
.enumerate()
{
self.bind_page(pass, call.page, &vertices);
pass.draw_indexed_indirect(&self.phase2_calls, k as u64 * 20);
}
}
fn bind_page<'a>(
&self,
pass: &mut wgpu::RenderPass<'_>,
page: u32,
vertices: &impl Fn(u32) -> &'a wgpu::Buffer,
) {
let table = self.pages[page as usize]
.as_ref()
.and_then(|t| t.group.as_ref())
.expect("prepared page");
pass.set_vertex_buffer(0, vertices(page).slice(..));
pass.set_bind_group(1, table, &[]);
}
}
#[cfg(test)]
@@ -516,16 +715,22 @@ mod tests {
fn plan_draws_runs_of_a_page_in_order() {
let (draws, pages, solid) = sample();
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
let (solid_calls, total) = plan(
let Plan {
phase1_calls,
solid_calls,
total,
} = plan(
&draws,
&pages,
solid,
solid,
4,
&mut calls,
&mut ranges,
&mut segments,
);
assert_eq!(total, 6 * 13);
assert_eq!(phase1_calls, solid_calls);
let call = |page, first, count| Call { page, first, count };
// Pages 0, 0 | 1 | 0, then translucent 0, 0 (never merged with solid).
assert_eq!(
@@ -539,16 +744,65 @@ mod tests {
);
assert_eq!(solid_calls, 3);
// Solid ranges, then each sorted page's, every group padded to 4.
assert_eq!(segments, [(None, 0, 4), (Some(0), 4, 1), (Some(1), 8, 1)]);
assert_eq!(
segments,
[
(Source::Quads, 0, 4),
(Source::Sorted(0), 4, 1),
(Source::Sorted(1), 8, 1)
]
);
assert_eq!(ranges[3], [24, QUADS, 1024, 36]);
assert_eq!(ranges[4], [12, 6, 768, 60]);
assert_eq!(ranges[8], [6, 0, 40, 72]);
assert_eq!(ranges.len(), 12);
}
/// Phase 2's draws (here the third and fourth) get calls of their own,
/// each with room for all of its draws, and ranges naming their draw
/// and call in phase 2.
#[test]
fn plan_gives_phase_2_calls_of_its_own() {
let (draws, pages, solid) = sample();
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
let plan = plan(
&draws,
&pages,
2,
solid,
4,
&mut calls,
&mut ranges,
&mut segments,
);
assert_eq!(
plan,
Plan {
phase1_calls: 1,
solid_calls: 3,
total: 6 * 13
}
);
let call = |page, first, count| Call { page, first, count };
// Phase 1: page 0 | phase 2: page 1, page 0 | translucent: page 0.
assert_eq!(
calls,
[
call(0, 0, 30),
call(1, 30, 6),
call(0, 36, 24),
call(0, 60, 18)
]
);
assert_eq!(segments[1], (Source::Seen, 4, 2));
assert_eq!(ranges[4], [6, 0, 256, 0]);
assert_eq!(ranges[5], [24, 1, 1024, 1]);
}
/// The compute pass writes exactly the indices the per-section draws
/// read: quads 0 1 2 0 2 3 for solid draws, sorted indices otherwise,
/// plus the base vertex.
/// plus the base vertex. With culling, phase 2's runs hold the indices
/// of the draws the test saw, in order, and their calls count them.
#[test]
fn flatten_writes_the_indices_of_the_per_section_draws() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
@@ -564,101 +818,170 @@ mod tests {
use wgpu::util::DeviceExt;
let (draws, pages, solid) = sample();
let align = (u64::from(device.limits().min_storage_buffer_offset_alignment) / 16).max(1);
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
let (_, total) = plan(
&draws,
&pages,
solid,
align,
&mut calls,
&mut ranges,
&mut segments,
);
let sorted: [Vec<u32>; 2] = [
(0..32).map(|i| (i * 7) % 32).collect(),
(0..8).rev().collect(),
];
let mut expected = Vec::new();
for (d, (_, page)) in draws.iter().zip(&pages) {
for k in 0..d[0] {
let i = match page {
None => (k / 6) * 4 + [0, 1, 2, 0, 2, 3][(k % 6) as usize],
Some(p) => sorted[*p as usize][(d[2] + k) as usize],
};
expected.push(d[3] + i);
}
}
let storage = |entries: &[u32]| {
let indices = |d: &[u32; 5], sorted_page: Option<u32>| -> Vec<u32> {
(0..d[0])
.map(|k| {
d[3] + match sorted_page {
None => (k / 6) * 4 + [0, 1, 2, 0, 2, 3][(k % 6) as usize],
Some(p) => sorted[p as usize][(d[2] + k) as usize],
}
})
.collect()
};
let storage = |entries: &[u32], usage| {
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: None,
contents: bytemuck::cast_slice(entries),
usage: wgpu::BufferUsages::STORAGE,
usage: wgpu::BufferUsages::STORAGE | usage,
})
};
let range_buffer = storage(bytemuck::cast_slice(&ranges));
let sorted_buffers = [storage(&sorted[0]), storage(&sorted[1]), storage(&[0; 4])];
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: total * 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: total * 4,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let none = wgpu::BufferUsages::empty();
let sorted_buffers = [
storage(&sorted[0], none),
storage(&sorted[1], none),
storage(&[0; 4], none),
];
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(include_str!("flatten.wgsl").into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: None,
layout: None,
module: &module,
entry_point: Some("flatten"),
compilation_options: Default::default(),
cache: None,
});
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_compute_pass(&Default::default());
pass.set_pipeline(&pipeline);
for &(source, first, count) in &segments {
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &range_buffer,
offset: first * 16,
size: wgpu::BufferSize::new(count * 16),
}),
},
wgpu::BindGroupEntry {
binding: 1,
resource: sorted_buffers[source.map_or(2, |p| p as usize)]
.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: out.as_entire_binding(),
},
],
});
pass.set_bind_group(0, &group, &[]);
pass.dispatch_workgroups(count as u32, 1, 1);
let pipeline = |entry| {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: None,
layout: None,
module: &module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
})
};
let (flatten, flatten_seen) = (pipeline("flatten"), pipeline("flatten_seen"));
let read_back = |buffer: &wgpu::Buffer, words: u64| -> Vec<u32> {
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: words * 4,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_buffer_to_buffer(buffer, 0, &read, 0, words * 4);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec()
};
// Without culling, then with the third and fourth draws in phase 2,
// of which the test saw only the fourth.
for phase1 in [solid, 2] {
let (mut calls, mut ranges, mut segments) = (Vec::new(), Vec::new(), Vec::new());
let plan = plan(
&draws,
&pages,
phase1,
solid,
align,
&mut calls,
&mut ranges,
&mut segments,
);
// Phase 2's draws as the test leaves them: instance counts of 0,
// 1 where it saw the section.
let mut phase2: Vec<[u32; 5]> = draws[phase1..solid]
.iter()
.map(|&[count, _, first, base, instance]| [count, 0, first, base, instance])
.collect();
if let Some(last) = phase2.last_mut() {
last[1] = 1;
}
let phase2_calls: Vec<[u32; 5]> = calls[plan.phase1_calls..plan.solid_calls]
.iter()
.map(|c| [0, 1, c.first, 0, 0])
.collect();
let range_buffer = storage(bytemuck::cast_slice(&ranges), none);
let phase2_buffer = storage(
bytemuck::cast_slice(&[phase2.as_slice(), &[[0; 5]]].concat()),
none,
);
let calls_buffer = storage(
bytemuck::cast_slice(&[phase2_calls.as_slice(), &[[0; 5]]].concat()),
wgpu::BufferUsages::COPY_SRC,
);
let out = storage(&vec![0; plan.total as usize], wgpu::BufferUsages::COPY_SRC);
let mut encoder = device.create_command_encoder(&Default::default());
{
let mut pass = encoder.begin_compute_pass(&Default::default());
for &(source, first, count) in &segments {
let ranges = wgpu::BindingResource::Buffer(wgpu::BufferBinding {
buffer: &range_buffer,
offset: first * 16,
size: wgpu::BufferSize::new(count * 16),
});
let entry = |binding, resource| wgpu::BindGroupEntry { binding, resource };
let (pipeline, entries) = match source {
Source::Seen => (
&flatten_seen,
vec![
entry(0, ranges),
entry(2, out.as_entire_binding()),
entry(3, phase2_buffer.as_entire_binding()),
entry(4, calls_buffer.as_entire_binding()),
],
),
Source::Quads | Source::Sorted(_) => {
let page = match source {
Source::Sorted(p) => p as usize,
_ => 2,
};
(
&flatten,
vec![
entry(0, ranges),
entry(1, sorted_buffers[page].as_entire_binding()),
entry(2, out.as_entire_binding()),
],
)
}
};
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: None,
layout: &pipeline.get_bind_group_layout(0),
entries: &entries,
});
pass.set_pipeline(pipeline);
pass.set_bind_group(0, &group, &[]);
pass.dispatch_workgroups(count as u32, 1, 1);
}
}
queue.submit([encoder.finish()]);
let got = read_back(&out, plan.total);
// Phase 1's and the translucent calls: each of their draws, in order.
let expected: Vec<u32> = (0..draws.len())
.filter(|i| !(phase1..solid).contains(i))
.flat_map(|i| indices(&draws[i], pages[i].1))
.collect();
let drawn: Vec<u32> = calls
.iter()
.enumerate()
.filter(|(c, _)| !(plan.phase1_calls..plan.solid_calls).contains(c))
.flat_map(|(_, c)| got[c.first as usize..(c.first + c.count) as usize].to_vec())
.collect();
assert_eq!(drawn, expected);
// Phase 2: page 1's run drew nothing; page 0's holds the fourth
// draw's indices.
if phase1 < solid {
let counts = read_back(&calls_buffer, phase2_calls.len() as u64 * 5);
assert_eq!(counts[0], 0);
assert_eq!(counts[5], draws[3][0]);
let run = &calls[plan.phase1_calls + 1];
assert_eq!(
got[run.first as usize..(run.first + draws[3][0]) as usize],
indices(&draws[3], None)
);
}
}
encoder.copy_buffer_to_buffer(&out, 0, &read, 0, total * 4);
queue.submit([encoder.finish()]);
read.slice(..).map_async(wgpu::MapMode::Read, |_| {});
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let got: Vec<u32> =
bytemuck::cast_slice(&read.slice(..).get_mapped_range().unwrap()).to_vec();
assert_eq!(got, expected);
}
}
+44 -3
View File
@@ -4,9 +4,11 @@
// translucent ones read their sorted indices.
struct Range {
count: u32,
// First sorted index, or QUADS for a solid draw.
// First sorted index, or QUADS for a solid draw (`flatten_seen`: its
// entry in occlusion culling's phase 2 draws).
first: u32,
base: u32,
// First index in the list (`flatten_seen`: its draw call).
dst: u32,
}
const QUADS: u32 = 0xffffffffu;
@@ -14,6 +16,13 @@ const GROUP: u32 = 64u;
@group(0) @binding(0) var<storage, read> ranges: array<Range>;
@group(0) @binding(1) var<storage, read> sorted: array<u32>;
@group(0) @binding(2) var<storage, read_write> out: array<u32>;
// Index `k` of a solid draw: quad k / 6's vertices 0 1 2 0 2 3.
fn quad(k: u32) -> u32 {
let c = k % 6u;
return (k / 6u) * 4u + select(select(c - 2u, 0u, c == 3u), c, c < 3u);
}
@compute @workgroup_size(64)
fn flatten(@builtin(workgroup_id) group: vec3<u32>,
@builtin(local_invocation_index) lane: u32) {
@@ -25,11 +34,43 @@ fn flatten(@builtin(workgroup_id) group: vec3<u32>,
for (var k = lane; k < d.count; k += GROUP) {
var i: u32;
if d.first == QUADS {
let c = k % 6u;
i = (k / 6u) * 4u + select(select(c - 2u, 0u, c == 3u), c, c < 3u);
i = quad(k);
} else {
i = sorted[d.first + k];
}
out[d.dst + k] = d.base + i;
}
}
// Occlusion culling's phase 2 (gfx/cull.rs), after its test: the draws as
// the test left them (`DrawIndexedIndirectArgs`, the instance count 1 where
// it saw the section), and phase 2's draw calls, whose index counts start
// at 0. A draw the test saw takes the next indices of its call's run.
@group(0) @binding(3) var<storage, read> draws: array<u32>;
@group(0) @binding(4) var<storage, read_write> calls: array<atomic<u32>>;
const UNSEEN: u32 = 0xffffffffu;
var<workgroup> start: u32;
@compute @workgroup_size(64)
fn flatten_seen(@builtin(workgroup_id) group: vec3<u32>,
@builtin(local_invocation_index) lane: u32) {
let r = group.y * 65535u + group.x;
if r >= arrayLength(&ranges) {
return;
}
let d = ranges[r];
if lane == 0u {
var s = UNSEEN;
if draws[d.first * 5u + 1u] != 0u {
s = atomicLoad(&calls[d.dst * 5u + 2u]) + atomicAdd(&calls[d.dst * 5u], d.count);
}
start = s;
}
let s = workgroupUniformLoad(&start);
if s == UNSEEN {
return;
}
for (var k = lane; k < d.count; k += GROUP) {
out[s + k] = d.base + quad(k);
}
}
+213 -93
View File
@@ -2,16 +2,21 @@
//! Iris's colour targets (`colortex0..`, each with a main and an alternate
//! side) and the pack's `begin`, `prepare`, `deferred`, `composite` and
//! `final` programs as full-screen passes over them, as Iris's
//! `CompositeRenderer` runs them. Horizon's world enters `colortex0` and
//! `depthtex0..2` after `prepare`, where Iris's gbuffers programs would have
//! drawn, and the last picture goes to `world_post`. The targets keep GL's
//! layout (row 0 at the bottom, which the translated vertex programs
//! produce), so the world is flipped going in and the picture coming out.
use super::pack_stages::FrameUniform;
use crate::java_pack::{JavaFolder, JavaPasses};
//! `CompositeRenderer` runs them. Horizon's world enters the target the
//! pack's terrain program draws to (`colortex0` for most packs) and
//! `depthtex0` after `prepare`, where Iris's gbuffers programs would have
//! drawn (`depthtex1`/`depthtex2`: its depth before translucent geometry),
//! and the last picture goes to `world_post`. The
//! targets keep GL's layout (row 0 at the bottom, which the translated vertex
//! programs produce), so the world is flipped going in and the picture coming
//! out.
use super::pack_stages::{FrameUniform, noise, rgba_view};
use crate::java_pack::{JavaFolder, JavaPasses, NOISE_KEY, texture_key};
use crate::shader_pack::Rgba;
use glam::{Mat3, Mat4, Vec3, Vec4};
use horizon_shaderpack::pipeline::TargetSettings;
use horizon_shaderpack::pipeline::{Directives, Program, target_index};
use horizon_shaderpack::properties::Blend;
use horizon_shaderpack::uniforms::{CustomUniforms, SmoothFloat, UniformState};
use std::collections::{BTreeMap, BTreeSet, HashMap};
@@ -27,14 +32,13 @@ pub struct JavaFrame {
pub moon_angle: f32,
}
/// Iris's `noisetex` size (`noiseTextureResolution`'s default).
const NOISE_SIZE: u32 = 256;
/// The vertex buffers a pass may read (wgpu's default limit).
const MAX_ATTRIBUTES: usize = 8;
const BLIT: &str = r#"
@group(0) @binding(0) var color: texture_2d<f32>;
@group(0) @binding(1) var depth: texture_depth_2d;
@group(0) @binding(2) var opaque_depth: texture_depth_2d;
@vertex
fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
@@ -45,15 +49,20 @@ fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f {
struct World {
@location(0) color: vec4f,
@location(1) depth: vec4f,
@location(2) opaque_depth: vec4f,
}
// The world's colour and depth, upside down into GL's layout; Horizon's
// reversed depth (near 1) as GL's (near 0).
// The world's colour, depth and pre-translucent depth, upside down into GL's
// layout; Horizon's reversed depth (near 1) as GL's (near 0).
@fragment
fn world_in(@builtin(position) p: vec4f) -> World {
let size = textureDimensions(depth);
let xy = vec2u(u32(p.x), size.y - 1u - u32(p.y));
return World(textureLoad(color, xy, 0), vec4f(1.0 - textureLoad(depth, xy, 0)));
return World(
textureLoad(color, xy, 0),
vec4f(1.0 - textureLoad(depth, xy, 0)),
vec4f(1.0 - textureLoad(opaque_depth, xy, 0)),
);
}
// A target, upside down back into wgpu's layout.
@@ -64,6 +73,42 @@ fn present(@builtin(position) p: vec4f) -> @location(0) vec4f {
}
"#;
/// Convert a GL `Blend` override from `shaders.properties` into a wgpu `BlendState`.
fn blend_state(blend: &Blend) -> Option<wgpu::BlendState> {
let Blend::Factors(factors) = blend else {
return None;
};
let map_factor = |name: &str| -> wgpu::BlendFactor {
use wgpu::BlendFactor as B;
match name {
"ZERO" => B::Zero,
"ONE" => B::One,
"SRC_COLOR" => B::Src,
"ONE_MINUS_SRC_COLOR" => B::OneMinusSrc,
"DST_COLOR" => B::Dst,
"ONE_MINUS_DST_COLOR" => B::OneMinusDst,
"SRC_ALPHA" => B::SrcAlpha,
"ONE_MINUS_SRC_ALPHA" => B::OneMinusSrcAlpha,
"DST_ALPHA" => B::DstAlpha,
"ONE_MINUS_DST_ALPHA" => B::OneMinusDstAlpha,
"SRC_ALPHA_SATURATE" => B::SrcAlphaSaturated,
_ => B::One,
}
};
Some(wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: map_factor(&factors[0]),
dst_factor: map_factor(&factors[1]),
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: map_factor(&factors[2]),
dst_factor: map_factor(&factors[3]),
operation: wgpu::BlendOperation::Add,
},
})
}
/// A wgpu format for each of Iris's (`InternalTextureFormat`), renderable
/// everywhere: 8-bit unsigned normalized formats stay, signed normalized,
/// 16-bit and packed float ones become half floats, integer ones 32-bit.
@@ -123,8 +168,10 @@ struct Target {
#[derive(Clone, Copy, Debug, PartialEq)]
enum Source {
Target(u32),
/// `depthtex0..2`: the world's depth.
/// `depthtex0` / `gdepthtex`: the world's depth (including translucent water).
Depth,
/// `depthtex1` / `depthtex2`: the opaque world's depth (before translucent water).
OpaqueDepth,
Noise,
/// `shadowtex*`: no shadow map yet, so a far depth (nothing in shadow);
/// also Distant Horizons' and Voxy's depth, as without those mods.
@@ -135,7 +182,8 @@ enum Source {
fn source(name: &str) -> Source {
match name {
"depthtex0" | "depthtex1" | "depthtex2" | "gdepthtex" => Source::Depth,
"depthtex0" | "gdepthtex" => Source::Depth,
"depthtex1" | "depthtex2" => Source::OpaqueDepth,
"noisetex" => Source::Noise,
"shadowtex0" | "shadowtex1" | "shadow" | "watershadow" => Source::Shadow,
n if n.starts_with("dhDepthTex") || n.starts_with("vxDepthTex") => Source::Shadow,
@@ -146,6 +194,7 @@ fn source(name: &str) -> Source {
/// A sampler of a pass: its texture and sampler bindings.
struct SamplerBinding {
name: String,
source: Source,
dimension: wgpu::TextureViewDimension,
/// `isampler` (true) or `usampler` (false).
@@ -183,6 +232,7 @@ struct Common {
repeat: wgpu::Sampler,
compare: wgpu::Sampler,
noise: wgpu::TextureView,
custom_textures: BTreeMap<String, wgpu::TextureView>,
/// Full-screen triangle data per attribute kind (see `PassGpu::attributes`).
vertices: [wgpu::Buffer; 4],
world_layout: wgpu::BindGroupLayout,
@@ -194,12 +244,16 @@ struct Common {
struct Bound {
folder: usize,
size: [u32; 2],
/// `world_post`'s targets it binds (`Post::generation`).
post_generation: u64,
targets: BTreeMap<u32, Target>,
depth: wgpu::TextureView,
opaque_depth: wgpu::TextureView,
/// Per pass, then `final`.
groups: Vec<wgpu::BindGroup>,
world_in: (wgpu::RenderPipeline, wgpu::BindGroup),
/// The world goes to colortex0's alternate side.
/// The world's target, and whether the world goes to its alternate side.
world_target: u32,
world_alt: bool,
final_target: Option<wgpu::TextureView>,
present: (wgpu::RenderPipeline, wgpu::BindGroup),
@@ -212,6 +266,7 @@ struct Bound {
struct Folder {
targets: Vec<TargetSettings>,
world_at: usize,
world_target: u32,
flipped_after_prepare: BTreeSet<u32>,
flipped_at_end: BTreeSet<u32>,
passes: Vec<PassGpu>,
@@ -221,6 +276,9 @@ struct Folder {
pub struct JavaStages {
pub id: String,
/// A pass samples `depthtex1`/`depthtex2`: the world's depth is kept
/// before translucent geometry draws.
pub reads_opaque_depth: bool,
format: wgpu::TextureFormat,
common: Common,
folders: Vec<Folder>,
@@ -234,6 +292,9 @@ pub struct JavaStages {
uniform_state: UniformState,
/// `eyeBrightnessSmooth`, block then sky.
eye_smooth: [SmoothFloat; 2],
/// `wetness`: `rainStrength` smoothed with `wetnessHalflife` going up
/// and `drynessHalflife` going down.
wetness_smooth: SmoothFloat,
}
impl JavaStages {
@@ -245,7 +306,7 @@ impl JavaStages {
id: &str,
pack: &JavaPasses,
) -> Result<Self, String> {
let common = Common::new(device, queue);
let common = Common::new(device, queue, &pack.custom_textures);
let folder_for = [-1, 0, 1].map(|d| pack.folder_for(d));
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let folders = pack
@@ -270,6 +331,7 @@ impl JavaStages {
Ok::<_, String>(Folder {
targets: plan.targets.clone(),
world_at: plan.world_at,
world_target: plan.world_target,
flipped_after_prepare: plan.flipped_after_prepare.clone(),
flipped_at_end: plan.flipped_at_end.clone(),
passes,
@@ -281,8 +343,14 @@ impl JavaStages {
if let Some(error) = pollster::block_on(scope.pop()) {
return Err(error.to_string());
}
let reads_opaque_depth = folders
.iter()
.flat_map(|f| f.passes.iter().chain(&f.final_pass))
.flat_map(|p| &p.samplers)
.any(|s| s.source == Source::OpaqueDepth);
Ok(Self {
id: id.to_owned(),
reads_opaque_depth,
format,
common,
folders,
@@ -295,6 +363,7 @@ impl JavaStages {
uniforms: pack.uniforms.clone(),
uniform_state: UniformState::default(),
eye_smooth: [SmoothFloat::default(); 2],
wetness_smooth: SmoothFloat::default(),
})
}
@@ -315,11 +384,9 @@ impl JavaStages {
) -> &wgpu::BindGroup {
let dimension = (frame.dimension[0] as i32).clamp(-1, 1);
let folder = self.folder_for[(dimension + 1) as usize].unwrap_or(0);
if self
.bound
.as_ref()
.is_none_or(|b| b.folder != folder || b.size != size)
{
if self.bound.as_ref().is_none_or(|b| {
b.folder != folder || b.size != size || b.post_generation != post.generation()
}) {
self.bound = Some(self.bind(device, queue, folder, color, depth, size, post));
}
let now = std::time::Instant::now();
@@ -339,6 +406,12 @@ impl JavaStages {
for (i, smooth) in self.eye_smooth.iter_mut().enumerate() {
inputs.eye_smooth[i] = smooth.update(inputs.eye[i], half_life, half_life, frame_time);
}
inputs.wetness = self.wetness_smooth.update(
frame.weather[0],
directives.wetness_half_life,
directives.dryness_half_life,
frame_time,
);
self.uniforms
.update(&mut self.uniform_state, frame_time, &|name| {
inputs.builtin(name)
@@ -417,8 +490,9 @@ impl JavaStages {
let folder = &self.folders[folder_index];
let plan = folder;
let all = folder.passes.iter().chain(&folder.final_pass);
// Every target a pass reads or writes, and colortex0 (the world).
let mut used: BTreeSet<u32> = [0].into();
// Every target a pass reads or writes, colortex0 (shown without
// `final`) and the world's.
let mut used: BTreeSet<u32> = [0, folder.world_target].into();
for pass in all.clone() {
used.extend(pass.draw_buffers.iter().copied());
used.extend(pass.samplers.iter().filter_map(|s| match s.source {
@@ -496,20 +570,41 @@ impl JavaStages {
size,
)
.create_view(&Default::default());
let opaque_depth = texture(
"Java shader pack opaque depth",
wgpu::TextureFormat::R32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
size,
)
.create_view(&Default::default());
let mut blank = Vec::new();
let groups = all
.clone()
.map(|pass| pass.bind(device, queue, &targets, &depth, &self.common, &mut blank))
.map(|pass| {
pass.bind(
device,
queue,
&targets,
&depth,
&opaque_depth,
&self.common,
&mut blank,
)
})
.collect();
// The world goes into colortex0's side the gbuffers would write.
let colortex0 = &targets[&0];
// The world goes into its target's side the gbuffers would write.
let world_target = &targets[&folder.world_target];
let world_pipeline = blit_pipeline(
device,
&self.common,
&self.common.world_layout,
"world_in",
&[colortex0.format, wgpu::TextureFormat::R32Float],
&[
world_target.format,
wgpu::TextureFormat::R32Float,
wgpu::TextureFormat::R32Float,
],
);
let world_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Java shader pack world"),
@@ -523,6 +618,10 @@ impl JavaStages {
binding: 1,
resource: wgpu::BindingResource::TextureView(world_depth),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(post.opaque_depth()),
},
],
});
@@ -536,6 +635,7 @@ impl JavaStages {
.create_view(&Default::default())
});
// Without `final`, Iris shows colortex0 as the last pass left it.
let colortex0 = &targets[&0];
let shown = final_target.clone().unwrap_or_else(|| {
if plan.flipped_at_end.contains(&0) {
colortex0.alt_view.clone()
@@ -569,11 +669,14 @@ impl JavaStages {
Bound {
folder: folder_index,
size,
post_generation: post.generation(),
targets,
depth,
opaque_depth,
groups,
world_in: (world_pipeline, world_group),
world_alt: plan.flipped_after_prepare.contains(&0),
world_target: folder.world_target,
world_alt: plan.flipped_after_prepare.contains(&folder.world_target),
final_target,
present: (present_pipeline, present_group),
output_view,
@@ -584,15 +687,16 @@ impl JavaStages {
}
fn world_in(encoder: &mut wgpu::CommandEncoder, bound: &Bound) {
let colortex0 = &bound.targets[&0];
let target = &bound.targets[&bound.world_target];
let side = if bound.world_alt {
&colortex0.alt_view
&target.alt_view
} else {
&colortex0.main_view
&target.main_view
};
let attachments = [
Some(color_attachment(side, None)),
Some(color_attachment(&bound.depth, None)),
Some(color_attachment(&bound.opaque_depth, None)),
];
let mut render = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Java shader pack world"),
@@ -666,7 +770,8 @@ fn blit_pipeline(
}
impl Common {
fn new(device: &wgpu::Device, queue: &wgpu::Queue) -> Self {
/// `custom` holds the pack's `texture.*` images (`JavaPasses::custom_textures`).
fn new(device: &wgpu::Device, queue: &wgpu::Queue, custom: &BTreeMap<String, Rgba>) -> Self {
let sampler = |filter, address, compare| {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Java shader pack"),
@@ -680,40 +785,15 @@ impl Common {
})
};
use wgpu::{AddressMode::*, FilterMode::*};
// Iris's noise: random RGB, opaque.
let mut seed = 0x2545_f491_u32;
let noise_bytes: Vec<u8> = (0..NOISE_SIZE * NOISE_SIZE * 4)
.map(|i| {
seed ^= seed << 13;
seed ^= seed >> 17;
seed ^= seed << 5;
if i % 4 == 3 { 255 } else { (seed >> 24) as u8 }
})
let noise = match custom.get(NOISE_KEY) {
Some(image) => rgba_view(device, queue, "noisetex", image),
None => rgba_view(device, queue, "noisetex", &noise()),
};
let custom_textures = custom
.iter()
.filter(|(key, _)| *key != NOISE_KEY)
.map(|(key, image)| (key.clone(), rgba_view(device, queue, key, image)))
.collect();
let noise = device.create_texture(&wgpu::TextureDescriptor {
label: Some("noisetex"),
size: wgpu::Extent3d {
width: NOISE_SIZE,
height: NOISE_SIZE,
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(
noise.as_image_copy(),
&noise_bytes,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(NOISE_SIZE * 4),
rows_per_image: None,
},
noise.size(),
);
// The full-screen triangle (0, 0), (2, 0), (0, 2): the pack's
// projection maps 0..1 to the screen. Texture coordinates are the
// position; colours white; normals up.
@@ -753,13 +833,15 @@ impl Common {
nearest: sampler(Nearest, ClampToEdge, None),
repeat: sampler(Linear, Repeat, None),
compare: sampler(Linear, ClampToEdge, Some(wgpu::CompareFunction::LessEqual)),
noise: noise.create_view(&Default::default()),
noise,
custom_textures,
vertices,
world_layout: device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Java shader pack world"),
entries: &[
texture_entry(0, unfilterable),
texture_entry(1, wgpu::TextureSampleType::Depth),
texture_entry(2, wgpu::TextureSampleType::Depth),
],
}),
present_layout: device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -841,6 +923,7 @@ impl PassGpu {
.cloned()
.unwrap_or_else(|| format!("{sampler}_s"));
samplers.push(SamplerBinding {
name: sampler.clone(),
source: source(sampler),
dimension,
signed,
@@ -885,7 +968,7 @@ impl PassGpu {
)),
)
}
(false, Source::Depth | Source::Shadow) => (
(false, Source::Depth | Source::OpaqueDepth | Source::Shadow) => (
wgpu::TextureSampleType::Float { filterable: false },
Some(false),
),
@@ -979,10 +1062,11 @@ impl PassGpu {
// Output location `l` writes target `draw_buffers[l]` (or the screen).
let outputs = &program.fragment.interface.outputs;
let pass_blend = program.pass.blend.as_ref().and_then(blend_state);
let target_states: Vec<Option<wgpu::ColorTargetState>> = match screen {
Some(format) => vec![Some(wgpu::ColorTargetState {
format,
blend: None,
blend: pass_blend,
write_mask: if outputs.contains_key(&0) {
wgpu::ColorWrites::ALL
} else {
@@ -994,12 +1078,25 @@ impl PassGpu {
.iter()
.enumerate()
.map(|(l, &i)| {
let format = formats
.get(i as usize)
.copied()
.unwrap_or(wgpu::TextureFormat::Rgba8Unorm);
// Only filterable float/unorm formats support blending.
let can_blend = matches!(
sample_type(format),
wgpu::TextureSampleType::Float { filterable: true }
);
let buf_blend = program
.pass
.buffer_blends
.iter()
.find(|(idx, _)| *idx == i)
.map(|(_, b)| blend_state(b))
.unwrap_or(pass_blend);
Some(wgpu::ColorTargetState {
format: formats
.get(i as usize)
.copied()
.unwrap_or(wgpu::TextureFormat::Rgba8Unorm),
blend: None,
format,
blend: if can_blend { buf_blend } else { None },
write_mask: if outputs.contains_key(&(l as u32)) {
wgpu::ColorWrites::ALL
} else {
@@ -1068,37 +1165,55 @@ impl PassGpu {
}
/// The pass's bind group over `targets`.
#[allow(clippy::too_many_arguments)]
fn bind(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
targets: &BTreeMap<u32, Target>,
depth: &wgpu::TextureView,
opaque_depth: &wgpu::TextureView,
common: &Common,
blank: &mut Vec<wgpu::Texture>,
) -> wgpu::BindGroup {
let mut views = Vec::new();
for s in &self.samplers {
let view = match (s.compare, s.source) {
(true, _) => blank_view(device, queue, blank, s, BlankKind::FarDepth),
(false, Source::Target(i)) if s.dimension == wgpu::TextureViewDimension::D2 => {
match targets.get(&i) {
Some(t) if self.reads_alt.contains(&i) => t.alt_view.clone(),
Some(t) => t.main_view.clone(),
None => blank_view(device, queue, blank, s, BlankKind::Color(false)),
let custom = (!s.compare && s.dimension == wgpu::TextureViewDimension::D2)
.then(|| {
common
.custom_textures
.get(&texture_key(&self.program, &s.name))
})
.flatten();
let view = if let Some(custom) = custom {
custom.clone()
} else {
match (s.compare, s.source) {
(true, _) => blank_view(device, queue, blank, s, BlankKind::FarDepth),
(false, Source::Target(i)) if s.dimension == wgpu::TextureViewDimension::D2 => {
match targets.get(&i) {
Some(t) if self.reads_alt.contains(&i) => t.alt_view.clone(),
Some(t) => t.main_view.clone(),
None => blank_view(device, queue, blank, s, BlankKind::Color(false)),
}
}
(false, Source::Depth) if s.dimension == wgpu::TextureViewDimension::D2 => {
depth.clone()
}
(false, Source::OpaqueDepth)
if s.dimension == wgpu::TextureViewDimension::D2 =>
{
opaque_depth.clone()
}
(false, Source::Shadow) => blank_view(device, queue, blank, s, BlankKind::Far),
(false, Source::Noise) if s.dimension == wgpu::TextureViewDimension::D2 => {
common.noise.clone()
}
(false, Source::Blank(white)) => {
blank_view(device, queue, blank, s, BlankKind::Color(white))
}
_ => blank_view(device, queue, blank, s, BlankKind::Color(false)),
}
(false, Source::Depth) if s.dimension == wgpu::TextureViewDimension::D2 => {
depth.clone()
}
(false, Source::Shadow) => blank_view(device, queue, blank, s, BlankKind::Far),
(false, Source::Noise) if s.dimension == wgpu::TextureViewDimension::D2 => {
common.noise.clone()
}
(false, Source::Blank(white)) => {
blank_view(device, queue, blank, s, BlankKind::Color(white))
}
_ => blank_view(device, queue, blank, s, BlankKind::Color(false)),
};
let sampler = match (s.compare, s.source) {
_ if s.shared.is_some() => match s.shared.as_deref() {
@@ -1110,7 +1225,7 @@ impl PassGpu {
(true, _) => &common.compare,
_ if s.signed.is_some() => &common.nearest,
(false, Source::Noise) => &common.repeat,
(false, Source::Depth | Source::Shadow) => &common.nearest,
(false, Source::Depth | Source::OpaqueDepth | Source::Shadow) => &common.nearest,
(false, Source::Target(i)) => {
match targets.get(&i).map(|t| sample_type(t.format)) {
Some(wgpu::TextureSampleType::Float { filterable: true }) | None => {
@@ -1442,6 +1557,8 @@ struct Inputs {
/// `eyeBrightness` and `eyeBrightnessSmooth`: block, sky light × 16.
eye: [f32; 2],
eye_smooth: [f32; 2],
/// `wetness` (`JavaStages::wetness_smooth`).
wetness: f32,
/// `CelestialUniforms`, in view space.
sun_position: Vec3,
moon_position: Vec3,
@@ -1499,6 +1616,7 @@ impl Inputs {
frame: *frame,
eye: [0.0; 2],
eye_smooth: [0.0; 2],
wetness: frame.weather[0],
sun_position: Vec3::ZERO,
moon_position: Vec3::ZERO,
up_position: Vec3::ZERO,
@@ -1591,7 +1709,8 @@ impl Inputs {
}),
"upPosition" => vec3(self.up_position),
"moonPhase" => vec![f.moon[3]],
"rainStrength" | "wetness" => vec![f.weather[0]],
"rainStrength" => vec![f.weather[0]],
"wetness" => vec![self.wetness],
"thunderStrength" => vec![f.weather[1]],
"fogColor" => f.fog_color[..3].to_vec(),
"skyColor" => f.sky_color[..3].to_vec(),
@@ -1651,6 +1770,7 @@ mod tests {
assert_eq!(source("colortex4"), Source::Target(4));
assert_eq!(source("gaux1"), Source::Target(4));
assert_eq!(source("gdepthtex"), Source::Depth);
assert_eq!(source("depthtex1"), Source::OpaqueDepth);
assert_eq!(source("shadowtex1"), Source::Shadow);
assert_eq!(source("dhDepthTex0"), Source::Shadow);
assert_eq!(source("shadowcolor0"), Source::Blank(true));
+253 -26
View File
@@ -1,9 +1,12 @@
//! A Horizon shader pack on the GPU (docs/shader-packs.md): each `composite`
//! stage is one full-screen pass that reads the world's colour and depth 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::{ShaderPack, VERTEX_ENTRY};
//! 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)]
@@ -27,10 +30,81 @@ pub struct FrameUniform {
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 {
@@ -41,24 +115,47 @@ struct Target {
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>,
format: wgpu::TextureFormat,
/// Built for this world target size.
/// 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> {
@@ -79,6 +176,16 @@ impl PackStages {
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: &[
@@ -87,16 +194,15 @@ impl PackStages {
texture(2, float),
sampler(3, wgpu::SamplerBindingType::Filtering),
sampler(4, wgpu::SamplerBindingType::NonFiltering),
wgpu::BindGroupLayoutEntry {
binding: 5,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
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 {
@@ -106,10 +212,18 @@ impl PackStages {
});
let constants = pack.constants();
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let stage_count = pack.stages.len();
let passes = pack
.stages
.iter()
.map(|stage| {
.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()),
@@ -140,7 +254,7 @@ impl PackStages {
..Default::default()
},
targets: &[Some(wgpu::ColorTargetState {
format,
format: target_format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -151,38 +265,77 @@ impl PackStages {
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 sampler = |filter| {
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: sampler(wgpu::FilterMode::Linear),
nearest: sampler(wgpu::FilterMode::Nearest),
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,
format,
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(),
})
}
@@ -199,9 +352,14 @@ impl PackStages {
size: [u32; 2],
post: &super::world_post::Post,
mut uniform: FrameUniform,
eye_light: [u8; 2],
) -> &wgpu::BindGroup {
if self.world_size != size || self.targets.len() != self.passes.len() {
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));
@@ -216,7 +374,7 @@ impl PackStages {
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: self.format,
format: pass.target_format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
@@ -251,6 +409,38 @@ impl PackStages {
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 {
@@ -262,6 +452,42 @@ impl PackStages {
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);
@@ -269,6 +495,7 @@ impl PackStages {
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,
+181
View File
@@ -0,0 +1,181 @@
//! Solid draws grouped by vertex arena page within distance bands.
//!
//! Sections sit in arena pages of 256 MiB, so render distance 32 spreads
//! them over half a dozen pages, and a list sorted only by distance switches
//! page every few sections: about a thousand indirect multi-draws of two or
//! three entries a frame. Mesa's Intel driver (and wgpu-core, for each
//! vertex buffer change) pays per call, and below four entries Mesa unrolls
//! the call on the CPU entry by entry. Grouping the sections of one band of
//! distance by page keeps the list front to back to within the band (what
//! the depth test needs to reject hidden fragments early) with one call per
//! page and band.
//!
//! The picture does not change: solid draws write depth and are tested
//! against it, so their order only decides which of two fragments at the
//! same depth stays, and sections do not share faces.
/// The width of a band, in blocks.
const BAND: f64 = 64.0;
/// One section's draws: `start..start + len` of the list.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct Run {
key: u64,
pub start: u32,
pub len: u32,
/// The caller's data, carried along (occlusion culling's test inputs).
pub tag: [u32; 3],
}
#[derive(Default)]
pub(super) struct PageRuns {
runs: Vec<Run>,
scratch: Vec<([u32; 5], (u32, Option<u32>))>,
}
impl PageRuns {
pub fn clear(&mut self) {
self.runs.clear();
}
/// The draws `start..start + len` of a section `distance` from the
/// camera (squared, as bits) in arena `page`; runs come nearest first.
pub fn push(&mut self, distance: u64, page: u32, start: usize, len: usize, tag: [u32; 3]) {
if len > 0 {
let band = (f64::from_bits(distance).sqrt() / BAND) as u64;
self.runs.push(Run {
key: band << 32 | u64::from(page),
start: start as u32,
len: len as u32,
tag,
});
}
}
/// Reorders `draws[base..]` and their `pages`, which the pushed runs
/// cover in order, so that each band's runs in one page are adjacent
/// (in distance order); returns the runs at their new places.
pub fn regroup(
&mut self,
draws: &mut [[u32; 5]],
pages: &mut [(u32, Option<u32>)],
base: usize,
) -> &[Run] {
let page = |r: &Run| r.key as u32;
if self.runs.windows(2).all(|w| page(&w[0]) == page(&w[1])) {
return &self.runs;
}
// Stable: within a band and page the runs stay nearest first.
self.runs.sort_by_key(|r| r.key);
self.scratch.clear();
for r in &mut self.runs {
let (s, e) = (r.start as usize, (r.start + r.len) as usize);
r.start = (base + self.scratch.len()) as u32;
self.scratch
.extend(draws[s..e].iter().copied().zip(pages[s..e].iter().copied()));
}
for (i, &(draw, p)) in self.scratch.iter().enumerate() {
draws[base + i] = draw;
pages[base + i] = p;
}
&self.runs
}
}
#[cfg(test)]
mod tests {
use super::*;
fn bits(blocks: f64) -> u64 {
(blocks * blocks).to_bits()
}
/// Runs of (distance in blocks, page, draws), as the list builder pushes them.
fn list(runs: &[(f64, u32, u32)]) -> (PageRuns, Vec<[u32; 5]>, Vec<(u32, Option<u32>)>) {
let mut grouped = PageRuns::default();
let (mut draws, mut pages) = (Vec::new(), Vec::new());
for (n, &(distance, page, count)) in runs.iter().enumerate() {
let start = draws.len();
for k in 0..count {
draws.push([n as u32, k, 0, 0, 0]);
pages.push((page, None));
}
grouped.push(
bits(distance),
page,
start,
count as usize,
[n as u32, 0, 0],
);
}
(grouped, draws, pages)
}
#[test]
fn a_band_draws_each_page_once_keeping_the_order_inside_it() {
let (mut grouped, mut draws, mut pages) = list(&[
(10.0, 1, 2),
(20.0, 0, 1),
(30.0, 1, 3),
(40.0, 0, 2),
(70.0, 1, 1),
]);
let runs: Vec<_> = grouped
.regroup(&mut draws, &mut pages, 0)
.iter()
.map(|r| (r.tag[0], r.start, r.len))
.collect();
assert_eq!(
runs,
[(1, 0, 1), (3, 1, 2), (0, 3, 2), (2, 5, 3), (4, 8, 1)]
);
let order: Vec<_> = draws.iter().map(|d| (d[0], d[1])).collect();
assert_eq!(
order,
[
(1, 0),
(3, 0),
(3, 1),
(0, 0),
(0, 1),
(2, 0),
(2, 1),
(2, 2),
(4, 0)
]
);
let pages: Vec<_> = pages.iter().map(|p| p.0).collect();
assert_eq!(pages, [0, 0, 0, 1, 1, 1, 1, 1, 1]);
}
#[test]
fn bands_stay_in_distance_order() {
let (mut grouped, mut draws, mut pages) =
list(&[(10.0, 1, 1), (100.0, 0, 1), (200.0, 0, 1)]);
grouped.regroup(&mut draws, &mut pages, 0);
let order: Vec<_> = draws.iter().map(|d| d[0]).collect();
assert_eq!(order, [0, 1, 2]);
}
#[test]
fn one_page_and_an_offset_list() {
let (mut grouped, mut draws, mut pages) = list(&[(10.0, 3, 2), (90.0, 3, 1)]);
let before = draws.clone();
assert_eq!(grouped.regroup(&mut draws, &mut pages, 0)[1].start, 2);
assert_eq!(draws, before);
// A list after other draws: starts are absolute.
let mut grouped = PageRuns::default();
let mut draws = vec![[9; 5], [7, 0, 0, 0, 0], [8, 0, 0, 0, 0]];
let mut pages = vec![(5, None), (1, None), (0, None)];
grouped.push(bits(5.0), 1, 1, 1, [0; 3]);
grouped.push(bits(6.0), 0, 2, 1, [1, 0, 0]);
let runs = grouped.regroup(&mut draws, &mut pages, 1).to_vec();
assert_eq!(draws, [[9; 5], [8, 0, 0, 0, 0], [7, 0, 0, 0, 0]]);
assert_eq!(pages, [(5, None), (0, None), (1, None)]);
assert_eq!((runs[0].tag[0], runs[0].start), (1, 1));
// Empty runs are left out.
grouped.clear();
grouped.push(bits(1.0), 0, 0, 0, [0; 3]);
assert!(grouped.regroup(&mut [], &mut [], 0).is_empty());
}
}
+61 -9
View File
@@ -169,14 +169,20 @@ fn in_view(key: SectionKey, camera: DVec3, radius: i32, frustum: &Frustum) -> Op
if !in_view_distance(camera_column(camera), radius, key.0, key.2) {
return None;
}
in_frustum(key, camera, frustum)
in_frustum(key, camera, frustum, false)
}
/// `in_view` without the distance test.
fn in_frustum(key: SectionKey, camera: DVec3, frustum: &Frustum) -> Option<(f64, Vec3)> {
/// `in_view` without the distance test; `inside`: the section's column is
/// entirely inside the frustum, so the section is too.
fn in_frustum(
key: SectionKey,
camera: DVec3,
frustum: &Frustum,
inside: bool,
) -> Option<(f64, Vec3)> {
let origin = origin(key, camera);
let min = origin.as_vec3();
if !frustum.intersects(min, min + Vec3::splat(16.0)) {
if !inside && !frustum.intersects(min, min + Vec3::splat(16.0)) {
return None;
}
Some(((origin + DVec3::splat(8.0)).length_squared(), min))
@@ -192,8 +198,9 @@ impl<T> Resident<T> {
/// Columns are tested first, with the same distance test (it depends
/// only on x and z) and a box spanning their sections: every section box
/// lies inside it and the frustum test rounds monotonically, so a column
/// the frustum rejects holds no section it would accept. `radius` is the
/// render distance in chunks.
/// the frustum rejects holds no section it would accept, and one
/// entirely inside holds none it would reject (its sections skip the
/// test). `radius` is the render distance in chunks.
pub fn visible(
&self,
camera: DVec3,
@@ -211,7 +218,9 @@ impl<T> Resident<T> {
}
let min = origin((column.x, column.low, column.z), camera).as_vec3();
let top = origin((column.x, column.high, column.z), camera).as_vec3();
if !frustum.intersects(min, top + Vec3::splat(16.0)) {
let max = top + Vec3::splat(16.0);
let inside = frustum.contains(min, max);
if !inside && !frustum.intersects(min, max) {
continue;
}
for &(y, slot) in &column.sections {
@@ -219,7 +228,7 @@ impl<T> Resident<T> {
if near && !reachable(key) {
continue;
}
if let Some((d, o)) = in_frustum(key, camera, frustum) {
if let Some((d, o)) = in_frustum(key, camera, frustum, inside) {
out.push(Visible {
distance: d.to_bits(),
key,
@@ -277,7 +286,7 @@ mod tests {
if near {
in_view(k, camera, radius, &frustum).filter(|_| reachable(k))
} else if beyond(k.0, k.2) {
in_frustum(k, camera, &frustum)
in_frustum(k, camera, &frustum, false)
} else {
None
}
@@ -298,6 +307,49 @@ mod tests {
}
}
#[test]
fn columns_entirely_inside_skip_no_section() {
// Short columns ahead of the camera: most lie entirely inside.
let mut r = Resident::default();
for x in -12i32..=12 {
for z in 1..=12 {
for y in 3..6 {
if (x + y + z).rem_euclid(3) != 0 {
r.insert((x, y, z), ());
}
}
}
}
let camera = DVec3::new(0.5, -8.0, 0.5);
let projection = super::super::world_projection(70.0, 16.0 / 9.0, 512.0);
let frustum = Frustum {
planes: super::super::frustum(projection * crate::camera::view_matrix(0.0, 5.0)),
};
let inside = r
.columns
.iter()
.filter(|c| {
let min = origin((c.x, c.low, c.z), camera).as_vec3();
let top = origin((c.x, c.high, c.z), camera).as_vec3();
frustum.contains(min, top + Vec3::splat(16.0))
})
.count();
assert!(inside > 50 && inside < r.columns.len(), "{inside}");
let mut fast = Vec::new();
r.visible(camera, 12, &frustum, |_| true, |_, _| false, &mut fast);
let mut slow: Vec<_> = r
.items
.iter()
.filter_map(|&(k, _)| in_view(k, camera, 12, &frustum).map(|(d, _)| (d, k)))
.collect();
slow.sort_unstable_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
let fast: Vec<_> = fast
.iter()
.map(|v| (f64::from_bits(v.distance), v.key))
.collect();
assert_eq!(fast, slow);
}
/// Every key reaches its value through its slot and column, and every
/// column's bounds and sections match the keys.
fn check(r: &Resident<u32>, model: &HashMap<SectionKey, u32>) {
+179 -51
View File
@@ -1,18 +1,35 @@
//! GPU frame time from timestamp queries, read back a few frames late so the
//! CPU never waits for it. Frames whose readback slot is still busy go untimed.
//!
//! Where the GPU can only time pass boundaries (Apple GPUs on Metal), the
//! frame is bracketed by two one-thread compute passes that write the
//! timestamps (Metal leaves an empty pass's timestamps at zero).
//! Where the GPU can only time pass boundaries (Apple GPUs on Metal), a
//! one-thread compute pass stamps the frame's start, and a render pass over
//! the frame's target that shades one pixel with every write masked stamps
//! its end (Metal leaves the timestamps of a pass that does nothing at zero).
//! A pass that loads the target runs after every pass that draws into it; a
//! compute pass, which depends on nothing, ran alongside them. Metal also
//! resolves timestamps without waiting for the passes that write them, so
//! they are resolved in a later frame, once the GPU has finished this one.
//! Resolved in the frame itself, the end was mostly the one an earlier frame
//! left in the query, and frames read about 0.01 ms.
use std::sync::{
Arc,
atomic::{AtomicU8, Ordering},
};
const SLOTS: usize = 4;
/// Frames in flight at once; on Metal a slot is busy for about five frames.
const SLOTS: usize = 8;
/// Each slot's timestamps have their own queries and resolve range.
const RESOLVE_STRIDE: u64 = wgpu::QUERY_RESOLVE_BUFFER_ALIGNMENT;
/// The frame's target and its format.
pub type Target<'a> = (&'a wgpu::TextureView, wgpu::TextureFormat);
const FREE: u8 = 0;
const PENDING: u8 = 1;
const MAPPED: u8 = 2;
/// Its frame is submitted; with markers, until the GPU has finished it.
const RUNNING: u8 = 1;
/// Its frame is finished: its timestamps can be resolved.
const DONE: u8 = 2;
/// Resolved and on its way back.
const PENDING: u8 = 3;
const MAPPED: u8 = 4;
struct Slot {
buffer: wgpu::Buffer,
@@ -27,10 +44,29 @@ pub struct GpuTimer {
current: Option<usize>,
next: usize,
period_ns: f32,
/// Timestamps between commands, or else this pass's boundaries.
marker: Option<wgpu::ComputePipeline>,
/// Timestamps between commands, or else these passes' boundaries.
markers: Option<Markers>,
/// Slots resolved in this frame's commands, read back once it is submitted.
resolved: Vec<usize>,
}
/// The marker passes: the start's compute pipeline, and the end's shader and
/// pipeline, for the target format it was made for.
struct Markers {
module: wgpu::ShaderModule,
start: wgpu::ComputePipeline,
end: Option<(wgpu::TextureFormat, wgpu::RenderPipeline)>,
}
/// The start's one thread; the end's triangle over the whole target (the
/// pass's scissor keeps one pixel), shading nothing it writes.
const MARKER: &str =
"@vertex fn cover(@builtin(vertex_index) i: u32) -> @builtin(position) vec4<f32> {
return vec4<f32>(f32(i & 1u) * 4.0 - 1.0, f32(i >> 1u) * 4.0 - 1.0, 0.0, 1.0);
}
@fragment fn nothing() -> @location(0) vec4<f32> { return vec4<f32>(0.0); }
@compute @workgroup_size(1) fn mark() {}";
impl GpuTimer {
/// The features timing uses that the adapter has (none: no timing).
pub fn features(adapter: &wgpu::Adapter) -> wgpu::Features {
@@ -44,11 +80,11 @@ impl GpuTimer {
let queries = device.create_query_set(&wgpu::QuerySetDescriptor {
label: Some("Frame timestamps"),
ty: wgpu::QueryType::Timestamp,
count: 2,
count: 2 * SLOTS as u32,
});
let resolve = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame timestamp resolve"),
size: 16,
size: RESOLVE_STRIDE * SLOTS as u64,
usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
@@ -70,42 +106,43 @@ impl GpuTimer {
current: None,
next: 0,
period_ns: queue.get_timestamp_period(),
marker: (!device
markers: (!device
.features()
.contains(wgpu::Features::TIMESTAMP_QUERY_INSIDE_ENCODERS))
.then(|| {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Frame timestamp"),
source: wgpu::ShaderSource::Wgsl(
"@compute @workgroup_size(1) fn mark() {}".into(),
),
source: wgpu::ShaderSource::Wgsl(MARKER.into()),
});
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Frame timestamp"),
layout: None,
module: &module,
entry_point: Some("mark"),
compilation_options: Default::default(),
cache: None,
})
Markers {
start: device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Frame timestamp"),
layout: None,
module: &module,
entry_point: Some("mark"),
compilation_options: Default::default(),
cache: None,
}),
module,
end: None,
}
}),
resolved: Vec::new(),
})
}
pub fn begin(&mut self, encoder: &mut wgpu::CommandEncoder) {
if self.markers.is_some() {
for slot in 0..SLOTS {
if self.slots[slot].state.load(Ordering::Acquire) == DONE {
self.resolve(encoder, slot);
}
}
}
let slot = self.next;
self.current = (self.slots[slot].state.load(Ordering::Acquire) == FREE).then_some(slot);
if self.current.is_some() {
self.stamp(encoder, 0);
}
}
pub fn end(&mut self, encoder: &mut wgpu::CommandEncoder) {
let Some(slot) = self.current else { return };
self.stamp(encoder, 1);
encoder.resolve_query_set(&self.queries, 0..2, &self.resolve, 0);
encoder.copy_buffer_to_buffer(&self.resolve, 0, &self.slots[slot].buffer, 0, 16);
}
fn stamp(&self, encoder: &mut wgpu::CommandEncoder, index: u32) {
let Some(marker) = &self.marker else {
let index = 2 * slot as u32;
let Some(markers) = &self.markers else {
encoder.write_timestamp(&self.queries, index);
return;
};
@@ -113,29 +150,85 @@ impl GpuTimer {
label: Some("Frame timestamp"),
timestamp_writes: Some(wgpu::ComputePassTimestampWrites {
query_set: &self.queries,
beginning_of_pass_write_index: (index == 0).then_some(0),
end_of_pass_write_index: (index == 1).then_some(1),
beginning_of_pass_write_index: Some(index),
end_of_pass_write_index: None,
}),
});
pass.set_pipeline(marker);
pass.set_pipeline(&markers.start);
pass.dispatch_workgroups(1, 1, 1);
}
/// After the frame's submission: start reading its timestamps back.
pub fn submitted(&mut self) {
/// After the frame's last pass into `target` (the frame's own, of `format`).
pub fn end(
&mut self,
device: &wgpu::Device,
encoder: &mut wgpu::CommandEncoder,
(target, format): Target,
) {
let Some(slot) = self.current else { return };
let index = 2 * slot as u32 + 1;
let Some(markers) = &mut self.markers else {
encoder.write_timestamp(&self.queries, index);
self.resolve(encoder, slot);
return;
};
if markers.end.as_ref().is_none_or(|(f, _)| *f != format) {
markers.end = Some((format, end_pipeline(device, &markers.module, format)));
}
let (_, pipeline) = markers.end.as_ref().expect("made above");
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Frame timestamp"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
timestamp_writes: Some(wgpu::RenderPassTimestampWrites {
query_set: &self.queries,
beginning_of_pass_write_index: None,
end_of_pass_write_index: Some(index),
}),
..Default::default()
});
pass.set_pipeline(pipeline);
pass.set_scissor_rect(0, 0, 1, 1);
pass.draw(0..3, 0..1);
}
/// Copy `slot`'s timestamps to its readback buffer.
fn resolve(&mut self, encoder: &mut wgpu::CommandEncoder, slot: usize) {
let (first, at) = (2 * slot as u32, RESOLVE_STRIDE * slot as u64);
encoder.resolve_query_set(&self.queries, first..first + 2, &self.resolve, at);
encoder.copy_buffer_to_buffer(&self.resolve, at, &self.slots[slot].buffer, 0, 16);
self.slots[slot].state.store(RUNNING, Ordering::Release);
self.resolved.push(slot);
}
/// After the frame's submission: start reading back what it resolved,
/// and with markers, wait for the GPU to finish the frame it timed.
pub fn submitted(&mut self, queue: &wgpu::Queue) {
for slot in self.resolved.drain(..) {
let state = self.slots[slot].state.clone();
state.store(PENDING, Ordering::Release);
self.slots[slot]
.buffer
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
Ordering::Release,
);
});
}
let Some(slot) = self.current.take() else {
return;
};
let state = self.slots[slot].state.clone();
state.store(PENDING, Ordering::Release);
self.slots[slot]
.buffer
.slice(..)
.map_async(wgpu::MapMode::Read, move |result| {
state.store(
if result.is_ok() { MAPPED } else { FREE },
Ordering::Release,
);
});
if self.markers.is_some() {
let state = self.slots[slot].state.clone();
state.store(RUNNING, Ordering::Release);
queue.on_submitted_work_done(move || state.store(DONE, Ordering::Release));
}
self.next = (slot + 1) % SLOTS;
}
/// GPU milliseconds of frames whose timestamps have arrived, oldest first.
@@ -149,11 +242,46 @@ impl GpuTimer {
if let Ok(data) = slot.buffer.slice(..).get_mapped_range() {
// Read unaligned: the mapping isn't guaranteed to be 8-byte aligned.
let stamps: [u64; 2] = bytemuck::pod_read_unaligned(&data[..16]);
let ticks = stamps[1].saturating_sub(stamps[0]);
out.push((ticks as f64 * f64::from(self.period_ns) / 1e6) as f32);
// A start marker that ran late (rare on Metal) leaves the frame untimed.
if let Some(ticks) = stamps[1].checked_sub(stamps[0]) {
out.push((ticks as f64 * f64::from(self.period_ns) / 1e6) as f32);
}
}
slot.buffer.unmap();
slot.state.store(FREE, Ordering::Release);
}
}
}
/// The end marker's pipeline for a `format` target, every write masked.
fn end_pipeline(
device: &wgpu::Device,
module: &wgpu::ShaderModule,
format: wgpu::TextureFormat,
) -> wgpu::RenderPipeline {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Frame timestamp"),
layout: None,
vertex: wgpu::VertexState {
module,
entry_point: Some("cover"),
compilation_options: Default::default(),
buffers: &[],
},
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
multisample: wgpu::MultisampleState::default(),
fragment: Some(wgpu::FragmentState {
module,
entry_point: Some("nothing"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: None,
write_mask: wgpu::ColorWrites::empty(),
})],
}),
multiview_mask: None,
cache: None,
})
}
+568 -23
View File
@@ -1,26 +1,163 @@
//! 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. A shader pack
//! also needs the target (its stages read the world's colour and depth).
//! 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,
}
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 {
@@ -88,76 +225,482 @@ 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.
/// it straight to the screen. `pack`: a shader pack runs this frame,
/// reading `extras`.
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 {
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) {
if self
.target
.as_ref()
.is_none_or(|t| t.size != size || t.extras != extras)
{
self.generation += 1;
let extent = wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
};
let texture = |label, format, usage| {
device
.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
.create_view(&Default::default())
let make_tex = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: extent,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage,
view_formats: &[],
})
};
let color = texture(
let color = make_tex(
"Scaled world",
self.format,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
);
let depth = texture(
)
.create_view(&Default::default());
let depth_tex = make_tex(
"Scaled world depth",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
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,
}
});
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 {
@@ -175,11 +718,13 @@ impl Post {
],
})
}
/// Draw the scaled world onto `screen`.
pub fn finish(&self, encoder: &mut wgpu::CommandEncoder, screen: &wgpu::TextureView) {
let Some(target) = &self.target else { return };
self.finish_with(encoder, screen, &target.group);
}
/// Draw what `group` binds (`bind`) onto `screen`.
pub fn finish_with(
&self,
+67 -1
View File
@@ -156,6 +156,48 @@ pub enum Overlay {
Graph,
}
/// How finely far terrain is drawn (`FarDetail` in the LOD options).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum FarDetail {
/// Each tile level starts at eight times its size from the camera.
#[default]
Full,
/// Coarser levels start at half that distance: about a quarter of the
/// far quads, for GPUs that far terrain slows down. Changes the picture.
Reduced,
}
impl FarDetail {
pub fn name(self) -> &'static str {
match self {
FarDetail::Full => "full",
FarDetail::Reduced => "reduced",
}
}
pub fn label(self) -> &'static str {
match self {
FarDetail::Full => "Full",
FarDetail::Reduced => "Reduced",
}
}
fn parse(s: &str) -> Self {
if s == "reduced" {
FarDetail::Reduced
} else {
FarDetail::Full
}
}
}
static REDUCED_SUGGESTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether the settings suggest Reduced far detail: set when the renderer
/// starts, on the GPUs `lod_capped` names (far terrain slows them most).
pub fn suggest_reduced_far_detail(on: bool) {
REDUCED_SUGGESTED.store(on, std::sync::atomic::Ordering::Relaxed);
}
pub fn reduced_far_detail_suggested() -> bool {
REDUCED_SUGGESTED.load(std::sync::atomic::Ordering::Relaxed)
}
/// The farthest LOD Distance, in chunks, on integrated GPUs other than
/// Apple's: they share the CPU's memory bandwidth, and past it far terrain
/// costs them more frame time than it is worth.
@@ -181,6 +223,8 @@ pub struct Graphics {
pub preset: Preset,
/// Distant surface terrain range, in chunks; 0 disables LODs.
pub lod_distance: u32,
/// How finely far terrain is drawn; Full unless the player picks Reduced.
pub far_detail: FarDetail,
/// Skip sections hidden behind closed ones (vanilla's occlusion graph).
pub cave_culling: bool,
/// Hide shared leaf and mangrove-root faces, reducing foliage density.
@@ -219,6 +263,7 @@ impl Default for Graphics {
Self {
preset: Preset::Custom,
lod_distance: 0,
far_detail: FarDetail::Full,
cave_culling: true,
cull_leaves: false,
entity_culling: false,
@@ -265,6 +310,7 @@ impl Graphics {
match key.trim() {
"preset" => g.preset = Preset::parse(value),
"lodDistance" => g.lod_distance = number(g.lod_distance).min(1024),
"farDetail" => g.far_detail = FarDetail::parse(value),
"caveCulling" => g.cave_culling = flag(g.cave_culling),
"cullLeaves" => g.cull_leaves = flag(g.cull_leaves),
"entityCulling" => g.entity_culling = flag(g.entity_culling),
@@ -302,9 +348,10 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\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:{}\nshaderPack:{}\ngraphicsApi:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
bool_text(self.cave_culling),
bool_text(self.cull_leaves),
bool_text(self.entity_culling),
@@ -405,6 +452,7 @@ mod tests {
let g = Graphics {
preset: Preset::Balanced,
lod_distance: 256,
far_detail: FarDetail::Reduced,
cave_culling: false,
cull_leaves: true,
entity_culling: true,
@@ -487,6 +535,24 @@ mod tests {
assert_eq!(Graphics::parse("lodDistance:invalid\n").lod_distance, 0);
}
#[test]
fn far_detail_is_full_unless_reduced_is_picked() {
assert_eq!(Graphics::default().far_detail, FarDetail::Full);
assert_eq!(
Graphics::parse("farDetail:reduced\n").far_detail,
FarDetail::Reduced
);
for text in ["farDetail:full\n", "farDetail:other\n", ""] {
assert_eq!(Graphics::parse(text).far_detail, FarDetail::Full);
}
// No preset changes it.
for preset in Preset::ALL {
let mut s = crate::settings::Settings::default();
apply_preset(preset, &mut s);
assert_eq!(s.horizon.far_detail, FarDetail::Full, "{preset:?}");
}
}
#[test]
fn automatic_workers_leave_cores_for_the_render_and_server_threads() {
let g = Graphics::default();
+24 -7
View File
@@ -2,7 +2,7 @@
//! they show, in what groups, with what description and cost, and their
//! input. How they look comes from `graphics_style`.
use crate::{
graphics::{GraphicsApi, Overlay, Pacing, PresentMode, Preset},
graphics::{FarDetail, GraphicsApi, Overlay, Pacing, PresentMode, Preset},
graphics_style::{Cost, STYLE},
hud::{HudAtlas, HudBatch},
options_ui::Opt,
@@ -22,6 +22,7 @@ pub enum Level {
pub enum H {
Preset,
LodDistance,
FarDetail,
CaveCulling,
CullLeaves,
EntityCulling,
@@ -85,6 +86,16 @@ pub fn rows(level: Level) -> Vec<Row> {
Medium,
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.",
);
// Suggested, never switched on, where far terrain costs the GPU most.
let far = h(
H::FarDetail,
Medium,
if crate::graphics::reduced_far_detail_suggested() {
"Reduced draws far terrain coarser: faster on this integrated GPU."
} else {
"Reduced draws far terrain coarser, for GPUs it slows down."
},
);
let fov = v(
Opt::Fov,
Low,
@@ -101,17 +112,23 @@ 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, fov, fps, vsync, gui],
Level::Simple => vec![preset, render, lod, far, fov, fps, vsync, gui, shader_pack],
Level::Advanced => vec![
Row::Header("Rendering"),
render,
lod,
far,
v(
Opt::SimulationDistance,
Medium,
@@ -234,11 +251,7 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"Lowers the frame rate when minimised, or idle too.",
),
h(
H::ShaderPack,
High,
"Redraws the world with a pack from the shaderpacks folder. Off is vanilla.",
),
shader_pack,
Row::Header("Preferences"),
v(Opt::ViewBobbing, None, "Sways the view while walking."),
v(Opt::ChunkFade, None, "Fades newly loaded chunks in."),
@@ -335,6 +348,7 @@ impl H {
0 => labelled("LOD Distance", "Off"),
n => labelled("LOD Distance", format!("{n} chunks")),
},
H::FarDetail => labelled("Far Detail", g.far_detail.label()),
H::CaveCulling => labelled("Cave Culling", on_off(g.cave_culling)),
H::CullLeaves => labelled("Cull Leaves", on_off(g.cull_leaves)),
H::EntityCulling => labelled("Entity Culling", on_off(g.entity_culling)),
@@ -411,6 +425,9 @@ impl H {
H::LodDistance => {
g.lod_distance = step(&[0, 64, 128, 256, 512, 1024], g.lod_distance, back)
}
H::FarDetail => {
g.far_detail = step(&[FarDetail::Full, FarDetail::Reduced], g.far_detail, back)
}
H::CaveCulling => g.cave_culling = !g.cave_culling,
H::CullLeaves => g.cull_leaves = !g.cull_leaves,
H::EntityCulling => g.entity_culling = !g.entity_culling,
+390 -43
View File
@@ -9,6 +9,11 @@ use std::{
};
use wgpu::util::DeviceExt;
/// A sprite's atlas region, and its size in GUI pixels: the size of the
/// default resources' texture of that name, whatever the resolution of the
/// pack that replaced it (vanilla's GUI code draws in those units: `blit`
/// divides by the texture size it is given, sprites by their `gui.scaling`
/// size). Textures the default resources lack keep their own size.
#[derive(Clone, Copy, Debug)]
pub struct Rect {
pub u0: f32,
@@ -18,11 +23,44 @@ pub struct Rect {
pub w: u32,
pub h: u32,
}
impl Rect {
/// The region's size in texels of an atlas this large.
pub fn texels(&self, (width, height): (u32, u32)) -> (u32, u32) {
(
((self.u1 - self.u0) * width as f32).round() as u32,
((self.v1 - self.v0) * height as f32).round() as u32,
)
}
}
/// `TextureMetadataSection`'s `blur` and `clamp`: how vanilla samples a
/// standalone texture (`ReloadableTexture.setSampler`: linear rather than
/// nearest filtering, clamp-to-edge rather than repeat addressing).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct TextureFlags {
pub blur: bool,
pub clamp: bool,
}
impl TextureFlags {
/// The `texture` section of a `.png.mcmeta` file, if it sets either flag.
pub fn from_mcmeta(json: &str) -> Option<Self> {
let meta: serde_json::Value = serde_json::from_str(json).ok()?;
let section = meta.get("texture")?;
let flag = |key| section.get(key).and_then(serde_json::Value::as_bool) == Some(true);
let flags = Self {
blur: flag("blur"),
clamp: flag("clamp"),
};
(flags != Self::default()).then_some(flags)
}
}
pub struct HudAtlas {
pub pixels: Vec<u8>,
pub width: u32,
pub height: u32,
pub sprites: HashMap<String, Rect>,
/// Sprites whose `.png.mcmeta` sets `blur` or `clamp`; the others
/// sample as vanilla's defaults (nearest, repeat).
pub flags: HashMap<String, TextureFlags>,
pub fire_animations: Vec<(Rect, crate::sprites::SpriteAnimation)>,
/// The default font (bitmap sheets in this atlas, unifont rasterized on demand).
pub font: crate::font::Font,
@@ -30,6 +68,9 @@ pub struct HudAtlas {
}
/// Atlas rows reserved for glyphs rasterized at run time.
pub const GLYPH_ROWS: u32 = 512;
/// The largest atlas side: the 2D texture limit of the GPUs Horizon runs on
/// (Apple silicon, RADV and Intel allow 16384; NVIDIA 32768).
pub const MAX_ATLAS_SIZE: u32 = 16384;
/// 128x128 atlas slots for map textures. A lobby paints its signs as walls of
/// framed maps, so the pool holds a wall; too few slots re-upload every map
/// every frame. 26.3's `MapTextureManager` keeps a texture per map instead.
@@ -40,6 +81,9 @@ pub const MINIMAP_SPRITE: &str = "horizon/minimap";
pub const SKIN_SLOTS: usize = 32;
/// Reused by the visible rows of the classic world selector.
pub const WORLD_ICON_SLOTS: usize = 32;
/// Atlas slots for armour trim textures made at run time (`trims`), each the
/// size of the largest trim texture.
pub const TRIM_SLOTS: usize = 32;
pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
decode_bytes(&fs::read(path)?)
}
@@ -62,6 +106,15 @@ pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
}
Ok((info.width, info.height, rgba))
}
/// A PNG's width and height, from its header alone.
fn png_size(path: &Path) -> Option<(u32, u32)> {
use std::io::Read;
let mut header = [0u8; 24];
fs::File::open(path).ok()?.read_exact(&mut header).ok()?;
let field = |at: usize| u32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
(header[..8] == *b"\x89PNG\r\n\x1a\n" && header[12..16] == *b"IHDR")
.then(|| (field(16), field(20)))
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
let Ok(entries) = fs::read_dir(dir) else {
return;
@@ -77,6 +130,20 @@ fn walk(dir: &Path, out: &mut Vec<PathBuf>) {
}
/// A packed atlas image: sprite name, size and RGBA pixels.
type PackedImage = (String, u32, u32, Vec<u8>);
/// `image` inside a one-texel border copied from its opposite edges, the
/// texels repeat addressing reads past each edge.
fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
let (bw, bh) = (w + 2, h + 2);
let mut out = Vec::with_capacity((bw * bh * 4) as usize);
for y in 0..bh {
let row = (y + h - 1) % h;
for x in 0..bw {
let at = ((row * w + (x + w - 1) % w) * 4) as usize;
out.extend_from_slice(&data[at..at + 4]);
}
}
(name.clone(), bw, bh, out)
}
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
/// files already packed: generated ones (`paletted_permutations` such as the
/// armour trim sprites, `unstitch` regions) and files listed under another id.
@@ -160,8 +227,14 @@ impl HudAtlas {
(g.rect, g.advance)
}
pub fn build(root: &Path) -> Result<Self> {
Self::build_over(root, root)
}
/// The atlas of a resource root that layers packs over `defaults` (the
/// game's own resources), whose textures give the sprites' GUI sizes.
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
Self::pack(
root,
defaults,
&[
"gui",
"item",
@@ -180,7 +253,7 @@ impl HudAtlas {
}
/// Empty slots written at run time: map textures (game/maps.rs), the
/// minimap, downloaded skins, world icons and the on-screen keyboard's text.
fn slots(images: &mut Vec<PackedImage>) {
fn slots(images: &mut Vec<PackedImage>, trim: (u32, u32)) {
for i in 0..MAP_SLOTS {
images.push((format!("horizon/map{i}"), 128, 128, vec![0; 128 * 128 * 4]));
}
@@ -195,6 +268,14 @@ impl HudAtlas {
for i in 0..SKIN_SLOTS {
images.push((format!("horizon/skin{i}"), 64, 64, vec![0; 64 * 64 * 4]));
}
for i in 0..TRIM_SLOTS {
images.push((
format!("horizon/trim{i}"),
trim.0,
trim.1,
vec![0; (trim.0 * trim.1 * 4) as usize],
));
}
for i in 0..WORLD_ICON_SLOTS {
images.push((
format!("horizon/world-icon{i}"),
@@ -216,14 +297,53 @@ impl HudAtlas {
/// holding its textures. The pixels are dropped once the glyph widths are
/// read, since nothing uploads them.
pub fn fonts(root: &Path) -> Result<Self> {
let mut atlas = Self::pack(root, &["font"], false)?;
let mut atlas = Self::pack(root, root, &["font"], false)?;
atlas.pixels = Vec::new();
Ok(atlas)
}
/// Shelf placements of `images` (tallest first) in the narrowest atlas
/// width from 4096 up that keeps the atlas, glyph rows included, within
/// `max` texels a side; and the first row free for glyphs. High-resolution
/// packs fill a 4096-wide atlas past what GPUs allow (Faithful 64x needs
/// 32768 rows); vanilla's stitcher fails the reload when its atlases don't
/// fit, and the packs are then turned off (`Minecraft.clearResourcePacksOnError`).
fn layout(images: &[PackedImage], max: u32) -> Result<(u32, Vec<(u32, u32)>, u32)> {
let mut width = 4096.min(max);
loop {
let mut placements = Vec::with_capacity(images.len());
let (mut x, mut y, mut row) = (0u32, 0u32, 0u32);
for (_, w, h, _) in images {
if x + w + 1 > width {
x = 0;
y += row + 1;
row = 0;
}
placements.push((x, y));
x += w + 1;
row = row.max(*h);
}
let glyph_region = y + row + 1;
let wide_enough = images.iter().all(|i| i.1 < width);
if wide_enough && glyph_region + GLYPH_ROWS <= max {
return Ok((width, placements, glyph_region));
}
if width >= max {
let texels: u64 = images.iter().map(|i| u64::from(i.1) * u64::from(i.2)).sum();
return Err(format!(
"the textures ({} images, {texels} texels) do not fit a {max}x{max} atlas",
images.len()
)
.into());
}
width = (width * 2).min(max);
}
}
/// Pack the textures under these folders of `textures/`, and for the
/// `full` atlas the generated sprites and the run-time slots too.
fn pack(root: &Path, folders: &[&str], full: bool) -> Result<Self> {
fn pack(root: &Path, defaults: &Path, folders: &[&str], full: bool) -> Result<Self> {
let textures = root.join("assets/minecraft/textures");
let default_textures =
(defaults != root).then(|| defaults.join("assets/minecraft/textures"));
let mut files = Vec::new();
for folder in folders {
walk(&textures.join(folder), &mut files);
@@ -232,7 +352,8 @@ impl HudAtlas {
let s = p.to_string_lossy();
!s.contains("title/background") && !s.contains("realms") && !s.contains("presets")
});
let decode_one = |path: &PathBuf| -> Option<(String, u32, u32, Vec<u8>)> {
type Decoded = (PackedImage, Option<TextureFlags>, Option<(u32, u32)>);
let decode_one = |path: &PathBuf| -> Option<Decoded> {
let name = path
.strip_prefix(&textures)
.ok()?
@@ -240,18 +361,33 @@ impl HudAtlas {
.to_string_lossy()
.replace('\\', "/");
let (w, mut h, pixels) = decode(path).ok()?;
let is_frame_strip = (name.starts_with("block/") || name.starts_with("item/")) && h > w;
let pixels = if is_frame_strip {
// Animated strips keep their first frame.
let frames = name.starts_with("block/") || name.starts_with("item/");
let pixels = if frames && h > w {
h = w;
pixels[..(w * w * 4) as usize].to_vec()
} else {
pixels
};
(w <= 1024 && (h <= 512 || name.starts_with("font/"))).then_some((name, w, h, pixels))
// The default texture's size, when a pack's differs from it.
let gui_size = default_textures
.as_ref()
.and_then(|d| png_size(&d.join(format!("{name}.png"))))
.map(|(dw, dh)| (dw, if frames { dh.min(dw) } else { dh }))
.filter(|&size| size != (w, h));
let flags = fs::read_to_string(path.with_extension("png.mcmeta"))
.ok()
.and_then(|json| TextureFlags::from_mcmeta(&json));
let (gw, gh) = gui_size.unwrap_or((w, h));
(gw <= 1024 && (gh <= 512 || name.starts_with("font/"))).then_some((
(name, w, h, pixels),
flags,
gui_size,
))
};
let threads = std::thread::available_parallelism().map_or(2, |n| n.get());
let chunk = files.len().div_ceil(threads).max(1);
let mut images: Vec<_> = std::thread::scope(|scope| {
let decoded: Vec<_> = std::thread::scope(|scope| {
let handles: Vec<_> = files
.chunks(chunk)
.map(|part| {
@@ -263,6 +399,18 @@ impl HudAtlas {
.flat_map(|h| h.join().unwrap_or_default())
.collect()
});
let mut flags = HashMap::new();
let mut gui_sizes = HashMap::new();
let mut images: Vec<PackedImage> = Vec::with_capacity(decoded.len());
for (image, image_flags, gui_size) in decoded {
if let Some(f) = image_flags {
flags.insert(image.0.clone(), f);
}
if let Some(size) = gui_size {
gui_sizes.insert(image.0.clone(), size);
}
images.push(image);
}
if full {
images.extend(atlas_sprites(root, &images));
}
@@ -277,24 +425,23 @@ impl HudAtlas {
}
images.push(("horizon/white".into(), 4, 4, vec![255; 64]));
if full {
Self::slots(&mut images);
Self::slots(&mut images, crate::trims::slot_size(root));
}
// Blurred sprites are sampled linearly in the atlas (screen.wgsl): a
// border wrapped from their own edges keeps the filter off their
// neighbours, as repeat addressing of their own texture would.
let mut bordered = std::collections::HashSet::new();
for image in &mut images {
if flags.get(&image.0).is_some_and(|f: &TextureFlags| f.blur) {
*image = wrap_border(image);
bordered.insert(image.0.clone());
}
}
images.sort_by(|a, b| b.2.cmp(&a.2).then(b.1.cmp(&a.1)));
let width = 4096u32;
let mut placements = Vec::with_capacity(images.len());
let (mut x, mut y, mut row) = (0u32, 0u32, 0u32);
for (_, w, h, _) in &images {
if x + w + 1 > width {
x = 0;
y += row + 1;
row = 0;
}
placements.push((x, y));
x += w + 1;
row = row.max(*h);
}
let glyph_region = y + row + 1;
let height = (glyph_region + GLYPH_ROWS).next_power_of_two();
let (width, placements, glyph_region) = Self::layout(&images, MAX_ATLAS_SIZE)?;
// A multiple of 1024 rather than a power of two: the default resources
// need 4096x5120 (8192 rows before), Faithful 32x 4096x12288 (16384).
let height = (glyph_region + GLYPH_ROWS).next_multiple_of(1024);
let mut pixels = vec![0u8; (width * height * 4) as usize];
let mut sprites = HashMap::new();
for ((name, w, h, data), (px, py)) in images.iter().zip(placements) {
@@ -303,6 +450,9 @@ impl HudAtlas {
let dst = (((py + row) * width + px) * 4) as usize;
pixels[dst..dst + (w * 4) as usize].copy_from_slice(src);
}
let b = u32::from(bordered.contains(name));
let (px, py, w, h) = (px + b, py + b, w - 2 * b, h - 2 * b);
let (gw, gh) = gui_sizes.get(name).copied().unwrap_or((w, h));
sprites.insert(
name.clone(),
Rect {
@@ -310,8 +460,8 @@ impl HudAtlas {
v0: py as f32 / height as f32,
u1: (px + w) as f32 / width as f32,
v1: (py + h) as f32 / height as f32,
w: *w,
h: *h,
w: gw,
h: gh,
},
);
}
@@ -331,14 +481,15 @@ impl HudAtlas {
.find(|a| a.layer == layer)
.cloned()
{
let image = animation.resized_pixels(animation.frame(0), rect.w, rect.h);
let (tw, th) = rect.texels((width, height));
let image = animation.resized_pixels(animation.frame(0), tw, th);
let x = (rect.u0 * width as f32).round() as usize;
let y = (rect.v0 * height as f32).round() as usize;
for row in 0..rect.h as usize {
let row_bytes = tw as usize * 4;
for row in 0..th as usize {
let dst = ((y + row) * width as usize + x) * 4;
let src = row * rect.w as usize * 4;
pixels[dst..dst + rect.w as usize * 4]
.copy_from_slice(&image[src..src + rect.w as usize * 4]);
let src = row * row_bytes;
pixels[dst..dst + row_bytes].copy_from_slice(&image[src..src + row_bytes]);
}
fire_animations.push((rect, animation));
}
@@ -358,6 +509,7 @@ impl HudAtlas {
width,
height,
sprites,
flags,
fire_animations,
font,
white,
@@ -384,6 +536,8 @@ pub struct HudBatch {
pub patches: Vec<crate::font::Patch>,
/// Vertex ranges with another blend or depth/scissor state, in order.
pub blends: Vec<(std::ops::Range<u32>, Blend)>,
/// The textures and time of the `Blend::EndPortal` runs.
pub end_portal: EndPortalFill,
/// Screen's first vertex: the preceding HUD is part of its blurred background.
pub blur_at: u32,
}
@@ -398,6 +552,19 @@ pub enum Blend {
Additive,
/// Alpha blending with an isolated depth buffer and a pixel scissor.
Depth([u32; 4]),
/// `RenderPipelines.VIGNETTE`: the destination darkened by the texture
/// (`ZERO, ONE_MINUS_SRC_COLOR`).
Vignette,
/// `RenderPipelines.END_PORTAL` as a GUI fill: the End portal's layers
/// projected from the screen (`HudBatch::end_portal` sets the textures).
EndPortal,
}
/// What a `Blend::EndPortal` fill samples: the atlas rects (u0, v0, width,
/// height) of `end_sky.png` and `end_portal.png`, and the `GameTime` uniform.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct EndPortalFill {
pub sprites: [[f32; 4]; 2],
pub game_time: f32,
}
pub fn rgba(r: u8, g: u8, b: u8, a: u8) -> u32 {
u32::from(a) << 24 | u32::from(b) << 16 | u32::from(g) << 8 | u32::from(r)
@@ -421,6 +588,7 @@ impl HudBatch {
height: (window[1] as f32 / scale as f32).ceil(),
patches: Vec::new(),
blends: Vec::new(),
end_portal: EndPortalFill::default(),
blur_at: 0,
}
}
@@ -727,7 +895,13 @@ impl HudBatch {
let mut glyphs = Vec::new();
let mut cursor = x;
for (i, (ch, style)) in text.chars().enumerate() {
let g = font.glyph(ch);
// The style's font (`Style.getFont`), the default one unless set.
let mut g = font.glyph_styled(ch, style);
// Font.StringRenderOutput.accept: obfuscated characters draw a
// random glyph of the same width each frame.
if style.obfuscated == Some(true) && ch != ' ' {
g = font.random_glyph(style, g.advance);
}
let c = style.color.map_or(color, |rgb| {
rgba((rgb >> 16) as u8, (rgb >> 8) as u8, rgb as u8, alpha)
});
@@ -821,6 +995,20 @@ impl HudBatch {
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct HudUniform {
size: [f32; 4],
/// `EndPortalFill`: the two sprites, then the game time in x.
end_portal: [[f32; 4]; 3],
}
impl HudUniform {
fn new(size: [f32; 4], fill: EndPortalFill) -> Self {
Self {
size,
end_portal: [
fill.sprites[0],
fill.sprites[1],
[fill.game_time, 0.0, 0.0, 0.0],
],
}
}
}
pub struct HudRenderer {
pub fire_animations: Vec<(Rect, crate::sprites::SpriteAnimation, usize)>,
@@ -831,6 +1019,8 @@ pub struct HudRenderer {
invert_pipeline: wgpu::RenderPipeline,
additive_pipeline: wgpu::RenderPipeline,
depth_pipeline: wgpu::RenderPipeline,
vignette_pipeline: wgpu::RenderPipeline,
end_portal_pipeline: wgpu::RenderPipeline,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
buffer: wgpu::Buffer,
@@ -853,7 +1043,7 @@ impl HudRenderer {
});
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("HUD size"),
contents: bytemuck::bytes_of(&HudUniform { size: [1.0; 4] }),
contents: bytemuck::bytes_of(&HudUniform::new([1.0; 4], EndPortalFill::default())),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
@@ -861,7 +1051,7 @@ impl HudRenderer {
entries: &[
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
@@ -989,6 +1179,31 @@ impl HudRenderer {
"fs_main",
false,
);
// RenderPipelines.VIGNETTE: ZERO, ONE_MINUS_SRC_COLOR, ZERO, ONE.
let vignette_pipeline = create(
"HUD vignette",
wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::OneMinusSrc,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
},
"fs_main",
false,
);
// RenderPipelines.END_PORTAL: opaque.
let end_portal_pipeline = create(
"HUD end portal",
wgpu::BlendState::REPLACE,
"fs_end_portal",
false,
);
let capacity = 1 << 16;
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("HUD vertices"),
@@ -1003,6 +1218,8 @@ impl HudRenderer {
invert_pipeline,
additive_pipeline,
depth_pipeline,
vignette_pipeline,
end_portal_pipeline,
group,
uniform,
buffer,
@@ -1023,7 +1240,8 @@ impl HudRenderer {
if frame == *previous {
continue;
}
let pixels = animation.resized_pixels(frame, rect.w, rect.h);
let (w, h) = rect.texels((texture.width(), texture.height()));
let pixels = animation.resized_pixels(frame, w, h);
uploads.texture(
encoder,
wgpu::TexelCopyTextureInfo {
@@ -1036,8 +1254,8 @@ impl HudRenderer {
},
aspect: wgpu::TextureAspect::All,
},
rect.w,
rect.h,
w,
h,
&pixels,
);
*previous = frame;
@@ -1096,9 +1314,10 @@ impl HudRenderer {
encoder,
&self.uniform,
0,
bytemuck::bytes_of(&HudUniform {
size: [size[0] as f32, size[1] as f32, batch.scale, 0.0],
}),
bytemuck::bytes_of(&HudUniform::new(
[size[0] as f32, size[1] as f32, batch.scale, 0.0],
batch.end_portal,
)),
);
}
/// Draw the batch `prepare` staged.
@@ -1186,6 +1405,8 @@ impl HudRenderer {
Blend::Invert => &self.invert_pipeline,
Blend::Additive => &self.additive_pipeline,
Blend::Depth(_) => &self.depth_pipeline,
Blend::Vignette => &self.vignette_pipeline,
Blend::EndPortal => &self.end_portal_pipeline,
});
pass.draw(range.clone(), 0..1);
}
@@ -1198,10 +1419,115 @@ impl HudRenderer {
mod tests {
use super::*;
/// `count` images of `w x h`; layout reads only their sizes.
fn sized(count: usize, w: u32, h: u32) -> Vec<PackedImage> {
(0..count)
.map(|i| (format!("i{i}"), w, h, Vec::new()))
.collect()
}
fn assert_inside(images: &[PackedImage], width: u32, placements: &[(u32, u32)], free: u32) {
for ((_, w, h, _), (x, y)) in images.iter().zip(placements) {
assert!(
x + w <= width && y + h < free,
"{x},{y} {w}x{h} outside {width}x{free}"
);
}
}
fn write_png(root: &Path, name: &str, w: u32, h: u32) {
let path = root.join(format!("assets/minecraft/textures/{name}.png"));
fs::create_dir_all(path.parent().unwrap()).unwrap();
let mut encoder = png::Encoder::new(fs::File::create(path).unwrap(), w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let pixels = vec![255; (w * h * 4) as usize];
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
}
#[test]
fn the_fonts_only_atlas_measures_text_like_the_full_one() {
// A synthetic default font: two 8x8 glyphs, 3 and 5 pixels wide.
let root = std::env::temp_dir().join(format!("horizon-fonts-atlas-{}", std::process::id()));
fn a_pack_texture_keeps_the_default_textures_gui_size() {
let temp = std::env::temp_dir().join(format!("horizon-gui-size-{}", std::process::id()));
let (defaults, pack) = (temp.join("defaults"), temp.join("pack"));
write_png(&defaults, "gui/container/inventory", 256, 256);
write_png(&defaults, "gui/sprites/widget/button", 200, 20);
write_png(&defaults, "block/stone", 16, 16);
write_png(&defaults, "item/compass", 16, 512);
// Faithful 64x's inventory (past the 512 rows a texture may have),
// 32x's button and stone, an animated strip, and a texture of its own.
write_png(&pack, "gui/container/inventory", 1024, 1024);
write_png(&pack, "gui/sprites/widget/button", 400, 40);
write_png(&pack, "block/stone", 32, 32);
write_png(&pack, "item/compass", 32, 1024);
write_png(&pack, "gui/sprites/pack_only", 30, 10);
let atlas = HudAtlas::build_over(&pack, &defaults).unwrap();
let _ = fs::remove_dir_all(&temp);
let size = (atlas.width, atlas.height);
for (name, gui, texels) in [
("gui/container/inventory", (256, 256), (1024, 1024)),
("gui/sprites/widget/button", (200, 20), (400, 40)),
("block/stone", (16, 16), (32, 32)),
("item/compass", (16, 16), (32, 32)),
("gui/sprites/pack_only", (30, 10), (30, 10)),
] {
let r = atlas.sprites[name];
assert_eq!(((r.w, r.h), r.texels(size)), (gui, texels), "{name}");
}
}
#[test]
fn the_default_textures_keep_a_4096_wide_atlas() {
let images = sized(400, 64, 64);
let (width, placements, free) = HudAtlas::layout(&images, MAX_ATLAS_SIZE).unwrap();
assert_eq!(width, 4096);
assert_inside(&images, width, &placements, free);
}
#[test]
fn a_high_resolution_pack_widens_the_atlas_to_fit_the_gpu() {
// 34 rows of three 1024x512 GUI textures: 17442 rows at 4096 wide,
// past the 16384 GPUs allow (Faithful 64x needed 32768).
let images = sized(100, 1024, 512);
let (width, placements, free) = HudAtlas::layout(&images, MAX_ATLAS_SIZE).unwrap();
assert_eq!(width, 8192);
assert!(free + GLYPH_ROWS <= MAX_ATLAS_SIZE);
assert_inside(&images, width, &placements, free);
}
#[test]
fn textures_that_cannot_fit_are_an_error_not_a_gpu_panic() {
let error = HudAtlas::layout(&sized(300, 1024, 1024), MAX_ATLAS_SIZE).unwrap_err();
assert!(error.to_string().contains("do not fit a 16384x16384 atlas"));
// One image wider than the largest atlas.
assert!(HudAtlas::layout(&sized(1, 2048, 16), 2048).is_err());
}
#[test]
fn a_wrapped_border_repeats_the_opposite_edges() {
// 2x2 texels a b / c d, one byte each in red.
let image = (
"x".to_owned(),
2,
2,
[1u8, 2, 3, 4]
.iter()
.flat_map(|&v| [v, 0, 0, 255])
.collect(),
);
let (_, w, h, data) = wrap_border(&image);
assert_eq!((w, h), (4, 4));
let red: Vec<u8> = data.chunks(4).map(|t| t[0]).collect();
#[rustfmt::skip]
assert_eq!(red, [
4, 3, 4, 3,
2, 1, 2, 1,
4, 3, 4, 3,
2, 1, 2, 1,
]);
}
/// A synthetic default font: two 8x8 glyphs, 3 and 5 pixels wide.
fn two_glyph_font(name: &str) -> std::path::PathBuf {
let root =
std::env::temp_dir().join(format!("horizon-fonts-{name}-{}", std::process::id()));
let font = root.join("assets/minecraft/font");
let textures = root.join("assets/minecraft/textures/font");
fs::create_dir_all(&font).unwrap();
@@ -1226,6 +1552,11 @@ mod tests {
.unwrap()
.write_image_data(&pixels)
.unwrap();
root
}
#[test]
fn the_fonts_only_atlas_measures_text_like_the_full_one() {
let root = two_glyph_font("atlas");
let full = HudAtlas::build(&root).unwrap();
let fonts = HudAtlas::fonts(&root).unwrap();
let _ = fs::remove_dir_all(&root);
@@ -1234,6 +1565,21 @@ mod tests {
assert!(width > 0.0);
assert_eq!(fonts.font.width("abba"), width);
}
#[test]
fn obfuscated_glyphs_keep_the_width() {
let root = two_glyph_font("obfuscated");
let fonts = HudAtlas::fonts(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let (a, b) = (fonts.font.glyph('a').advance, fonts.font.glyph('b').advance);
assert_ne!(a, b);
for _ in 0..20 {
assert_eq!(fonts.font.random_glyph(&Default::default(), a).advance, a);
assert_eq!(fonts.font.random_glyph(&Default::default(), b).advance, b);
}
// No glyph that wide: FontSet's missing glyph.
let missing = fonts.font.random_glyph(&Default::default(), 30.0);
assert!(missing.rect.is_none() && missing.right == 5.0);
}
#[test]
fn effect_icons_are_painted_from_the_mob_effect_directory() {
@@ -1418,6 +1764,7 @@ mod tests {
height: height as f32,
patches: Vec::new(),
blends: Vec::new(),
end_portal: EndPortalFill::default(),
blur_at: 0,
};
for (image_width, image_height) in [(195, 136), (176, 166), (176, 222)] {
+50 -1
View File
@@ -1,4 +1,9 @@
struct Hud { size: vec4<f32> }
struct Hud {
size: vec4<f32>,
// Blend::EndPortal: atlas rects (u0, v0, width, height) of end_sky.png and
// end_portal.png, then vanilla's GameTime in x.
end_portal: array<vec4<f32>, 3>,
}
@group(0) @binding(0) var<uniform> hud: Hud;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@group(0) @binding(2) var atlas_sampler: sampler;
@@ -6,6 +11,8 @@ struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
// projection_from_position(gl_Position): the End portal's texProj0.
@location(2) proj: vec4<f32>,
}
@vertex fn vs_main(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>, @location(2) color: vec4<f32>, @location(3) depth: f32) -> Out {
let pixels = pos * hud.size.z;
@@ -13,6 +20,7 @@ struct Out {
out.clip = vec4(pixels.x / hud.size.x * 2.0 - 1.0, 1.0 - pixels.y / hud.size.y * 2.0, depth, 1.0);
out.uv = uv;
out.color = color;
out.proj = vec4(out.clip.xy * 0.5 + 0.5, out.clip.zw);
return out;
}
@fragment fn fs_main(in: Out) -> @location(0) vec4<f32> {
@@ -31,3 +39,44 @@ struct Out {
if c.a == 0.0 { discard; }
return c;
}
// rendertype_end_portal.fsh (entity.wgsl has the same for portal blocks).
const PORTAL_COLORS = array<vec3<f32>, 16>(
vec3(0.022087, 0.098399, 0.110818),
vec3(0.011892, 0.095924, 0.089485),
vec3(0.027636, 0.101689, 0.100326),
vec3(0.046564, 0.109883, 0.114838),
vec3(0.064901, 0.117696, 0.097189),
vec3(0.063761, 0.086895, 0.123646),
vec3(0.084817, 0.111994, 0.166380),
vec3(0.097489, 0.154120, 0.091064),
vec3(0.106152, 0.131144, 0.195191),
vec3(0.097721, 0.110188, 0.187229),
vec3(0.133516, 0.138278, 0.148582),
vec3(0.070006, 0.243332, 0.235792),
vec3(0.196766, 0.142899, 0.214696),
vec3(0.047281, 0.315338, 0.321970),
vec3(0.204675, 0.390010, 0.302066),
vec3(0.080955, 0.314821, 0.661491),
);
fn portal_sample(rect: vec4<f32>, p: vec4<f32>) -> vec3<f32> {
let st = fract(p.xy / p.w);
return textureSampleLevel(atlas, atlas_sampler, rect.xy + st * rect.zw, 0.0).rgb;
}
fn portal_layer(p: vec4<f32>, layer: f32) -> vec4<f32> {
let a = radians((layer * layer * 4321.0 + layer * 9.0) * 2.0);
let c = cos(a);
let s = sin(a);
let k = (4.5 - layer / 4.0) * 2.0;
var v = vec4(k * (c * p.x - s * p.y), k * (s * p.x + c * p.y), p.z, p.w);
v = vec4(v.x + 17.0 / layer * v.w, v.y + (2.0 + layer / 1.5) * (hud.end_portal[2].x * 1.5) * v.w, v.z, v.w);
return vec4(0.5 * v.x + 0.25 * v.w, 0.5 * v.y + 0.25 * v.w, v.z, v.w);
}
// RenderPipelines.END_PORTAL (WinScreen's poem background): end_sky and 15
// layers of end_portal.png.
@fragment fn fs_end_portal(in: Out) -> @location(0) vec4<f32> {
var c = portal_sample(hud.end_portal[0], in.proj) * PORTAL_COLORS[0];
for (var i = 0; i < 15; i++) {
c += portal_sample(hud.end_portal[1], portal_layer(in.proj, f32(i + 1))) * PORTAL_COLORS[i];
}
return vec4(c, 1.0);
}
+58 -2
View File
@@ -23,17 +23,36 @@ pub struct JavaFolder {
/// `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 `colortex0` and `depthtex*` after them.
/// 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` and `eyeBrightnessHalflife`.
/// `sunPathRotation`, `eyeBrightnessHalflife`, `wetnessHalflife` and
/// `drynessHalflife`.
pub directives: Directives,
}
/// 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>,
@@ -41,6 +60,9 @@ pub struct JavaPasses {
pub uniforms: CustomUniforms,
/// What the pack keeps of the vanilla sky (`clouds`, `sun`, `moon`).
pub sky: SkyDirectives,
/// `texture.<stage>.<sampler>` and `texture.noise` images, by
/// `"<stage>.<sampler>"` and [`NOISE_KEY`].
pub custom_textures: BTreeMap<String, crate::shader_pack::Rgba>,
}
impl JavaPasses {
@@ -126,6 +148,7 @@ pub fn load(
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,
@@ -136,6 +159,27 @@ pub fn load(
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}");
@@ -144,6 +188,7 @@ pub fn load(
folders,
uniforms,
sky: properties.sky(),
custom_textures,
})
}
@@ -151,6 +196,17 @@ pub fn load(
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"))
+112 -5
View File
@@ -12,8 +12,8 @@ use horizon_protocol::{
item::Stack,
packets::play::{
self, AddEntity, AttributeSnapshot, EntityPositionSync, MoveEntity, MoveEntityPos,
MoveEntityPosRot, MoveEntityRot, PositionPath, RotateHead, SetEntityData, SetEntityMotion,
SetEquipment, SetPassengers, UpdateAttributes, VecDelta,
MoveEntityPosRot, MoveEntityRot, PositionPath, RotateHead, SetEntityData, SetEntityLink,
SetEntityMotion, SetEquipment, SetPassengers, UpdateAttributes, VecDelta,
},
};
@@ -50,6 +50,10 @@ pub struct EntityState {
pub data: i32,
/// SynchedEntityData entries that differ from the defaults.
pub meta: Vec<(u8, i32, Value)>,
/// The defaults of entries `meta` leaves out while they are at them,
/// sent when one goes back to its default (a calf grown up, a mob healed
/// back to 1, wool grown back white) beyond Entity's own below.
pub defaults: Vec<(u8, i32, Value)>,
/// EquipmentSlot ordinals (0 main hand, 1 off hand, 2 feet, 3 legs,
/// 4 chest, 5 head, 6 body, 7 saddle) and their stacks.
pub equipment: Vec<(u8, Option<Stack>)>,
@@ -59,6 +63,10 @@ pub struct EntityState {
/// `Entity.getRequiresPrecisePosition` (primed TNT and falling blocks
/// touching a block): moves are sent as full position syncs.
pub precise: bool,
/// `Entity.needsSync`: send its changes this tick, due or not.
pub needs_sync: bool,
/// `Leashable.getLeashHolder`: the entity holding its lead.
pub leash: Option<i32>,
}
impl EntityState {
@@ -76,9 +84,12 @@ impl EntityState {
self.velocity = [0.0; 3];
self.data = 0;
self.meta.clear();
self.defaults.clear();
self.equipment.clear();
self.attributes.clear();
self.precise = false;
self.needs_sync = false;
self.leash = None;
}
}
@@ -211,6 +222,8 @@ struct ServerEntity {
/// and the first update after it dismounts is a full position sync.
passengers: Vec<i32>,
was_riding: bool,
/// The lead's holder the clients were told of.
leash: Option<i32>,
/// The level tick it was last seen in, its place in that tick's list, and
/// whether it was added in it (its first update is the next tick's).
seen: u32,
@@ -254,6 +267,7 @@ impl ServerEntity {
teleport_delay: 0,
passengers: riding.passengers(e.id).to_vec(),
was_riding: false,
leash: e.leash,
seen: 0,
index: 0,
added: true,
@@ -306,6 +320,13 @@ impl ServerEntity {
if let Some(&vehicle) = riding.vehicle.get(&e.id) {
out.push(riding.set_passengers(vehicle));
}
// A leashed entity: who holds its lead.
if let Some(dest) = e.leash {
out.push(play::Clientbound::SetEntityLink(SetEntityLink {
source: e.id,
dest,
}));
}
}
/// Changes since the last update (ServerEntity.sendChanges): movement and
@@ -319,10 +340,18 @@ impl ServerEntity {
self.passengers.clear();
self.passengers.extend_from_slice(passengers);
}
// Leashable.setLeashedTo / dropLeash: sendToTrackingPlayers at once.
if e.leash != self.leash {
out.push(play::Clientbound::SetEntityLink(SetEntityLink {
source: e.id,
dest: e.leash.unwrap_or(0),
}));
self.leash = e.leash;
}
// SynchedEntityData.isDirty: once for every client.
let dirty = e.meta != self.meta;
let due = self.interval != 0 && self.tick.is_multiple_of(self.interval);
if due || dirty {
if due || dirty || e.needs_sync {
let rotation = (pack_degrees(e.pitch), pack_degrees(e.yaw));
let turned = |a: i8, b: i8| (i32::from(a) - i32::from(b)).abs() >= 1;
let rotate = self.arrow
@@ -398,7 +427,7 @@ impl ServerEntity {
} else {
None
};
if self.track_deltas {
if self.track_deltas || e.needs_sync {
let diff: f64 = (0..3)
.map(|k| (e.velocity[k] - self.velocity[k]).powi(2))
.sum();
@@ -466,7 +495,10 @@ impl ServerEntity {
(0, serializer::BYTE) => Value::Byte(0),
(6, serializer::POSE) => Value::Id(0),
(14, serializer::OPTIONAL_BLOCK_POS) => Value::OptPos(None),
_ => continue,
_ => match e.defaults.iter().find(|d| (d.0, d.1) == (*index, *kind)) {
Some(d) => d.2.clone(),
None => continue,
},
};
if !e.meta.iter().any(|m| m.0 == *index) {
changed.push((*index, *kind, default));
@@ -852,9 +884,12 @@ mod tests {
velocity: [0.0; 3],
data: 0,
meta: Vec::new(),
defaults: Vec::new(),
equipment: Vec::new(),
attributes: Vec::new(),
precise: false,
needs_sync: false,
leash: None,
}
}
@@ -918,6 +953,37 @@ mod tests {
assert!(matches!(jump[0], play::Clientbound::EntityPositionSync(_)));
}
#[test]
fn a_knocked_back_mob_is_sent_at_once() {
let mut s = added(&state(0.0));
changes(&mut s, &state(0.0));
// Not due (tick 1 of 3), but LivingEntity.knockback set needsSync:
// its move and its new motion go out now.
let mut knocked = state(0.3);
knocked.velocity = [0.2, 0.3, 0.0];
knocked.needs_sync = true;
let out = changes(&mut s, &knocked);
assert!(
matches!(
out[..],
[
play::Clientbound::SetEntityMotion(_),
play::Clientbound::MoveEntityPos(_)
]
),
"{out:?}"
);
// Then every third tick again.
assert!(changes(&mut s, &state(0.5)).is_empty());
assert!(matches!(
changes(&mut s, &state(0.6))[..],
[
play::Clientbound::SetEntityMotion(_),
play::Clientbound::MoveEntityPos(_)
]
));
}
#[test]
fn fireballs_bundle_their_motion_with_their_power() {
let mut e = state(0.0);
@@ -1040,6 +1106,29 @@ mod tests {
assert!(changes(&mut s, &e).is_empty());
}
#[test]
fn a_value_back_at_a_listed_default_is_sent() {
let mut e = state(0.0);
e.defaults = vec![(16, serializer::BOOLEAN, Value::Bool(false))];
// A calf: paired with its baby flag, then grown up.
e.meta = vec![(16, serializer::BOOLEAN, Value::Bool(true))];
let mut s = added(&e);
changes(&mut s, &e);
e.meta.clear();
let data = changes(&mut s, &e)
.into_iter()
.find_map(|p| match p {
play::Clientbound::SetEntityData(d) => Some(d),
_ => None,
})
.expect("growing up is sent");
assert_eq!(
data.values,
vec![(16, serializer::BOOLEAN, Value::Bool(false))]
);
assert!(changes(&mut s, &e).is_empty());
}
#[test]
fn metadata_and_equipment_send_only_the_changes() {
let mut e = state(0.0);
@@ -1065,6 +1154,24 @@ mod tests {
assert_eq!(eq.slots, vec![(0, None)]);
}
#[test]
fn a_lead_tied_and_untied_reaches_the_clients() {
let link = |out: &[play::Clientbound]| {
out.iter().find_map(|p| match p {
play::Clientbound::SetEntityLink(l) => Some((l.source, l.dest)),
_ => None,
})
};
let mut e = state(0.0);
let mut s = added(&e);
changes(&mut s, &e);
e.leash = Some(7);
assert_eq!(link(&changes(&mut s, &e)), Some((e.id, 7)));
assert_eq!(link(&changes(&mut s, &e)), None);
e.leash = None;
assert_eq!(link(&changes(&mut s, &e)), Some((e.id, 0)));
}
/// Two clients at the origin; the chunks they hold.
struct Clients {
chunks: crate::fast_hash::FxHashSet<(i32, i32)>,
+43
View File
@@ -18,6 +18,31 @@ pub const CREATIVE: i32 = 1;
pub const ADVENTURE: i32 = 2;
pub const SPECTATOR: i32 = 3;
/// The body state a player's file keeps that lives on `PlayerMotion`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct SavedBody {
pub fire_ticks: i32,
pub air: i32,
pub fall_distance: f64,
}
impl SavedBody {
/// The body state of `motion`, to save.
pub fn of(motion: &PlayerMotion) -> Self {
Self {
fire_ticks: motion.fire_ticks,
air: motion.air,
fall_distance: motion.fall_distance,
}
}
/// Put it on a new body.
pub fn apply(self, motion: &mut PlayerMotion) {
motion.fire_ticks = self.fire_ticks;
motion.air = self.air;
motion.fall_distance = self.fall_distance;
}
}
/// An effect as last sent in ClientboundUpdateMobEffectPacket.
#[derive(Clone, Debug, PartialEq)]
pub struct SentEffect {
@@ -93,6 +118,10 @@ pub struct Guest {
/// Where the player respawns (a bed's spot; `ServerPlayer.respawnConfig`),
/// None for the world spawn.
pub spawn: Option<[f64; 3]>,
/// A respawn point outside the Overworld (a respawn anchor, or one
/// `spawnpoint` set there): its dimension, block, yaw and whether it is
/// forced, as the player's save keeps it (`respawn`).
pub respawn_elsewhere: Option<(String, [i32; 3], f32, bool)>,
/// Pending ClientboundPlayerPositionPacket id; movement is ignored until
/// the client accepts it (ServerGamePacketListenerImpl.awaitingPositionFromClient).
pub awaiting_teleport: Option<(i32, [f64; 3])>,
@@ -129,6 +158,9 @@ pub struct Guest {
pub tracker: Tracker,
// Player state the gameplay code reads and writes.
pub motion: Option<Box<PlayerMotion>>,
/// What the player's file says of the body (`Entity.load`: the fire
/// and air ticks and the fall distance), for the first body it gets.
pub loaded_body: Option<SavedBody>,
/// The hurt and attack logs of the body it had before it respawned,
/// until the new body takes them over (the control socket's `hurtlog`
/// reads across a death).
@@ -149,6 +181,9 @@ pub struct Guest {
/// The ender chest inventory (`PlayerEnderChestContainer`, 27 slots).
pub ender_items: Vec<Option<ItemStack>>,
pub health: f32,
/// The saved `AbsorptionAmount`, until the player's first body on the
/// server takes it (the body holds the live amount).
pub saved_absorption: Option<f32>,
pub food: i32,
pub saturation: f32,
pub xp_level: i32,
@@ -168,6 +203,10 @@ pub struct Guest {
/// `lastSentHealth`, `lastSentFood` and `lastFoodSaturationZero`.
pub sent_health: Option<(f32, i32, bool)>,
pub sent_xp: Option<(f32, i32, i32)>,
/// The air supply and absorption the player's client was last sent
/// (Entity.DATA_AIR_SUPPLY_ID, Player.DATA_PLAYER_ABSORPTION_ID); their
/// defaults until either changes.
pub sent_air: (i32, f32),
/// 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).
@@ -252,6 +291,7 @@ impl Guest {
sleep_timer: 0,
sent_bed: None,
spawn: None,
respawn_elsewhere: None,
awaiting_teleport: None,
teleport_counter: 0,
teleport_tick: 0,
@@ -270,6 +310,7 @@ impl Guest {
batch_quota: 0.0,
tracker: Tracker::default(),
motion: None,
loaded_body: None,
logs_kept: None,
inventory: vec![None; 36],
armor: [None, None, None, None],
@@ -280,6 +321,7 @@ impl Guest {
craft_result: None,
ender_items: vec![None; 27],
health: 20.0,
saved_absorption: None,
food: 20,
saturation: 5.0,
xp_level: 0,
@@ -292,6 +334,7 @@ impl Guest {
menu_state: 0,
sent_health: None,
sent_xp: None,
sent_air: (300, 0.0),
last_death: None,
ack_up_to: std::cell::Cell::new(-1),
sent_selected: 0,
+45 -4
View File
@@ -842,6 +842,20 @@ impl Hub {
/// ServerGamePacketListenerImpl.teleport: move the client and wait for its confirmation.
pub fn teleport(&mut self, i: usize, position: Option<[f64; 3]>) {
self.teleport_relative(i, position, 0);
}
/// A teleport to `position` that keeps the player's motion and look
/// (`Relative.DELTA` and `Relative.ROTATION`: an End gateway's).
pub fn teleport_keeping_motion(&mut self, i: usize, position: [f64; 3]) {
use play::relative::{DELTA_X, DELTA_Y, DELTA_Z, ROTATE_DELTA, X_ROT, Y_ROT};
let relatives = Y_ROT | X_ROT | DELTA_X | DELTA_Y | DELTA_Z | ROTATE_DELTA;
self.teleport_relative(i, Some(position), relatives);
}
/// ServerGamePacketListenerImpl.teleport with `relatives`: the rotation
/// and motion they name are sent as zero changes.
fn teleport_relative(&mut self, i: usize, position: Option<[f64; 3]>, relatives: i32) {
let ticks = self.ticks;
let g = &mut self.guests[i];
g.teleport_tick = ticks;
@@ -852,13 +866,14 @@ impl Hub {
g.teleport_counter = (g.teleport_counter + 1) & 0x7FFF_FFFF;
let id = g.teleport_counter;
g.awaiting_teleport = Some((id, g.position));
let turn = relatives & (play::relative::Y_ROT | play::relative::X_ROT) != 0;
g.send(&C::PlayerPosition(play::PlayerPosition {
teleport: id,
position: g.position,
delta: [0.0; 3],
yaw: g.yaw,
pitch: g.pitch,
relatives: 0,
yaw: if turn { 0.0 } else { g.yaw },
pitch: if turn { 0.0 } else { g.pitch },
relatives,
}));
}
@@ -1544,9 +1559,12 @@ impl Hub {
velocity: [0.0; 3],
data: 0,
meta,
defaults: Vec::new(),
equipment,
attributes,
precise: false,
needs_sync: false,
leash: None,
}
}
@@ -1755,6 +1773,27 @@ impl Hub {
}));
}
}
// ServerEntity.sendDirtyEntityData: the air supply and the absorption
// amount the server's body changed go to the player, whose HUD draws
// them (bubbles, golden hearts).
let g = &mut self.guests[i];
if let Some(m) = g.motion.as_ref() {
let now = (m.air, m.health.absorption());
let mut values = Vec::new();
if now.0 != g.sent_air.0 {
values.push((1, serializer::INT, Value::Int(now.0)));
}
if now.1.to_bits() != g.sent_air.1.to_bits() {
values.push((17, serializer::FLOAT, Value::Float(now.1)));
}
if !values.is_empty() {
g.sent_air = now;
g.send(&C::SetEntityData(play::SetEntityData {
id: g.entity_id,
values,
}));
}
}
// 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];
@@ -2465,9 +2504,11 @@ impl Hub {
global: false,
}));
}
// lastSentExp, lastSentHealth and lastSentFood.
// lastSentExp, lastSentHealth and lastSentFood; the client's new
// player starts with the default air and no absorption.
g.sent_health = None;
g.sent_xp = None;
g.sent_air = (300, 0.0);
let (cx, cz) = g.chunk();
g.send(&C::SetChunkCacheCenter(play::SetChunkCacheCenter {
x: cx,
+94 -2
View File
@@ -1,6 +1,6 @@
//! Guests' saved state: `players/data/<uuid>.dat` in the vanilla player
//! compound, through the same code that saves the host.
use super::guest::Guest;
use super::guest::{Guest, SavedBody};
use crate::{
items::ItemStack,
save::{Level, read_dat, write_dat},
@@ -61,6 +61,7 @@ pub fn apply(guest: &mut Guest, level: &Level) {
guest.head_yaw = guest.yaw;
guest.on_ground = level.on_ground;
guest.health = level.health;
guest.saved_absorption = Some(level.absorption);
guest.food = level.food;
guest.saturation = level.saturation;
guest.xp_level = level.xp_level;
@@ -71,8 +72,25 @@ pub fn apply(guest: &mut Guest, level: &Level) {
guest.game_mode = level.mode();
guest.flying = level.flying;
guest.selected = (level.selected_slot.max(0) as usize).min(8);
guest.spawn = level.spawn.map(|s| [s[0] + 0.5, s[1], s[2] + 0.5]);
// A point that is the world spawn is none of the player's own: it
// respawns wherever the world spawn is then (`setworldspawn`).
guest.spawn = level
.spawn
.filter(|s| Some(s.map(|v| v.floor() as i32)) != level.world_spawn)
.map(|s| [s[0] + 0.5, s[1], s[2] + 0.5]);
guest.respawn_elsewhere = level.respawn_elsewhere.clone();
guest.saved = level.player_extra.clone();
let extra = level.player_extra.as_ref();
let short = |key: &str, default: i64| {
extra
.and_then(|t| crate::save::number(t.get(key)))
.unwrap_or(default) as i32
};
guest.loaded_body = Some(SavedBody {
fire_ticks: short("Fire", -20),
air: short("Air", 300),
fall_distance: level.fall_distance,
});
guest.recipe_book = level.recipe_book.clone();
guest.dimension = match level.dimension.as_str() {
d @ ("minecraft:the_nether" | "minecraft:the_end") => d.to_owned(),
@@ -123,6 +141,12 @@ pub fn store(guest: &Guest, level: &mut Level) {
level.dimension = guest.dimension.clone();
level.rotation = [guest.yaw, guest.pitch];
level.health = guest.health;
level.absorption = guest
.motion
.as_ref()
.map_or(guest.saved_absorption.unwrap_or(0.0), |m| {
m.health.absorption()
});
level.food = guest.food;
level.saturation = guest.saturation;
level.xp_level = guest.xp_level;
@@ -135,8 +159,27 @@ pub fn store(guest: &Guest, level: &mut Level) {
level.flying = guest.flying;
level.selected_slot = guest.selected as i32;
level.spawn = guest.spawn.map(|s| [s[0] - 0.5, s[1], s[2] - 0.5]);
level.respawn_elsewhere = guest.respawn_elsewhere.clone();
level.on_ground = guest.on_ground;
level.player_extra = guest.saved.clone();
// The live body's fire, air and fall distance (Entity.saveWithoutId),
// or what the file had while the player has no body yet.
let body = guest
.motion
.as_deref()
.map(SavedBody::of)
.or(guest.loaded_body);
if let Some(body) = body {
level.fall_distance = body.fall_distance;
let extra = level
.player_extra
.get_or_insert_with(|| horizon_world::nbt::Tag::Compound(Default::default()));
if let horizon_world::nbt::Tag::Compound(map) = extra {
let short = |v: i32| horizon_world::nbt::Tag::Short(v.clamp(-32768, 32767) as i16);
map.insert("Fire".into(), short(body.fire_ticks));
map.insert("Air".into(), short(body.air));
}
}
level.recipe_book = guest.recipe_book.clone();
level.ender_items = guest.ender_items.clone();
level.inventory.clear();
@@ -160,3 +203,52 @@ pub fn store(guest: &Guest, level: &mut Level) {
put(100, &guest.armor[3]);
put(150, &guest.offhand);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lan::{Joined, PlayerSender};
use horizon_protocol::packets::configuration::ClientInformation;
use std::sync::{Arc, atomic::AtomicI32, mpsc};
fn guest() -> Guest {
let (tx, _rx) = mpsc::channel();
Guest::new(
Joined {
conn: 2,
uuid: 2,
name: "Alex".into(),
properties: Vec::new(),
info: ClientInformation::new(8),
brand: "test".into(),
sender: PlayerSender::new(tx, Arc::default()),
latency: Arc::new(AtomicI32::new(0)),
address: "127.0.0.1:25565".parse().unwrap(),
},
2,
[0.0, 64.0, 0.0],
8,
)
}
#[test]
fn a_saved_burn_and_fall_come_back() {
// A player saved burning in mid-fall (#1323, #1327): Entity.saveWithoutId's
// Fire, Air and fall_distance, read back for the next body.
let mut g = guest();
g.loaded_body = Some(SavedBody {
fire_ticks: 96,
air: 250,
fall_distance: 9.5,
});
let mut level = Level::default();
store(&g, &mut level);
let tag = level.player_tag();
let mut read = Level::default();
read.read_player(&tag);
read.player_extra = Some(tag);
let mut back = guest();
apply(&mut back, &read);
assert_eq!(back.loaded_body, g.loaded_body);
}
}
+3
View File
@@ -104,6 +104,7 @@ pub mod landing;
pub mod lang;
pub mod level_loading;
pub mod light;
pub mod locator_bar;
pub mod lod;
pub mod logging;
pub mod menu;
@@ -180,12 +181,14 @@ pub mod ticket_levels;
pub mod title;
pub mod title_edition;
pub mod toasts;
pub mod trims;
pub mod ui;
pub mod ui_theme;
pub mod vanillagen;
pub mod vault;
pub mod weather;
pub mod widgets;
pub mod win_screen;
pub mod window;
pub mod world;
pub mod world_spawn;
+742
View File
@@ -0,0 +1,742 @@
//! The locator bar: `ClientWaypointManager` (the waypoints the server
//! tracks for this player, from `ClientboundTrackedWaypointPacket`),
//! `WaypointStyleManager` (`waypoint_style/*.json`: near and far distances
//! and the dot sprites between them) and `LocatorBar`, the contextual bar
//! that draws a dot per waypoint within 60 degrees of the view, with an
//! arrow when it is above or below the screen.
use crate::hud_layout::{Blend, GuiDraw};
use horizon_assets::animation::{AnimationClip, AnimationMetadata};
use horizon_core::geometry::trig;
use horizon_protocol::packets::play::{
TrackedWaypoint, WaypointId, WaypointTarget, waypoint_operation,
};
use std::{borrow::Cow, collections::HashMap, fs, path::Path};
/// `WaypointStyle`: the sprites (under `hud/locator_bar_dot/`) from near to far.
#[derive(Clone, Debug, PartialEq)]
pub struct WaypointStyle {
pub near_distance: i32,
pub far_distance: i32,
pub sprites: Vec<String>,
}
impl WaypointStyle {
/// `WaypointStyleManager.MISSING`.
fn missing() -> Self {
Self {
near_distance: 0,
far_distance: 1,
sprites: vec!["hud/locator_bar_dot/missingno".into()],
}
}
/// `WaypointStyle.CODEC` and `validate`: None when the file is invalid.
pub fn parse(json: &str) -> Option<Self> {
let v: serde_json::Value = serde_json::from_str(json).ok()?;
let distance = |key: &str, default: i32| match v.get(key) {
None => Some(default),
Some(d) => d
.as_i64()
.filter(|d| (0..=60_000_000).contains(d))
.map(|d| d as i32),
};
let near_distance = distance("near_distance", 128)?;
let far_distance = distance("far_distance", 332)?;
let sprites = v
.get("sprites")?
.as_array()?
.iter()
.map(|s| {
let id = s.as_str()?;
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
// Sprites of the gui atlas are named by path (minecraft only).
(namespace == "minecraft").then(|| format!("hud/locator_bar_dot/{path}"))
})
.collect::<Option<Vec<_>>>()?;
(!sprites.is_empty() && near_distance > 0 && near_distance < far_distance).then_some(Self {
near_distance,
far_distance,
sprites,
})
}
/// `WaypointStyle.sprite`.
pub fn sprite(&self, distance: f32) -> &str {
let (first, last) = (&self.sprites[0], &self.sprites[self.sprites.len() - 1]);
if distance < self.near_distance as f32 {
return first;
}
if distance >= self.far_distance as f32 {
return last;
}
match self.sprites.len() {
1 => first,
3 => &self.sprites[1],
n => {
let t = (distance - self.near_distance as f32)
/ (self.far_distance - self.near_distance) as f32;
// Mth.lerpInt(t, 1, n - 1).
&self.sprites[(1 + (t * (n as f32 - 2.0)).floor() as i32) as usize]
}
}
}
}
/// An animated GUI sprite (`.png.mcmeta` animation, ticked with the GUI
/// atlas): its image and frame sizes and the frame sequence.
#[derive(Clone, Debug)]
pub struct SpriteFrames {
image: [i32; 2],
frame: [i32; 2],
clip: AnimationClip,
}
impl SpriteFrames {
/// None for a still sprite or invalid metadata.
fn load(png: &Path) -> Option<Self> {
let text = fs::read_to_string(png.with_extension("png.mcmeta")).ok()?;
let meta: HashMap<String, Box<serde_json::value::RawValue>> =
serde_json::from_str(&text).ok()?;
let animation = AnimationMetadata::from_json(meta.get("animation")?.get()).ok()?;
// The PNG header: width and height at bytes 16..24 of IHDR.
let mut header = [0u8; 24];
std::io::Read::read_exact(&mut fs::File::open(png).ok()?, &mut header).ok()?;
let dim = |at: usize| i32::from_be_bytes(header[at..at + 4].try_into().unwrap_or_default());
let image = [dim(16), dim(20)];
let frame = animation.frame_size(image[0], image[1]);
if frame[0] <= 0 || frame[1] <= 0 || image[0] % frame[0] != 0 || image[1] % frame[1] != 0 {
return None;
}
let count = (image[0] / frame[0]) * (image[1] / frame[1]);
let clip = animation.clip(count as u32).ok()??;
Some(Self { image, frame, clip })
}
/// The `region` (image width, height, u, v) of the frame shown at `tick`.
fn region(&self, tick: u64) -> [i32; 4] {
let index = self.clip.sample(tick).frame as i32;
let columns = self.image[0] / self.frame[0];
[
self.image[0],
self.image[1],
index % columns * self.frame[0],
index / columns * self.frame[1],
]
}
}
/// `WaypointStyleManager`: every `assets/<namespace>/waypoint_style/*.json`,
/// plus the animations of the bar's own sprites (the arrows blink).
#[derive(Clone, Debug, Default)]
pub struct WaypointStyles(
HashMap<String, WaypointStyle>,
HashMap<String, SpriteFrames>,
);
impl WaypointStyles {
pub fn load(root: &Path) -> Self {
let mut frames = HashMap::new();
let hud = root.join("assets/minecraft/textures/gui/sprites/hud");
let dots = fs::read_dir(hud.join("locator_bar_dot"))
.into_iter()
.flatten()
.flatten()
.map(|e| e.path());
let arrows = ["locator_bar_arrow_up", "locator_bar_arrow_down"]
.map(|a| hud.join(format!("{a}.png")));
for png in arrows.into_iter().chain(dots) {
if png.extension().is_none_or(|e| e != "png") {
continue;
}
let Some(f) = SpriteFrames::load(&png) else {
continue;
};
let name = png
.strip_prefix(hud.parent().unwrap_or(&hud))
.map(|r| r.with_extension("").to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
frames.insert(name, f);
}
let mut out = HashMap::new();
for ns in fs::read_dir(root.join("assets"))
.into_iter()
.flatten()
.flatten()
{
let namespace = ns.file_name().to_string_lossy().into_owned();
for f in fs::read_dir(ns.path().join("waypoint_style"))
.into_iter()
.flatten()
.flatten()
{
let path = f.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
let name = path.file_stem().unwrap_or_default().to_string_lossy();
if let Some(style) = fs::read_to_string(&path)
.ok()
.and_then(|t| WaypointStyle::parse(&t))
{
out.insert(format!("{namespace}:{name}"), style);
} else {
log::warn!("Horizon: invalid waypoint style {}", path.display());
}
}
}
Self(out, frames)
}
/// The sprite at `tick`, with its animation frame when it has one.
fn sprite(&self, name: &str, x: i32, y: i32, w: i32, h: i32, color: u32, tick: u64) -> GuiDraw {
let mut d = sprite(name, x, y, w, h, color);
if let (Some(f), GuiDraw::Sprite { region, .. }) = (self.1.get(name), &mut d) {
*region = Some(f.region(tick));
}
d
}
/// `get`: the style, or the missing one.
pub fn get(&self, id: &str) -> Cow<'_, WaypointStyle> {
let id = if id.contains(':') {
Cow::Borrowed(id)
} else {
Cow::Owned(format!("minecraft:{id}"))
};
self.0
.get(&*id)
.map_or_else(|| Cow::Owned(WaypointStyle::missing()), Cow::Borrowed)
}
}
/// `ClientWaypointManager`: the tracked waypoints by identifier.
#[derive(Clone, Debug, Default)]
pub struct ClientWaypoints {
waypoints: Vec<TrackedWaypoint>,
}
impl ClientWaypoints {
/// `ClientboundTrackedWaypointPacket.apply`.
pub fn apply(&mut self, packet: &TrackedWaypoint) {
let at = self.waypoints.iter().position(|w| w.id == packet.id);
match packet.operation {
waypoint_operation::TRACK => match at {
Some(i) => self.waypoints[i] = packet.clone(),
None => self.waypoints.push(packet.clone()),
},
waypoint_operation::UNTRACK => {
if let Some(i) = at {
self.waypoints.remove(i);
}
}
// `TrackedWaypoint.update`: only the same kind of waypoint moves.
_ => {
if let Some(w) = at.map(|i| &mut self.waypoints[i]) {
use WaypointTarget as T;
if matches!(
(w.target, packet.target),
(T::Block(_), T::Block(_))
| (T::Chunk(_), T::Chunk(_))
| (T::Azimuth(_), T::Azimuth(_))
) {
w.target = packet.target;
}
}
}
}
}
pub fn has_waypoints(&self) -> bool {
!self.waypoints.is_empty()
}
pub fn clear(&mut self) {
self.waypoints.clear();
}
}
/// What the locator bar needs of the camera and the world this frame.
pub struct View<'a> {
pub gui_width: i32,
pub gui_height: i32,
/// The camera entity's UUID, its block position and its position.
pub camera_uuid: u128,
pub camera_entity_pos: [f64; 3],
/// `Camera.position()` and `yRot()`, `xRot()`, `getFov()` in degrees.
pub camera_pos: [f64; 3],
pub yaw: f32,
pub pitch: f32,
pub fov: f32,
/// `Camera.getViewRotationProjectionMatrix`, applied to a point
/// relative to the camera: normalised device coordinates.
pub project: &'a dyn Fn([f64; 3]) -> [f64; 3],
/// An entity with this UUID in the level: (block position, eye position).
pub entity: &'a dyn Fn(u128) -> Option<([i32; 3], [f64; 3])>,
pub styles: &'a WaypointStyles,
/// Client ticks, for animated sprites.
pub tick: u64,
}
/// `TrackedWaypoint.PitchDirection`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Pitch {
None,
Up,
Down,
}
/// `UUID.hashCode`.
fn uuid_hash(uuid: u128) -> i32 {
let hilo = (uuid >> 64) as u64 ^ uuid as u64;
((hilo >> 32) as i32) ^ (hilo as i32)
}
/// `String.hashCode`.
fn string_hash(s: &str) -> i32 {
s.encode_utf16()
.fold(0i32, |h, c| h.wrapping_mul(31).wrapping_add(i32::from(c)))
}
/// `ARGB.setBrightness`.
pub fn set_brightness(color: u32, brightness: f32) -> u32 {
let [alpha, r, g, b] = color.to_be_bytes().map(i32::from);
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let range = (max - min) as f32;
let saturation = if max != 0 { range / max as f32 } else { 0.0 };
let argb =
|r: i32, g: i32, b: i32| u32::from_be_bytes([alpha as u8, r as u8, g as u8, b as u8]);
let round = |v: f32| (v * 255.0).round() as i32;
if saturation == 0.0 {
let v = round(brightness);
return argb(v, v, v);
}
let (cr, cg, cb) = (
(max - r) as f32 / range,
(max - g) as f32 / range,
(max - b) as f32 / range,
);
let mut hue = if r == max {
cb - cg
} else if g == max {
2.0 + cr - cb
} else {
4.0 + cg - cr
} / 6.0;
if hue < 0.0 {
hue += 1.0;
}
let segment = (hue - hue.floor()) * 6.0;
let offset = segment - segment.floor();
let primary = brightness * (1.0 - saturation);
let secondary = brightness * (1.0 - saturation * offset);
let tertiary = brightness * (1.0 - saturation * (1.0 - offset));
let full = round(brightness);
match segment as i32 {
0 => argb(full, round(tertiary), round(primary)),
1 => argb(round(secondary), full, round(primary)),
2 => argb(round(primary), full, round(tertiary)),
3 => argb(round(primary), round(secondary), full),
4 => argb(round(tertiary), round(primary), full),
5 => argb(full, round(primary), round(secondary)),
_ => argb(r, g, b),
}
}
/// `Mth.wrapDegrees`.
fn wrap_degrees(angle: f32) -> f32 {
let mut a = angle % 360.0;
if a >= 180.0 {
a -= 360.0;
}
if a < -180.0 {
a += 360.0;
}
a
}
fn centre(v: [i32; 3]) -> [f64; 3] {
v.map(|c| f64::from(c) + 0.5)
}
impl View<'_> {
/// `Vec3iWaypoint.position`: the tracked entity's eyes when it is within
/// 3 blocks (Manhattan) of the waypoint, else the block's centre.
fn block_position(&self, w: &TrackedWaypoint, v: [i32; 3]) -> [f64; 3] {
if let WaypointId::Uuid(uuid) = w.id
&& let Some((block, eyes)) = (self.entity)(uuid)
{
let manhattan: i32 = (0..3).map(|i| (block[i] - v[i]).abs()).sum();
if manhattan <= 3 {
return eyes;
}
}
centre(v)
}
/// `yawAngleToCamera` of a point: `degreesDifference(camera yaw, the
/// angle of camera - point turned clockwise by 90 degrees)`.
fn yaw_to(&self, p: [f64; 3]) -> f64 {
let c = self.camera_pos;
// Vec3.rotateClockwise90: (-z, y, x).
let (x, z) = (-(c[2] - p[2]), c[0] - p[0]);
let angle = trig::atan2(z, x) as f32 * (180.0 / std::f32::consts::PI);
f64::from(wrap_degrees(angle - self.yaw))
}
/// `ChunkPos.getMiddleBlockPosition(y)`'s centre.
fn chunk_middle(chunk: [i32; 2], y: i32) -> [f64; 3] {
centre([chunk[0] * 16 + 8, y, chunk[1] * 16 + 8])
}
fn yaw_angle(&self, w: &TrackedWaypoint) -> f64 {
match w.target {
WaypointTarget::Empty => f64::NAN,
WaypointTarget::Block(v) => self.yaw_to(self.block_position(w, v)),
WaypointTarget::Chunk(c) => {
self.yaw_to(Self::chunk_middle(c, self.camera_pos[1] as i32))
}
WaypointTarget::Azimuth(a) => {
f64::from(wrap_degrees(a * (180.0 / std::f32::consts::PI) - self.yaw))
}
}
}
/// `GameRenderer.projectHorizonToScreen`.
fn horizon(&self) -> f64 {
if self.pitch <= -90.0 {
return f64::NEG_INFINITY;
}
if self.pitch >= 90.0 {
return f64::INFINITY;
}
// Math.tan of the float products, in double.
let rad = std::f32::consts::PI / 180.0;
f64::from(self.pitch * rad).tan() / f64::from(self.fov / 2.0 * rad).tan()
}
fn pitch_direction(&self, w: &TrackedWaypoint) -> Pitch {
let by = |y: f64| {
if y < -1.0 {
Pitch::Down
} else if y > 1.0 {
Pitch::Up
} else {
Pitch::None
}
};
match w.target {
WaypointTarget::Empty => Pitch::None,
WaypointTarget::Chunk(_) | WaypointTarget::Azimuth(_) => by(self.horizon()),
WaypointTarget::Block(v) => {
let p = self.block_position(w, v);
let c = self.camera_pos;
let s = (self.project)([p[0] - c[0], p[1] - c[1], p[2] - c[2]]);
let behind = s[2] > 1.0;
match by(if behind { -s[1] } else { s[1] }) {
Pitch::None if behind && s[1] > 0.0 => Pitch::Up,
Pitch::None if behind && s[1] < 0.0 => Pitch::Down,
p => p,
}
}
}
}
/// `distanceSquared` from the camera entity.
fn distance_sq(&self, w: &TrackedWaypoint) -> f64 {
let e = self.camera_entity_pos;
let d = |p: [f64; 3]| (0..3).map(|i| (p[i] - e[i]).powi(2)).sum::<f64>();
match w.target {
WaypointTarget::Block(v) => d(centre(v)),
WaypointTarget::Chunk(c) => d(Self::chunk_middle(c, e[1].floor() as i32)),
WaypointTarget::Empty | WaypointTarget::Azimuth(_) => f64::INFINITY,
}
}
}
/// `LocatorBar.extractBackground`: the nine-sliced sprite and its rectangle.
pub fn background(gui_width: i32, gui_height: i32) -> (&'static str, [i32; 4]) {
(
"gui/sprites/hud/locator_bar_background",
[gui_width / 2 - 91, gui_height - 32 + 3, 182, 5],
)
}
fn sprite(name: &str, x: i32, y: i32, w: i32, h: i32, color: u32) -> GuiDraw {
GuiDraw::Sprite {
sprite: Cow::Owned(name.to_owned()),
x,
y,
w,
h,
region: None,
color,
blend: Blend::Textured,
}
}
impl ClientWaypoints {
/// `LocatorBar.extractRenderState`: the farthest waypoint first.
pub fn draw(&self, view: &View) -> Vec<GuiDraw> {
let top = view.gui_height - 32 + 3;
let mut sorted: Vec<(f64, &TrackedWaypoint)> = self
.waypoints
.iter()
.map(|w| (view.distance_sq(w), w))
.collect();
sorted.sort_by(|a, b| b.0.total_cmp(&a.0));
let mut out = Vec::new();
for (distance_sq, w) in sorted {
if w.id == WaypointId::Uuid(view.camera_uuid) {
continue;
}
let angle = view.yaw_angle(w);
// `!(angle <= -60) && !(angle > 60)`: NaN draws, at the centre.
if angle <= -60.0 || angle > 60.0 {
continue;
}
let middle = ((view.gui_width - 9) as f32 / 2.0).ceil() as i32;
let style = view.styles.get(&w.style);
let distance = (distance_sq as f32).sqrt();
let color = w.color.map_or_else(
|| {
let hash = match &w.id {
WaypointId::Uuid(u) => uuid_hash(*u),
WaypointId::Name(n) => string_hash(n),
};
set_brightness(0xFF00_0000 | hash as u32, 0.9)
},
|c| c as u32,
);
let dot = (angle * 173.0 / 2.0 / 60.0).floor() as i32;
out.push(view.styles.sprite(
style.sprite(distance),
middle + dot,
top - 2,
9,
9,
color,
view.tick,
));
let arrow = match view.pitch_direction(w) {
Pitch::None => continue,
Pitch::Down => (6, "hud/locator_bar_arrow_down"),
Pitch::Up => (-6, "hud/locator_bar_arrow_up"),
};
out.push(view.styles.sprite(
arrow.1,
middle + dot + 1,
top + arrow.0,
7,
5,
u32::MAX,
view.tick,
));
}
out
}
}
/// `Hud.ContextualInfo`: which bar sits above the hotbar.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ContextualBar {
Empty,
Experience,
Locator,
JumpableVehicle,
}
impl ContextualBar {
/// `Hud.nextContextualInfoState`: `recent_experience` is
/// `willPrioritizeExperienceInfo` (experience changed in the last 100
/// ticks), `jumping` is `willPrioritizeJumpInfo`.
pub fn next(
waypoints: bool,
jumpable_vehicle: bool,
jumping: bool,
has_experience: bool,
recent_experience: bool,
) -> Self {
if waypoints {
if jumpable_vehicle && jumping {
Self::JumpableVehicle
} else if has_experience && recent_experience {
Self::Experience
} else {
Self::Locator
}
} else if jumpable_vehicle {
Self::JumpableVehicle
} else if has_experience {
Self::Experience
} else {
Self::Empty
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn tracked(operation: i32, id: u128, target: WaypointTarget) -> TrackedWaypoint {
TrackedWaypoint {
operation,
id: WaypointId::Uuid(id),
style: "minecraft:default".into(),
color: None,
target,
}
}
#[test]
fn styles_pick_sprites_by_distance() {
let default = WaypointStyle::parse(
r#"{"sprites":["minecraft:default_0","minecraft:default_1","minecraft:default_2","minecraft:default_3"]}"#,
)
.unwrap();
assert_eq!((default.near_distance, default.far_distance), (128, 332));
assert_eq!(default.sprite(10.0), "hud/locator_bar_dot/default_0");
assert_eq!(default.sprite(128.0), "hud/locator_bar_dot/default_1");
// lerpInt((d - 128) / 204, 1, 3): halfway is the third sprite.
assert_eq!(default.sprite(229.0), "hud/locator_bar_dot/default_1");
assert_eq!(default.sprite(230.0), "hud/locator_bar_dot/default_2");
assert_eq!(default.sprite(332.0), "hud/locator_bar_dot/default_3");
let bowtie = WaypointStyle::parse(
r#"{"near_distance":64,"sprites":["minecraft:bowtie","minecraft:default_0","minecraft:default_1","minecraft:default_2","minecraft:default_3"]}"#,
)
.unwrap();
assert_eq!(bowtie.sprite(63.0), "hud/locator_bar_dot/bowtie");
// lerpInt((d - 64) / 268, 1, 4).
assert_eq!(bowtie.sprite(64.0), "hud/locator_bar_dot/default_0");
assert_eq!(bowtie.sprite(200.0), "hud/locator_bar_dot/default_1");
assert_eq!(bowtie.sprite(300.0), "hud/locator_bar_dot/default_2");
// Three sprites: the middle one between near and far.
let three = WaypointStyle::parse(r#"{"sprites":["a","b","c"]}"#).unwrap();
assert_eq!(three.sprite(200.0), "hud/locator_bar_dot/b");
// validate: sprites, near > 0, far > near.
assert!(WaypointStyle::parse(r#"{"sprites":[]}"#).is_none());
assert!(WaypointStyle::parse(r#"{"near_distance":0,"sprites":["a"]}"#).is_none());
assert!(WaypointStyle::parse(r#"{"near_distance":400,"sprites":["a"]}"#).is_none());
assert_eq!(
WaypointStyles::default().get("minecraft:none").sprite(5.0),
"hud/locator_bar_dot/missingno"
);
}
#[test]
fn packets_track_update_and_untrack() {
let mut w = ClientWaypoints::default();
w.apply(&tracked(
waypoint_operation::TRACK,
1,
WaypointTarget::Block([1, 2, 3]),
));
assert!(w.has_waypoints());
w.apply(&tracked(
waypoint_operation::UPDATE,
1,
WaypointTarget::Block([4, 5, 6]),
));
assert_eq!(w.waypoints[0].target, WaypointTarget::Block([4, 5, 6]));
// Another kind of waypoint doesn't update it.
w.apply(&tracked(
waypoint_operation::UPDATE,
1,
WaypointTarget::Azimuth(1.0),
));
assert_eq!(w.waypoints[0].target, WaypointTarget::Block([4, 5, 6]));
w.apply(&tracked(
waypoint_operation::UNTRACK,
1,
WaypointTarget::Empty,
));
assert!(!w.has_waypoints());
}
#[test]
fn brightness_like_argb() {
// Pure red at 0.9: Math.round(0.9 * 255) = 230.
assert_eq!(set_brightness(0xFFFF_0000, 0.9), 0xFFE6_0000);
assert_eq!(set_brightness(0xFF80_8080, 0.9), 0xFFE6_E6E6);
assert_eq!(uuid_hash(0x0000_0001_0000_0000_0000_0000_0000_0002), 3);
assert_eq!(string_hash("ab"), 97 * 31 + 98);
}
#[test]
fn arrows_blink_through_their_frames() {
// locator_bar_arrow_up.png.mcmeta: 7x10, two 5-pixel frames.
let meta = r#"{"frames":[{"index":0,"time":10},{"index":1,"time":4}],"height":5}"#;
let animation = AnimationMetadata::from_json(meta).unwrap();
let frames = SpriteFrames {
image: [7, 10],
frame: animation.frame_size(7, 10),
clip: animation.clip(2).unwrap().unwrap(),
};
assert_eq!(frames.region(0), [7, 10, 0, 0]);
assert_eq!(frames.region(9), [7, 10, 0, 0]);
assert_eq!(frames.region(10), [7, 10, 0, 5]);
assert_eq!(frames.region(14), [7, 10, 0, 0]);
}
#[test]
fn dots_sit_on_the_bar_by_angle() {
let styles = WaypointStyles(
[(
"minecraft:default".to_owned(),
WaypointStyle {
near_distance: 128,
far_distance: 332,
sprites: vec!["hud/locator_bar_dot/default_0".into()],
},
)]
.into(),
HashMap::new(),
);
let project = |p: [f64; 3]| [p[0], p[1], 0.5];
let entity = |_: u128| None;
let view = View {
gui_width: 427,
gui_height: 240,
camera_uuid: 9,
camera_entity_pos: [0.5, 64.0, 0.5],
camera_pos: [0.5, 65.62, 0.5],
yaw: 0.0,
pitch: 0.0,
fov: 70.0,
project: &project,
entity: &entity,
styles: &styles,
tick: 0,
};
let mut w = ClientWaypoints::default();
// Yaw 0 looks towards +z: a waypoint straight ahead sits in the middle.
w.apply(&tracked(
waypoint_operation::TRACK,
1,
WaypointTarget::Block([0, 65, 40]),
));
// One behind the camera and the camera's own waypoint are not drawn.
w.apply(&tracked(
waypoint_operation::TRACK,
2,
WaypointTarget::Block([0, 65, -40]),
));
w.apply(&tracked(
waypoint_operation::TRACK,
9,
WaypointTarget::Block([0, 65, 40]),
));
let draws = w.draw(&view);
assert_eq!(draws.len(), 1);
let GuiDraw::Sprite { x, y, .. } = draws[0] else {
panic!("{draws:?}");
};
assert_eq!((x, y), (209, 209));
// 30 degrees to the right: floor(30 * 173 / 120) = 43 pixels.
let east = ClientWaypoints {
waypoints: vec![tracked(
waypoint_operation::TRACK,
3,
WaypointTarget::Azimuth(30f32.to_radians()),
)],
};
let GuiDraw::Sprite { x, .. } = east.draw(&view)[0] else {
panic!();
};
assert_eq!(x, 209 + 43);
}
#[test]
fn contextual_bar_priorities() {
use ContextualBar as B;
assert_eq!(B::next(true, false, false, true, false), B::Locator);
assert_eq!(B::next(true, false, false, true, true), B::Experience);
assert_eq!(B::next(true, true, true, true, true), B::JumpableVehicle);
assert_eq!(B::next(false, false, false, true, true), B::Experience);
assert_eq!(B::next(false, false, false, false, false), B::Empty);
// Creative players see the bar too.
assert_eq!(B::next(true, false, false, false, false), B::Locator);
}
}
+69 -11
View File
@@ -326,14 +326,17 @@ struct Plan {
impl Plan {
#[cfg(test)]
fn new(camera: [i32; 2], detail: i32, distance: u32) -> Self {
Self::with_previous(camera, detail, distance, &Self::default(), 0)
Self::with_previous(camera, detail, distance, &Self::default(), 0, false)
}
/// `reduced`: Reduced far detail (`graphics::FarDetail`), each level from
/// half the distance.
fn with_previous(
camera: [i32; 2],
detail: i32,
distance: u32,
previous: &Self,
elevation: i32,
reduced: bool,
) -> Self {
let range = distance.min(1024) as i32 * 16;
let mut level = 0;
@@ -349,25 +352,35 @@ impl Plan {
for x in (camera[0] - range).div_euclid(size)..=(camera[0] + range).div_euclid(size) {
let key = TileKey { x, z, level };
result.roots.push(key);
result.visit(key, camera, detail, range, previous);
result.visit(key, camera, detail, range, previous, reduced);
}
}
result
}
fn visit(&mut self, key: TileKey, camera: [i32; 2], detail: i32, range: i32, previous: &Self) {
fn visit(
&mut self,
key: TileKey,
camera: [i32; 2],
detail: i32,
range: i32,
previous: &Self,
reduced: bool,
) {
if key.distance(camera) > range {
return;
}
self.nodes.insert(key);
// Keep distant cells small on screen; finest tiles retain individual blocks.
let threshold = key.size() * if previous.split.contains(&key) { 10 } else { 8 };
// Keep distant cells small on screen; finest tiles retain individual
// blocks. Reduced keeps them twice as large (a quarter of the cells).
let split = if previous.split.contains(&key) { 10 } else { 8 };
let threshold = key.size() * if reduced { split / 2 } else { split };
if key.level > 0
&& (key.distance(camera) < detail * 16 + 16
|| key.distance(camera).max(self.elevation) < threshold)
{
self.split.insert(key);
for child in key.children() {
self.visit(child, camera, detail, range, previous);
self.visit(child, camera, detail, range, previous, reduced);
}
}
}
@@ -517,8 +530,10 @@ pub struct Lod {
/// The plan or the covered columns changed: covered leaves need stand-ins.
replanned: bool,
invalidated: FxHashSet<(i32, i32)>,
area: Option<([i32; 2], i32, u32, i32)>,
area: Option<([i32; 2], i32, u32, i32, bool)>,
plan: Plan,
/// Reduced far detail (`set_reduced_detail`).
reduced_detail: bool,
pending: FxHashSet<TileKey>,
sample_nearby: bool,
detail_masks: FxHashMap<TileKey, [u64; 4]>,
@@ -714,6 +729,7 @@ impl Lod {
replanned: true,
invalidated: FxHashSet::default(),
area: None,
reduced_detail: false,
plan: Plan::default(),
pending: FxHashSet::default(),
sample_nearby: true,
@@ -930,6 +946,11 @@ impl Lod {
pub fn set_near_loading(&mut self, loading: bool) {
self.near_loading = loading;
}
/// Reduced far detail (`graphics::FarDetail::Reduced`): the next update
/// replans when it changes.
pub fn set_reduced_detail(&mut self, reduced: bool) {
self.reduced_detail = reduced;
}
fn held(&self) -> bool {
self.small && self.near_loading
}
@@ -1021,11 +1042,19 @@ impl Lod {
detail,
distance,
elevation,
self.reduced_detail,
);
if self.area != Some(area) {
crate::spans::time("lod_plan", || {
self.area = Some(area);
self.plan = Plan::with_previous(camera, detail, distance, &self.plan, elevation);
self.plan = Plan::with_previous(
camera,
detail,
distance,
&self.plan,
elevation,
self.reduced_detail,
);
self.ready.retain(|key, _| self.plan.nodes.contains(key));
self.detail_masks
.retain(|key, _| self.plan.nodes.contains(key));
@@ -1421,6 +1450,35 @@ mod tests {
use super::*;
use crate::mesher::TerrainVertex;
#[test]
fn reduced_far_detail_only_merges_tiles_away_from_the_near_terrain() {
let leaves = |plan: &Plan| {
plan.nodes
.iter()
.filter(|k| !plan.split.contains(k))
.count()
};
for camera in [[0, 0], [100, -40]] {
for distance in [64, 128, 512, 1024] {
let full = Plan::new(camera, 12, distance);
let reduced = Plan::with_previous(camera, 12, distance, &Plan::default(), 0, true);
// Never finer than Full anywhere, and as fine next to the
// full-detail area, whose edge the finest tiles match.
assert!(reduced.split.is_subset(&full.split));
assert_eq!(reduced.roots, full.roots);
for key in reduced.nodes.iter().filter(|k| !reduced.split.contains(k)) {
if key.distance(camera) < 12 * 16 + 16 {
assert_eq!(key.level, 0, "{key:?}");
}
}
let (full, reduced) = (leaves(&full), leaves(&reduced));
assert!(
reduced * 2 < full,
"{reduced} of {full} tiles at {distance}"
);
}
}
}
#[test]
fn far_plan_is_bounded_and_near_tiles_align_with_chunks() {
for camera in [[0, 0], [-1, -17], [1023, -4097]] {
for distance in [64, 256, 1024] {
@@ -1453,7 +1511,7 @@ mod tests {
#[test]
fn aerial_distance_avoids_unseen_full_detail_outside_the_near_area() {
let ground = Plan::new([0, 0], 12, 1024);
let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024);
let high = Plan::with_previous([0, 0], 12, 1024, &Plan::default(), 1024, false);
assert!(high.nodes.len() < ground.nodes.len());
assert!(
high.nodes.contains(&TileKey {
@@ -1505,7 +1563,7 @@ mod tests {
#[test]
fn detail_survives_movement_across_a_split_threshold() {
let before = Plan::new([0, 0], 2, 256);
let after = Plan::with_previous([16, 0], 2, 256, &before, 0);
let after = Plan::with_previous([16, 0], 2, 256, &before, 0, false);
let mut retained = 0;
for key in &before.split {
if key.distance([16, 0]) < key.size() * 10 {
@@ -1804,7 +1862,7 @@ mod tests {
// Exercise the actual worker/update seam: a parent stays displayed
// until all four child meshes can replace it in the same update.
let camera = [-256.0, 100.0, -256.0];
lod.area = Some(([-16, -16], 2, 64, 0));
lod.area = Some(([-16, -16], 2, 64, 0, false));
lod.plan.roots = vec![key];
lod.plan.nodes.insert(key);
// As a replan does (`update` only selects after a change).
+7
View File
@@ -792,6 +792,9 @@ pub struct Mob {
pub on_ground: bool,
pub horizontal_collision: bool,
pub vertical_collision_below: bool,
/// `Entity.needsSync`: knocked back, pushed or jumped since its server
/// last sent it, so the next update goes out at once.
pub needs_sync: bool,
pub health: f32,
pub hurt_time: i32,
pub invulnerable: i32,
@@ -1361,8 +1364,10 @@ impl PendingPushes {
}
/// Adds the pending pushes aimed at `m` to its velocity, in push order.
pub fn apply_mob_pushes(m: &mut Mob, pending: &mut PendingPushes) {
// Entity.push(x, y, z) sets needsSync.
let mut add = |v: Vec3| {
m.velocity = Vec3::new(m.velocity.x + v.x, m.velocity.y + v.y, m.velocity.z + v.z);
m.needs_sync = true;
};
if !pending.list.is_empty() {
pending.list.retain(|(id, v)| {
@@ -1529,6 +1534,7 @@ impl Mob {
on_ground: false,
horizontal_collision: false,
vertical_collision_below: false,
needs_sync: false,
hurt_time: 0,
invulnerable: 0,
death_time: 0,
@@ -1769,6 +1775,7 @@ impl Mob {
if power <= 0.0 {
return;
}
self.needs_sync = true;
while xd * xd + zd * zd < f64::from(1.0e-5_f32) {
xd = (self.random.next_double() - self.random.next_double()) * 0.01;
zd = (self.random.next_double() - self.random.next_double()) * 0.01;
+3
View File
@@ -832,6 +832,9 @@ impl Mob {
};
}
pub fn jump_from_ground(&mut self, w: &MobWorld) {
// LivingEntity.jumpFromGround (and the rabbit's and cubes' own,
// which call it or do the same): needsSync.
self.needs_sync = true;
if self.species() == super::Species::Rabbit {
super::rabbit::jump(self, w);
return;
+12
View File
@@ -310,6 +310,18 @@ mod tests {
}
}
#[test]
fn love_ticks_survive_a_save() {
// Animal.addAdditionalSaveData / readAdditionalSaveData: "InLove" (#1396).
let mut m = rabbit();
m.extra.in_love = 570;
let tag = crate::entity_nbt::mob_to_nbt(&m, [0, 0, 0, 1], None);
assert_eq!(tag.get("InLove"), Some(&horizon_world::nbt::Tag::Int(570)));
let types = HashMap::from([(m.kind.id.clone(), m.kind.clone())]);
let back = crate::entity_nbt::mob_from_nbt(&tag, &types, 2).unwrap();
assert_eq!(back.extra.in_love, 570);
}
#[test]
fn landing_waits_then_real_hop_clock_moves_the_model() {
let mut m = rabbit();
@@ -351,6 +351,9 @@ fn spawns_far_from_player(s: Species, category: Category) -> bool {
}
/// What natural spawning reads from a level beyond the spawn rules.
/// An entity type's species, width, height and category.
pub type MobType = (Species, f32, f32, Category);
pub trait NaturalLevel: SpawnLevel {
fn min_y(&self) -> i32;
/// `getMaxY()`: the highest buildable block.
@@ -359,7 +362,7 @@ pub trait NaturalLevel: SpawnLevel {
fn ambient_light(&self) -> f32;
fn has_ceiling(&self) -> bool;
/// The entity type's species, width, height and category.
fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)>;
fn mob_type(&self, entity: &str) -> Option<MobType>;
/// The `natural_mob_spawns` lists by category at a position.
fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap<String, Vec<SpawnEntry>>>;
/// The `natural_mob_spawns` spawn cost of a type at a position.
@@ -376,9 +379,9 @@ pub trait NaturalLevel: SpawnLevel {
wanted: &dyn Fn(&str) -> bool,
) -> Vec<StructureAt>;
fn structure_spawns(&self) -> &StructureSpawns;
/// `getNearestPlayer(x, y, z, -1, false)`: the squared distance to the
/// nearest player that is not a spectator.
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64>;
/// Where the players `getNearestPlayer(x, y, z, -1, false)` measures to
/// are: every one that is not a spectator.
fn nearest_player_positions(&self) -> &[[f64; 3]];
/// The world spawn point, when it is in this dimension.
fn respawn(&self) -> Option<[i32; 3]>;
/// `ServerLevel.canSpawnEntitiesInChunk`.
@@ -870,9 +873,13 @@ impl SpawnState {
/// `ChunkMap.getPlayersCloseForSpawning`: none past the natural spawn
/// chunk tracker's range of 8 chunks from every player, else the
/// non-spectators within 128 blocks of the chunk's centre.
fn players_near(&mut self, cx: i32, cz: i32) -> &[usize] {
let players = &self.players;
self.players_near.entry((cx, cz)).or_insert_with(|| {
fn players_near<'a>(
near: &'a mut FxHashMap<(i32, i32), Vec<usize>>,
players: &[SpawnPlayer],
cx: i32,
cz: i32,
) -> &'a [usize] {
near.entry((cx, cz)).or_insert_with(|| {
let tracked = players.iter().any(|p| {
let (px, pz) = (
(p.position[0].floor() as i32) >> 4,
@@ -896,8 +903,7 @@ impl SpawnState {
})
}
fn add_local(&mut self, cx: i32, cz: i32, category: Category) {
let near = self.players_near(cx, cz).to_vec();
for p in near {
for &p in Self::players_near(&mut self.players_near, &self.players, cx, cz) {
self.player_counts.entry(p).or_insert([0; 8])[category.index()] += 1;
}
}
@@ -908,10 +914,10 @@ impl SpawnState {
}
/// `canSpawnForCategoryLocal` (`LocalMobCapCalculator.canSpawn`).
pub fn can_spawn_local(&mut self, category: Category, cx: i32, cz: i32) -> bool {
let near = self.players_near(cx, cz).to_vec();
near.into_iter().any(|p| {
let near = Self::players_near(&mut self.players_near, &self.players, cx, cz);
near.iter().any(|p| {
self.player_counts
.get(&p)
.get(p)
.is_none_or(|c| c[category.index()] < category.max())
})
}
@@ -1080,9 +1086,11 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
}
let mut spawned: Vec<Aabb> = Vec::new();
let mut cluster = 0u32;
let mut nearest = Nearest::new(start);
for _ in 0..3 {
let (mut x, mut z) = (x0, z0);
let mut current: Option<SpawnEntry> = None;
// The group's entry, its type and where it was picked.
let mut current: Option<(SpawnEntry, Option<MobType>, [i32; 3])> = None;
let mut group_index = 0u32;
let mut max = (random.next_float() * 4.0).ceil() as i32;
let mut ll = 0;
@@ -1092,16 +1100,15 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
z += random.next_int_bound(6).unwrap_or(0) - random.next_int_bound(6).unwrap_or(0);
let (xx, zz) = (f64::from(x) + 0.5, f64::from(z) + 0.5);
trace(Event::Try(x, z));
let Some(distance) = l.nearest_player_distance_sqr(xx, f64::from(y), zz) else {
let Some(distance) = nearest.distance_sqr(l, xx, f64::from(y), zz) else {
continue;
};
if !right_distance(l, chunk, [x, y, z], distance) {
continue;
}
if current.is_none() {
let reduced = l.biome_tag("minecraft:reduce_water_ambient_spawns", x, y, z);
let list = if category == Category::WaterAmbient
&& reduced
&& l.biome_tag("minecraft:reduce_water_ambient_spawns", x, y, z)
&& random.next_float() < 0.98
{
None
@@ -1112,21 +1119,32 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
break;
};
max = sample_count(&entry, random) as i32;
current = Some(entry);
let kind = l.mob_type(&entry.entity);
current = Some((entry, kind, [x, y, z]));
}
let entry = current.as_ref().unwrap_or_else(|| unreachable!());
if !valid_position_for_type(l, random, category, entry, [x, y, z], distance) {
let (entry, kind, picked_at) = current.as_ref().unwrap_or_else(|| unreachable!());
// The list the entry was picked from holds it where it was picked.
let listed = *picked_at == [x, y, z];
let valid = valid_position_for_type(
l,
random,
category,
entry,
*kind,
listed,
[x, y, z],
distance,
);
let Some((species, width, height, type_category)) = (*kind).filter(|_| valid) else {
continue;
}
};
let passed = state.can_spawn(l, &entry.entity, [x, y, z]);
trace(Event::Test(entry.entity.clone(), [x, y, z], passed));
if !passed {
continue;
}
// getMobForSpawn: a type the game cannot create ends the pass.
let Some((species, width, height, type_category)) = l.mob_type(&entry.entity) else {
return;
};
// getMobForSpawn (a type the game cannot create, which would end
// the pass, never passes isValidSpawnPostitionForType).
let yaw = random.next_float() * 360.0;
let bounds = spawn_box(xx, f64::from(y), zz, width, height);
let despawn = f64::from(type_category.despawn_distance().pow(2));
@@ -1167,6 +1185,52 @@ pub fn spawn_category_for_position<L: NaturalLevel, R: RandomSource>(
}
}
}
/// `getNearestPlayer(x, y, z, -1, false)`'s squared distance for the
/// attempts of one start. The spawn checks only compare it with 24² and the
/// despawn distances (128² at most), so it is measured only to the players
/// that can be within 128 blocks of an attempt near the start: when none is,
/// the nearest reads as infinitely far, which every check answers as it
/// answers the real distance. An attempt that wanders farther than
/// `MARGIN` from the start measures every player.
struct Nearest {
/// The players that can be within 128 blocks, once an attempt asked.
nearby: Option<Vec<[f64; 3]>>,
start: (f64, f64),
}
impl Nearest {
const REACH: f64 = 128.0;
const MARGIN: f64 = 64.0;
fn new(start: [i32; 3]) -> Self {
Self {
nearby: None,
start: (f64::from(start[0]) + 0.5, f64::from(start[2]) + 0.5),
}
}
fn distance_sqr<L: NaturalLevel>(&mut self, l: &L, x: f64, y: f64, z: f64) -> Option<f64> {
let all = l.nearest_player_positions();
if all.is_empty() {
return None;
}
let distance = |p: &[f64; 3]| (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
let (sx, sz) = self.start;
if (x - sx).abs() > Self::MARGIN || (z - sz).abs() > Self::MARGIN {
return all.iter().map(distance).reduce(f64::min);
}
// The others are farther than REACH, across, from every point within
// MARGIN of the start.
let nearby = self.nearby.get_or_insert_with(|| {
all.iter()
.filter(|p| {
let dx = ((p[0] - sx).abs() - Self::MARGIN).max(0.0);
let dz = ((p[2] - sz).abs() - Self::MARGIN).max(0.0);
dx * dx + dz * dz <= Self::REACH * Self::REACH
})
.copied()
.collect()
});
Some(nearby.iter().map(distance).fold(f64::INFINITY, f64::min))
}
}
/// `isRightDistanceToPlayerAndSpawnPoint`.
fn right_distance<L: NaturalLevel>(l: &L, chunk: (i32, i32), pos: [i32; 3], distance: f64) -> bool {
if distance <= 576.0 {
@@ -1185,16 +1249,20 @@ fn right_distance<L: NaturalLevel>(l: &L, chunk: (i32, i32), pos: [i32; 3], dist
let (cx, cz) = (pos[0] >> 4, pos[2] >> 4);
(cx, cz) == chunk || l.can_spawn_in_chunk(cx, cz)
}
/// `isValidSpawnPostitionForType`.
/// `isValidSpawnPostitionForType`, for the entry's type (`mob_type`).
/// `listed`: the position is where the entry was picked from `mobsAt`.
#[allow(clippy::too_many_arguments)]
fn valid_position_for_type<L: NaturalLevel, R: RandomSource>(
l: &L,
random: &mut R,
category: Category,
entry: &SpawnEntry,
kind: Option<MobType>,
listed: bool,
pos: [i32; 3],
distance: f64,
) -> bool {
let Some((species, width, height, type_category)) = l.mob_type(&entry.entity) else {
let Some((species, width, height, type_category)) = kind else {
return false;
};
if type_category == Category::Misc {
@@ -1205,9 +1273,10 @@ fn valid_position_for_type<L: NaturalLevel, R: RandomSource>(
return false;
}
let [x, y, z] = pos;
if !mobs_at(l, category, x, y, z)
.iter()
.any(|e| same_spawner(e, entry))
if !listed
&& !mobs_at(l, category, x, y, z)
.iter()
.any(|e| same_spawner(e, entry))
{
return false;
}
+73 -3
View File
@@ -337,7 +337,10 @@ impl PlayerMotion {
self.air = extra
.and_then(|t| crate::save::number(t.get("Air")))
.unwrap_or(300) as i32;
self.fall_distance = level.fall_distance;
self.set_health(level.health, false)?;
// LivingEntity.readAdditionalSaveData: internalSetAbsorptionAmount.
self.internal_set_absorption(level.absorption)?;
self.health.food.food = level.food;
self.health.food.saturation = level.saturation;
self.flying = level.flying;
@@ -1029,9 +1032,22 @@ impl MotionWorld<'_, '_, '_> {
}
/// `Entity.applyEffectsFromBlocks` for the blocks Horizon's player reacts
/// to inside its movement path: powder snow (`PowderSnowBlock.entityInside`
/// and the FREEZE / EXTINGUISH effects), cobwebs and sweet berry bushes. Pressure plates, tripwires and
/// rails are the world tick's scan.
/// and the FREEZE / EXTINGUISH effects), cobwebs, sweet berry bushes,
/// cactus and lit campfires, and the magma block it stands on
/// (`stepOn`). Pressure plates, tripwires and rails are the world tick's
/// scan.
pub fn apply_effects_from_blocks(&mut self) -> Result<()> {
self.effects_from_blocks(false)
}
/// What `applyEffectsFromBlocks` does to a server's player along the
/// moves its client sent since `from` (in its history): the damage of
/// magma, cactus, lit campfires and grown sweet berry bushes. Its client
/// moves it, so the slowing and the rest are the client's.
pub fn server_block_damage(&mut self, from: Vec3) -> Result<()> {
self.player.old_position = from;
self.effects_from_blocks(true)
}
fn effects_from_blocks(&mut self, damage_only: bool) -> Result<()> {
use horizon_world::entity_move::MovementRecord;
let mut movements: Vec<MovementRecord> =
self.player.history.records().iter().copied().collect();
@@ -1056,6 +1072,19 @@ impl MotionWorld<'_, '_, '_> {
if self.player.spectator {
return Ok(());
}
// Block.stepOn for the block under an entity on the ground:
// MagmaBlock's hot floor hurts unless it steps carefully (sneaks).
if self.player.ground.on_ground {
let on = self.legacy_on_position()?;
let magma = self
.assets
.registry
.state(self.block(on))
.is_some_and(|s| s.name == "minecraft:magma_block");
if magma && !self.player.shift_key_down {
self.environment_hurt("minecraft:hot_floor", 1.0)?;
}
}
let mut visited = std::collections::HashSet::new();
let mut entered = Vec::new();
for movement in &movements {
@@ -1081,6 +1110,7 @@ impl MotionWorld<'_, '_, '_> {
&mut visited,
&mut entered,
budget,
damage_only,
)?;
from = to;
}
@@ -1093,11 +1123,19 @@ impl MotionWorld<'_, '_, '_> {
&mut visited,
&mut entered,
16,
damage_only,
)?;
}
}
if budget <= 0 {
self.check_inside_blocks(movement.to, movement.to, &mut visited, &mut entered, 1)?;
self.check_inside_blocks(
movement.to,
movement.to,
&mut visited,
&mut entered,
1,
damage_only,
)?;
}
}
// InsideBlockEffectApplier.StepBasedCollector: the blocks entered
@@ -1137,6 +1175,7 @@ impl MotionWorld<'_, '_, '_> {
visited: &mut std::collections::HashSet<i64>,
entered: &mut Vec<(i32, BlockPos)>,
max_iterations: i32,
damage_only: bool,
) -> Result<i32> {
let deflated = self.player.dimensions.bounds(to).deflate(Vec3::new(
f64::from(1.0e-5_f32),
@@ -1213,6 +1252,33 @@ impl MotionWorld<'_, '_, '_> {
}
};
if inside && visited.insert(p.pack()) {
// CactusBlock and CampfireBlock.entityInside (`entity.hurt`).
match name {
"minecraft:cactus" => self.environment_hurt("minecraft:cactus", 1.0)?,
"minecraft:campfire" | "minecraft:soul_campfire" => {
let lit = self
.assets
.registry
.state(state)
.and_then(|s| s.properties.get("lit"))
.is_some_and(|v| v == "true");
let damage = if name == "minecraft:campfire" {
1.0
} else {
2.0
};
if lit {
self.environment_hurt("minecraft:campfire", damage)?;
}
}
"minecraft:sweet_berry_bush" if damage_only => {
self.sweet_berry_bush_damage(state)?;
}
_ => {}
}
if damage_only {
continue;
}
match name {
"minecraft:powder_snow" => {
self.powder_snow_inside(p)?;
@@ -1261,6 +1327,10 @@ impl MotionWorld<'_, '_, '_> {
/// is at least 0.003 on X or Z.
fn sweet_berry_bush_inside(&mut self, state: BlockStateId) -> Result<()> {
self.make_stuck_in_block(Vec3::new(f64::from(0.8_f32), 0.75, f64::from(0.8_f32)));
self.sweet_berry_bush_damage(state)
}
/// The server's half of `SweetBerryBushBlock.entityInside`: the prick.
fn sweet_berry_bush_damage(&mut self, state: BlockStateId) -> Result<()> {
let age = self
.assets
.registry
@@ -6,7 +6,7 @@ use horizon_world::{
living_fluid::{LivingFluidWorld, TravelFluidTag},
};
impl MotionWorld<'_, '_, '_> {
fn environment_hurt(&mut self, source: &str, damage: f32) -> Result<()> {
pub(super) fn environment_hurt(&mut self, source: &str, damage: f32) -> Result<()> {
if self.player.invulnerable || self.player.spectator {
return Ok(());
}
+509 -8
View File
@@ -10,6 +10,7 @@ use crate::{
keybinds::{self, CATEGORIES, KeyBinds, MAPPINGS},
lang::{self, LanguageInfo},
settings::{SOUND_SOURCES, Settings},
shader_pack::PackConfig,
text::{self, Style, Text},
ui,
};
@@ -864,6 +865,8 @@ pub enum Page {
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,
@@ -871,9 +874,13 @@ pub enum Page {
Controller,
PadBinds,
Extras,
/// CreditsAndAttributionScreen.
Credits,
/// Horizon Settings: every page Horizon adds, as a mod's settings
/// screen hangs off vanilla's Options.
Horizon,
/// AI Agent: agent control on or off, whether an agent is connected, the connection details.
Agent,
}
#[derive(Clone, Debug, PartialEq)]
enum Act {
@@ -894,6 +901,14 @@ enum Act {
/// 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 {
@@ -906,6 +921,11 @@ enum WKind {
value: f32,
opt: Opt,
},
ShaderOptionSlider {
label: Text,
value: f32,
index: usize,
},
Text {
text: Text,
color: u32,
@@ -943,6 +963,13 @@ pub enum Event {
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`).
@@ -998,6 +1025,15 @@ pub struct OptionsUi {
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 {
@@ -1028,6 +1064,10 @@ impl Default for OptionsUi {
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,
}
}
}
@@ -1086,6 +1126,7 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Page::Extras => {
"Optional additions, all off by default: the game stays vanilla until you turn one on."
}
Page::Agent => "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
Page::Mods => {
"Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on."
}
@@ -1107,6 +1148,11 @@ fn pairs(opts: &[Opt]) -> Vec<Row> {
.collect()
}
impl OptionsUi {
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
let label = format!("{}: {}", item.caption(), item.value(&s.extras));
button(0.0, 0.0, 150.0, Text::literal(label), Act::Extra(item))
}
fn settings_search_active(&self) -> bool {
self.settings_search.focused
&& matches!(self.page(), Page::Video | Page::Controls | Page::KeyBinds)
@@ -1152,7 +1198,7 @@ impl OptionsUi {
}
/// Pick shader pack `i` of the folder, or turn it off when it is the
/// one running.
pub(crate) fn toggle_shader_pack(&self, s: &mut Settings, i: usize) {
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;
};
@@ -1162,6 +1208,83 @@ impl OptionsUi {
} 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
@@ -1195,11 +1318,14 @@ impl OptionsUi {
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",
Page::Extras => "Horizon Extras",
Page::Credits => "credits_and_attribution.screen.title",
Page::Horizon => "Horizon Settings",
Page::Agent => "AI Agent",
}
}
fn scrollbar_x(&self, width: f32) -> f32 {
@@ -1316,8 +1442,31 @@ impl OptionsUi {
open("Extras...", Page::Extras),
Some(open("Mods...", Page::Mods)),
),
// No AI hides the AI Agent page and everything AI.
if s.extras.no_ai {
Row::Buttons(self.extra_button(s, crate::extras::Item::NoAi), None)
} else {
Row::Buttons(
open("AI Agent...", Page::Agent),
Some(self.extra_button(s, crate::extras::Item::NoAi)),
)
},
]
}
Page::Agent => {
let mut rows = vec![Row::Buttons(
self.extra_button(s, crate::extras::Item::AgentControl),
Some(button(
0.0,
0.0,
150.0,
Text::literal("Copy Connection Details"),
Act::CopyAgentDetails,
)),
)];
rows.extend(self.agent_status.iter().cloned().map(Row::Note));
rows
}
Page::Video => {
// Horizon's graphics screens, Simple and Advanced.
let mut rows = vec![Row::Buttons(
@@ -1544,6 +1693,87 @@ impl OptionsUi {
}
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()
@@ -1554,6 +1784,9 @@ 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;
@@ -1595,10 +1828,13 @@ impl OptionsUi {
("options.accessibility", Some(Page::Accessibility)),
// Vanilla's Telemetry Data and Credits slots: Horizon's
// settings and its mods, as a mod adds its button here.
// Credits & Attribution (WinScreen's credits) follows them.
("Horizon Settings...", Some(Page::Horizon)),
("Mods...", Some(Page::Mods)),
("options.credits_and_attribution", Some(Page::Credits)),
];
let gy = (61.0 + 30.0f32).min(h - 33.0 - 120.0);
let rows = entries.len().div_ceil(2) as f32;
let gy = (61.0 + 30.0f32).min(h - 33.0 - rows * 24.0);
for (i, (key, page)) in entries.iter().enumerate() {
let x = cx - 158.0 + 4.0 + (i % 2) as f32 * 158.0;
let y = gy + (i / 2) as f32 * 24.0;
@@ -1609,6 +1845,46 @@ impl OptionsUi {
top = 0.0;
bottom = 0.0;
}
Page::Credits => {
// CreditsAndAttributionScreen: the title header, three 210-wide
// buttons 8 apart (HeaderAndFooterLayout: 30 below the header
// unless the footer is closer), Done.
out.push(Widget {
x: 0.0,
y: 12.0,
w,
h: 9.0,
kind: WKind::Text {
text: tr(Self::title(page)),
color: u32::MAX,
centered: true,
},
list: false,
});
let y = (33.0 + 30.0f32).min(h - 33.0 - 76.0);
let x = cx - 105.0;
out.push(button(
x,
y,
210.0,
tr("credits_and_attribution.button.credits"),
Act::Credits,
));
// ConfirmLinkScreen to minecraft.net: Horizon opens no links.
for (i, key) in [
"credits_and_attribution.button.attribution",
"credits_and_attribution.button.licenses",
]
.into_iter()
.enumerate()
{
let y = y + 28.0 * (i + 1) as f32;
out.push(inactive(button(x, y, 210.0, tr(key), Act::None)));
}
out.push(done(h - 27.0));
top = 0.0;
bottom = 0.0;
}
Page::KeyBinds => {
top = 38.0;
out.push(Widget {
@@ -1900,6 +2176,10 @@ 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) {
@@ -1988,12 +2268,34 @@ impl OptionsUi {
out[list_start..].iter_mut().for_each(|w| w.list = true);
let folder = match page {
Page::Mods => Some(("Open Mod Folder", Act::OpenModFolder)),
Page::ShaderPacks => {
Some(("Open Shader Pack Folder", Act::OpenShaderPackFolder))
Page::ShaderPackOptions => {
Some(("Reset to Defaults", Act::ResetShaderPackOptions))
}
_ => None,
};
if let Some((label, act)) = folder {
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 {
out.push(button(
cx - 154.0,
h - 27.0,
@@ -2160,6 +2462,16 @@ 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 } => {
@@ -2200,6 +2512,31 @@ 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,
@@ -2414,6 +2751,19 @@ 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.
@@ -2499,6 +2849,36 @@ impl OptionsUi {
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 => {}
}
}
@@ -2509,6 +2889,16 @@ 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));
@@ -2537,7 +2927,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();
let slider = self.dragging.take().is_some() || self.dragging_shader_option.take().is_some();
let graphics_slider = self.graphics.release();
if slider || graphics_slider {
vec![Event::ButtonClick]
@@ -2593,6 +2983,14 @@ 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;
@@ -2609,6 +3007,12 @@ 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()
@@ -3036,6 +3440,74 @@ mod tests {
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();
@@ -4133,9 +4605,13 @@ mod tests {
})
.collect()
};
// Vanilla's ten Options buttons, the last two Horizon's.
// Vanilla's Options buttons, Horizon's two in the Telemetry and Credits slots, then
// Credits & Attribution (WinScreen's credits).
let main = pushes(&mut ui);
assert_eq!(&main[main.len() - 2..], [Page::Horizon, Page::Mods]);
assert_eq!(
&main[main.len() - 3..],
[Page::Horizon, Page::Mods, Page::Credits]
);
ui.push(Page::Horizon);
assert_eq!(
pushes(&mut ui),
@@ -4146,8 +4622,33 @@ mod tests {
Page::Controller,
Page::Extras,
Page::Mods,
Page::Agent,
]
);
// No AI hides the AI Agent page.
let mut no_ai = Settings::default();
no_ai.extras.no_ai = true;
let (widgets, _, _, _) = ui.layout(&no_ai, 427.0, 240.0);
assert!(!widgets.iter().any(|w| matches!(
&w.kind,
WKind::Button {
act: Act::Push(Page::Agent),
..
}
)));
// The AI Agent page: the switch, Copy and the status the game sets.
ui.push(Page::Agent);
ui.agent_status = vec!["Waiting for an agent.".into()];
let (widgets, _, _, _) = ui.layout(&s, 427.0, 240.0);
let acts: Vec<&Act> = widgets
.iter()
.filter_map(|w| match &w.kind {
WKind::Button { act, .. } => Some(act),
_ => None,
})
.collect();
assert!(acts.contains(&&Act::Extra(crate::extras::Item::AgentControl)));
assert!(acts.contains(&&Act::CopyAgentDetails));
// Vanilla's own pages keep the Horizon pages they had.
ui.stack = vec![Page::Main, Page::Video];
let video = pushes(&mut ui);
+1
View File
@@ -87,6 +87,7 @@ fn style_of(o: &serde_json::Map<String, serde_json::Value>) -> Style {
underlined: flag("underlined"),
strikethrough: flag("strikethrough"),
obfuscated: flag("obfuscated"),
font: o.get("font").and_then(crate::text::font_id),
..Style::default()
}
}
+119 -53
View File
@@ -1,7 +1,8 @@
//! Memoized palette eligibility for the game thread's random-tick snapshot.
//! Which sections of the game thread's chunks random-tick
//! (`LevelChunkSection.isRandomlyTicking`), memoized per section.
use crate::{
blocks::BlockFacts,
chunk::{Chunk, MIN_Y, SECTIONS},
chunk::{Chunk, MIN_Y, SECTIONS, Section},
fast_hash::FxHashMap,
};
use horizon_world::registry::BlockStateId;
@@ -10,6 +11,13 @@ use std::sync::{Arc, Weak};
/// A value snapshot, in ascending section order, with no per-chunk allocation.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct Sections(u32);
impl Sections {
/// The minimum Y of the lowest section in the set from block `min_y` up.
pub fn from(self, min_y: i32) -> Option<i32> {
let index = ((min_y - MIN_Y) >> 4).max(0) as u32;
Sections(self.0 & u32::MAX.checked_shl(index).unwrap_or(0)).next()
}
}
impl Iterator for Sections {
type Item = i32;
fn next(&mut self) -> Option<i32> {
@@ -29,12 +37,20 @@ impl ExactSizeIterator for Sections {}
struct Cached {
chunk: Weak<Chunk>,
/// The section each bit of `sections` was read from.
read: [Weak<Section>; SECTIONS],
sections: Sections,
}
/// Resident chunks are immutable behind `Arc`; edits use `Arc::make_mut`.
/// A weak reference detects both edits and replacement, without retaining block
/// data or forcing a deep copy on the next edit. It also prevents address reuse.
/// Clear this cache whenever the gameplay facts change (including resource reload).
/// Vanilla keeps a count of randomly ticking blocks and fluids per section,
/// updated by every `setBlockState`, and ticks the sections where it is above
/// zero. Here a section's answer is read from its blocks after any edit:
/// resident chunks and sections are immutable behind `Arc` and edited through
/// `Arc::make_mut`, so a weak reference tells an edited or replaced one from
/// the one read, without retaining block data or forcing a deep copy on the
/// next edit, and prevents address reuse. An unchanged chunk costs one
/// pointer comparison; a changed one, a comparison per section and a read of
/// the sections that changed. Clear this cache whenever the gameplay facts
/// change (including resource reload).
#[derive(Default)]
pub struct RandomTickSections {
chunks: FxHashMap<(i32, i32), Cached>,
@@ -46,6 +62,7 @@ impl RandomTickSections {
pub fn remove(&mut self, key: &(i32, i32)) {
self.chunks.remove(key);
}
/// The sections of `chunk` holding a randomly ticking block or fluid.
pub fn get(&mut self, key: (i32, i32), chunk: &Arc<Chunk>, facts: &BlockFacts) -> Sections {
self.get_with(key, chunk, |id| facts.get(BlockStateId(id)).random_ticks)
}
@@ -60,17 +77,23 @@ impl RandomTickSections {
}
let cached = self.chunks.entry(key).or_insert_with(|| Cached {
chunk: Weak::new(),
read: std::array::from_fn(|_| Weak::new()),
sections: Sections::default(),
});
if cached.chunk.as_ptr() != Arc::as_ptr(chunk) {
let mut mask = 0;
for (i, section) in chunk.sections.iter().enumerate() {
// Preserve maybe_has, including unused entries and global palettes.
if section.blocks.maybe_has(&mut random) {
mask |= 1 << i;
for (i, (section, read)) in chunk.sections.iter().zip(&mut cached.read).enumerate() {
if read.as_ptr() == Arc::as_ptr(section) {
continue;
}
// `isRandomlyTicking`: the blocks themselves, not the palette,
// which keeps the states no block uses any more.
if section.blocks.has(&mut random) {
cached.sections.0 |= 1 << i;
} else {
cached.sections.0 &= !(1 << i);
}
*read = Arc::downgrade(section);
}
cached.sections = Sections(mask);
cached.chunk = Arc::downgrade(chunk);
}
cached.sections
@@ -97,10 +120,24 @@ mod tests {
[MIN_Y]
);
assert_eq!(worker.get(0, MIN_Y, 0), 0);
// Even after the last ticking block is removed, its unused palette entry counts.
// Once the last ticking block is gone the section stops ticking, as
// vanilla's count drops to zero, though its palette still lists it.
Arc::make_mut(&mut chunk).set(0, MIN_Y, 0, 0);
assert_eq!(cache.get_with((0, 0), &chunk, |id| id == 1).len(), 1);
Arc::make_mut(&mut Arc::make_mut(&mut chunk).sections[0]).compact();
assert!(chunk.sections[0].blocks.maybe_has(|id| id == 1));
assert_eq!(cache.get_with((0, 0), &chunk, |id| id == 1).len(), 0);
// Only the edited section is read again (its palette: air, then 1).
Arc::make_mut(&mut chunk).set(0, MIN_Y + 16, 0, 1);
let mut asked = Vec::new();
let sections = cache.get_with((0, 0), &chunk, |id| {
asked.push(id);
id == 1
});
assert_eq!(
(sections.collect::<Vec<_>>(), asked),
(vec![MIN_Y + 16], vec![0, 1])
);
Arc::make_mut(&mut chunk).set(0, MIN_Y + 16, 0, 0);
Arc::make_mut(&mut Arc::make_mut(&mut chunk).sections[1]).compact();
assert_eq!(cache.get_with((0, 0), &chunk, |id| id == 1).len(), 0);
Arc::make_mut(&mut chunk).sections[SECTIONS - 1] = Arc::new(Section::default());
cache.clear(); // resource reload, with changed facts for the same state IDs
@@ -138,13 +175,36 @@ mod tests {
}
let chunk = Arc::new(chunk);
let mut cache = RandomTickSections::default();
// Global maybe_has considers the entire ID range, including absent states.
// Global palettes: the states the blocks hold, not the whole ID range.
assert_eq!(cache.get_with((0, 0), &chunk, |id| id == 40000).len(), 0);
cache.clear();
assert_eq!(
cache
.get_with((0, 0), &chunk, |id| id == 40000)
.get_with((0, 0), &chunk, |id| id == 299)
.collect::<Vec<_>>(),
[MIN_Y, MIN_Y + 9 * 16, MIN_Y + (SECTIONS as i32 - 1) * 16]
);
let sections = cache.get_with((0, 0), &chunk, |id| id == 299);
let top = MIN_Y + (SECTIONS as i32 - 1) * 16;
let from = |y| sections.from(y);
assert_eq!(
[
from(MIN_Y - 16),
from(MIN_Y),
from(MIN_Y + 16),
from(MIN_Y + 9 * 16)
],
[
Some(MIN_Y),
Some(MIN_Y),
Some(MIN_Y + 9 * 16),
Some(MIN_Y + 9 * 16)
]
);
assert_eq!(
[from(top), from(top + 16), from(top + 1000)],
[Some(top), None, None]
);
}
}
@@ -201,7 +261,7 @@ mod tick_parity {
}
#[test]
fn actual_random_ticks_match_original_scan() {
fn actual_random_ticks_match_a_block_scan() {
let root = std::env::var_os("HORIZON_RESOURCES")
.map(PathBuf::from)
.unwrap_or_else(|| {
@@ -228,6 +288,7 @@ mod tick_parity {
let registry = data::load_registry(&root).unwrap();
let (mut facts, behaviours) = BlockFacts::load_pair(&root, &registry).unwrap();
let grass = registry.default_state("minecraft:grass_block").unwrap();
let stone = registry.default_state("minecraft:stone").unwrap();
let chunk = |x| {
let mut c = Chunk::empty(x, 0);
c.lit = true;
@@ -280,6 +341,15 @@ mod tick_parity {
40 | 41 => {
Arc::make_mut(level.chunks.get_mut(&(0, 0)).unwrap()).lit = step == 41;
}
70 => {
// Every block of a ticking section replaced: its
// palette still lists grass, but nothing ticks there.
let c = Arc::make_mut(level.chunks.get_mut(&(-1, 0)).unwrap());
for i in 0..4096 {
c.set(i & 15, MIN_Y + 4 * 16 + (i >> 8), i >> 4 & 15, stone.0);
}
assert!(c.sections[4].blocks.maybe_has(|id| id == grass.0));
}
_ => {}
}
}
@@ -290,47 +360,43 @@ mod tick_parity {
facts.states[grass.0 as usize].random_ticks = step == 60;
cache.clear();
}
let old: Vec<_> = original
.chunks
.iter()
.filter(|(_, c)| c.lit)
.map(|(&key, c)| {
let sections: Vec<_> = c
.sections
.iter()
.enumerate()
.filter(|(_, s)| {
s.blocks
.maybe_has(|id| facts.get(BlockStateId(id)).random_ticks)
})
.map(|(i, _)| MIN_Y + i as i32 * 16)
.collect();
(key, sections)
})
.collect();
let new: Vec<_> = cached
.chunks
.iter()
.filter(|(_, c)| c.lit)
.map(|(&key, c)| (key, cache.get(key, c, &facts)))
.collect();
assert_eq!(
old,
new.iter()
.map(|(k, s)| (*k, s.collect::<Vec<_>>()))
.collect::<Vec<_>>()
);
// Stand in for an inside-block/scheduled write AFTER eligibility capture.
// A write between ticks.
if step == 20 {
for level in [&mut original, &mut cached] {
level.set_raw(BlockPos::new(0, MIN_Y + 16, 0), grass);
}
}
for ((x, z), sections) in old {
behaviours.tick_chunk_random(&mut original, &mut a, ChunkPos::new(x, z), sections);
let lit = |level: &Level| {
let mut keys: Vec<_> = level
.chunks
.iter()
.filter(|(_, c)| c.lit)
.map(|(&key, _)| key)
.collect();
keys.sort();
keys
};
let random = |id| facts.get(BlockStateId(id)).random_ticks;
for (x, z) in lit(&original) {
// Vanilla's rule, asked as each section is reached: any of its
// 4,096 blocks ticks randomly.
let reference = |level: &mut Level, from: i32| {
let chunk = &level.chunks[&(x, z)];
(from..MIN_Y + SECTIONS as i32 * 16)
.step_by(16)
.find(|&min_y| {
(0..16).any(|y| {
(0..16).any(|z| (0..16).any(|x| random(chunk.get(x, min_y + y, z))))
})
})
};
behaviours.tick_chunk_random(&mut original, &mut a, ChunkPos::new(x, z), reference);
}
for ((x, z), sections) in new {
behaviours.tick_chunk_random(&mut cached, &mut b, ChunkPos::new(x, z), sections);
for (x, z) in lit(&cached) {
let memoized = |level: &mut Level, from| {
cache.get((x, z), &level.chunks[&(x, z)], &facts).from(from)
};
behaviours.tick_chunk_random(&mut cached, &mut b, ChunkPos::new(x, z), memoized);
}
assert_eq!(original.reads, cached.reads, "ordered reads at tick {step}");
assert_eq!(original.events, cached.events, "events at tick {step}");
+37 -8
View File
@@ -2258,14 +2258,18 @@ pub struct Level {
pub game_mode: i32,
pub time: i64,
pub day_time: i64,
/// The player's respawn point (bed, or the world spawn).
/// Where the player respawns when it has no bed or anchor: its forced
/// Overworld point (`spawnpoint`), or the world spawn.
pub spawn: Option<[f64; 3]>,
/// A respawn point in another dimension than the Overworld (a respawn
/// anchor): dimension, block, yaw and `forced`.
/// A respawn point resolved through its block when the player respawns
/// (a bed, a respawn anchor, a forced point outside the Overworld):
/// dimension, block, yaw and `forced`.
pub respawn_elsewhere: Option<(String, [i32; 3], f32, bool)>,
pub position: Option<[f64; 3]>,
pub rotation: [f32; 2],
pub health: f32,
/// LivingEntity `AbsorptionAmount`: the golden hearts left.
pub absorption: f32,
pub food: i32,
pub saturation: f32,
/// (slot, item, count, damage): 0..36 inventory, 100..103 armor (feet..head), 150 offhand.
@@ -2274,6 +2278,8 @@ pub struct Level {
pub inventory_components: HashMap<u8, Box<crate::item_data::StackData>>,
/// Player.XpSeed: the enchanting table seed.
pub xp_seed: i32,
/// ServerPlayer.seenCredits: the end poem has been shown.
pub seen_credits: bool,
/// Player.active_effects: (id, duration, amplifier, ambient, show_icon).
pub active_effects: Vec<(String, i32, i32, bool, bool)>,
/// Player.EnderItems: the ender chest inventory (27 slots).
@@ -2288,6 +2294,8 @@ pub struct Level {
pub dimension: String,
/// World spawn (`level.dat` `spawn`), distinct from a bed respawn point.
pub world_spawn: Option<[i32; 3]>,
/// The world spawn's yaw and pitch (`setworldspawn`'s rotation).
pub world_spawn_angle: [f32; 2],
pub difficulty: String,
pub hardcore: bool,
/// `difficulty_settings.locked` (LevelData.isDifficultyLocked).
@@ -2365,6 +2373,11 @@ impl Level {
]
}),
};
let angle = |k: &str| match data.get("spawn").and_then(|s| s.get(k)) {
Some(Tag::Float(b)) => f32::from_bits(*b),
_ => 0.0,
};
level.world_spawn_angle = [angle("yaw"), angle("pitch")];
if let Some(d) = data.get("difficulty_settings") {
level.difficulty = text(d.get("difficulty")).unwrap_or_else(|| "normal".into());
level.hardcore = n(d.get("hardcore")) != 0;
@@ -2455,6 +2468,7 @@ impl Level {
}
self.motion = read_doubles::<3>(p.get("Motion")).unwrap_or_default();
self.health = f("Health").map_or(20.0, |v| v as f32);
self.absorption = f("AbsorptionAmount").map_or(0.0, |v| v as f32);
self.food = n("foodLevel").map_or(20, |v| v as i32);
self.saturation = f("foodSaturationLevel").map_or(5.0, |v| v as f32);
self.food_exhaustion = f("foodExhaustionLevel").unwrap_or(0.0) as f32;
@@ -2465,6 +2479,7 @@ impl Level {
self.selected_slot = n("SelectedItemSlot").unwrap_or(0) as i32;
self.fall_distance = f("fall_distance").unwrap_or(0.0);
self.on_ground = n("OnGround").unwrap_or(0) != 0;
self.seen_credits = n("seenCredits").unwrap_or(0) != 0;
if let Some(mode) = n("playerGameType") {
self.creative = mode == 1;
self.game_mode = mode as i32;
@@ -2482,14 +2497,17 @@ impl Level {
{
let dimension =
text(respawn.get("dimension")).unwrap_or_else(|| "minecraft:overworld".into());
if dimension == "minecraft:overworld" {
let forced = number(respawn.get("forced")).is_some_and(|v| v != 0);
// A forced point in the Overworld (`spawnpoint`) is where the
// player respawns; anything else (a bed, an anchor) is resolved
// through its block then.
if dimension == "minecraft:overworld" && forced {
self.spawn = Some([f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])]);
} else {
let yaw = match respawn.get("yaw") {
Some(Tag::Float(b)) => f32::from_bits(*b),
_ => 0.0,
};
let forced = number(respawn.get("forced")).is_some_and(|v| v != 0);
self.respawn_elsewhere = Some((dimension, [pos[0], pos[1], pos[2]], yaw, forced));
}
}
@@ -2626,6 +2644,7 @@ impl Level {
("OnGround", Tag::Byte(i8::from(self.on_ground))),
("fall_distance", Tag::Double(self.fall_distance.to_bits())),
("Health", Tag::Float(self.health.to_bits())),
("AbsorptionAmount", Tag::Float(self.absorption.to_bits())),
("foodLevel", Tag::Int(self.food)),
("foodSaturationLevel", Tag::Float(self.saturation.to_bits())),
(
@@ -2686,6 +2705,7 @@ impl Level {
if let Some(pos) = self.position {
entries.push(("Pos", doubles(&pos)));
}
entries.push(("seenCredits", Tag::Byte(i8::from(self.seen_credits))));
// A respawn point other than the world spawn: an anchor elsewhere, or a bed.
if let Some((dimension, block, yaw, forced)) = &self.respawn_elsewhere {
let mut respawn = vec![
@@ -2704,6 +2724,8 @@ impl Level {
s[1].floor() as i32,
s[2].floor() as i32,
];
// A point of the player's own in the Overworld other than a bed
// (`respawn_elsewhere`): `spawnpoint`'s, forced.
if Some(block) != self.world_spawn {
entries.push((
"respawn",
@@ -2712,6 +2734,7 @@ impl Level {
("pos", Tag::IntArray(block.to_vec())),
("yaw", Tag::Float(0f32.to_bits())),
("pitch", Tag::Float(0f32.to_bits())),
("forced", Tag::Byte(1)),
]),
));
}
@@ -2731,7 +2754,6 @@ impl Level {
("seenCredits", Tag::Byte(0)),
("HurtTime", Tag::Short(0)),
("DeathTime", Tag::Short(0)),
("AbsorptionAmount", Tag::Float(0)),
("FallFlying", Tag::Byte(0)),
] {
map.entry(key.into()).or_insert(value);
@@ -2857,8 +2879,8 @@ impl Level {
Tag::compound([
("pos", Tag::IntArray(spawn.to_vec())),
("dimension", string("minecraft:overworld")),
("yaw", Tag::Float(0f32.to_bits())),
("pitch", Tag::Float(0f32.to_bits())),
("yaw", Tag::Float(self.world_spawn_angle[0].to_bits())),
("pitch", Tag::Float(self.world_spawn_angle[1].to_bits())),
]),
),
("difficulty_settings", difficulty),
@@ -4114,6 +4136,7 @@ mod tests {
position: Some([1.5, 70.0, -3.25]),
rotation: [90.0, 10.0],
health: 17.0,
absorption: 5.0,
food: 15,
saturation: 2.5,
inventory: vec![
@@ -4126,6 +4149,7 @@ mod tests {
xp_total: 30,
dimension: "minecraft:the_nether".into(),
world_spawn: Some([8, 64, 8]),
world_spawn_angle: [45.0, -10.0],
spawn: Some([20.0, 70.0, 20.0]),
difficulty_locked: true,
flying: true,
@@ -4144,6 +4168,7 @@ mod tests {
assert_eq!(back.position, Some([1.5, 70.0, -3.25]));
assert_eq!(back.rotation, [90.0, 10.0]);
assert_eq!((back.health, back.food, back.saturation), (17.0, 15, 2.5));
assert_eq!(back.absorption, 5.0);
let mut inv = back.inventory.clone();
inv.sort();
assert_eq!(
@@ -4160,6 +4185,7 @@ mod tests {
);
assert_eq!(back.dimension, "minecraft:the_nether");
assert_eq!(back.world_spawn, Some([8, 64, 8]));
assert_eq!(back.world_spawn_angle, [45.0, -10.0]);
assert_eq!(back.spawn, Some([20.0, 70.0, 20.0]));
assert!(back.difficulty_locked);
assert!(back.weather.raining);
@@ -4178,10 +4204,13 @@ mod tests {
let mut again = Level {
player_extra: Some(extra),
ender_items: ender,
// The live amount, not the one the loaded compound held (#1307).
absorption: 2.0,
..back
};
again.write(&dir).unwrap();
let third = Level::read(&dir).unwrap();
assert_eq!(third.absorption, 2.0);
assert!(matches!(
third
.player_extra
+1
View File
@@ -118,6 +118,7 @@ pub fn style_from_json(v: &Value) -> Style {
s.underlined = flag("underlined");
s.strikethrough = flag("strikethrough");
s.obfuscated = flag("obfuscated");
s.font = o.get("font").and_then(text::font_id);
s
}
impl NumberFormat {
+31 -6
View File
@@ -1,12 +1,25 @@
// Full-screen camera effects (screen_effects.rs): clip-space quads sampling
// the shared 2D atlas with repeat inside each sprite rectangle.
// Full-screen camera effects (screen_effects.rs): clip-space quads reading
// one sprite rectangle of the shared 2D atlas as if it were its own texture,
// with the sampler vanilla gives it (ReloadableTexture.setSampler: linear
// when `blur`, clamp-to-edge when `clamp`, else nearest and repeat).
@group(0) @binding(1) var atlas: texture_2d<f32>;
@group(0) @binding(2) var atlas_sampler: sampler;
// The block sprites' sampler (nearest), and the entity group's lightmap
// sampler (linear) for `blur`. A blurred sprite has a one-texel border in the
// atlas, wrapped as repeat reads it (hud.rs), so the hardware's bilinear
// sample is GL_LINEAR with repeat; clamp-to-edge keeps the sample point half
// a texel inside. Each sampling has its own pipeline (gfx.rs), so a pixel
// costs what it did with nearest and repeat only: one sample.
@group(0) @binding(2) var nearest_sampler: sampler;
@group(0) @binding(4) var linear_sampler: sampler;
override BLUR: bool = false;
override CLAMP: bool = false;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) @interpolate(flat) rect: vec4<f32>,
@location(2) color: vec4<f32>,
// With clamp-to-edge, the sprite's first and last texel centres.
@location(3) @interpolate(flat) edges: vec4<f32>,
}
@vertex fn vs_screen(@location(0) pos: vec2<f32>, @location(1) uv: vec2<f32>,
@location(2) rect: vec4<f32>, @location(3) color: vec4<f32>) -> Out {
@@ -15,12 +28,24 @@ struct Out {
out.uv = uv;
out.rect = rect;
out.color = color;
let half = 0.5 / vec2<f32>(textureDimensions(atlas));
out.edges = vec4(rect.xy + half, rect.zw - half);
return out;
}
fn sample(in: Out) -> vec4<f32> {
let f = fract(in.uv);
let uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
return textureSampleLevel(atlas, atlas_sampler, uv, 0.0) * in.color;
var f = in.uv;
if !CLAMP {
// Repeat: one period of the sprite.
f = fract(f);
}
var uv = in.rect.xy + f * (in.rect.zw - in.rect.xy);
if CLAMP {
uv = clamp(uv, in.edges.xy, in.edges.zw);
}
if BLUR {
return textureSampleLevel(atlas, linear_sampler, uv, 0.0) * in.color;
}
return textureSampleLevel(atlas, nearest_sampler, uv, 0.0) * in.color;
}
@fragment fn fs_screen(in: Out) -> @location(0) vec4<f32> {
let c = sample(in);
+114 -5
View File
@@ -4,7 +4,10 @@
//! `hud_overlays::camera_overlays` (vignette, pumpkin blur, powder-snow
//! outline, nether portal swirl). Positions are clip-space quads; textures
//! come from the shared 2D atlas and may repeat.
use crate::{hud::Rect, hud_layout::GuiDraw};
use crate::{
hud::{Rect, TextureFlags},
hud_layout::GuiDraw,
};
use std::collections::HashMap;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -25,15 +28,28 @@ pub struct ScreenVertex {
pub rect: [f32; 4],
pub color: [f32; 4],
}
/// Linear filtering (`TextureMetadataSection.blur`), else nearest.
pub const SAMPLE_BLUR: u32 = 1;
/// Clamp-to-edge addressing (`TextureMetadataSection.clamp`), else repeat.
pub const SAMPLE_CLAMP: u32 = 2;
/// The sampling bits of a standalone texture's flags.
pub fn sampling(flags: TextureFlags) -> u32 {
let bit = |on: bool, bit: u32| if on { bit } else { 0 };
bit(flags.blur, SAMPLE_BLUR) | bit(flags.clamp, SAMPLE_CLAMP)
}
#[derive(Clone, Debug)]
pub struct ScreenQuad {
pub vertices: [ScreenVertex; 4],
pub blend: Blend,
/// How the sprite is sampled: [`SAMPLE_BLUR`] and [`SAMPLE_CLAMP`].
pub sampling: u32,
}
/// What the camera sees this frame.
pub struct ScreenInputs<'a> {
pub sprites: &'a HashMap<String, Rect>,
/// `.png.mcmeta` sampling of the standalone textures among `sprites`.
pub flags: &'a HashMap<String, TextureFlags>,
pub aspect: f32,
pub partial: f32,
/// Particle sprite of the view-blocking block around the eye.
@@ -92,12 +108,17 @@ impl ScreenEffects {
pub fn build(&self, input: &ScreenInputs) -> Vec<ScreenQuad> {
let mut out = Vec::new();
let sprite = |name: &str| input.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
// Standalone textures (`ReloadableTexture.setSampler`) follow their
// metadata; atlas sprites (block overlay, fire, portal) sample
// nearest with repeat.
let texture_sampling = |name: &str| input.flags.get(name).copied().map_or(0, sampling);
let scale = hand_scale(input.aspect);
// Screen-effect quads live at view depth 0.5 in the hand projection.
let hand_quad = |corners: [[f32; 2]; 4],
uv: [[f32; 2]; 4],
rect: [f32; 4],
color: [f32; 4]| ScreenQuad {
color: [f32; 4],
sampling: u32| ScreenQuad {
vertices: std::array::from_fn(|i| ScreenVertex {
pos: [corners[i][0] * scale[0], corners[i][1] * scale[1]],
uv: uv[i],
@@ -105,6 +126,7 @@ impl ScreenEffects {
color,
}),
blend: Blend::Alpha,
sampling,
};
let full = [[-1.0, -1.0], [1.0, -1.0], [1.0, 1.0], [-1.0, 1.0]];
// ScreenEffectRenderer.submitBlockSprite: u1/v1 at (-1, -1), colour 0xFF191919.
@@ -117,6 +139,7 @@ impl ScreenEffects {
[[1.0, 1.0], [0.0, 1.0], [0.0, 0.0], [1.0, 0.0]],
rect,
[c, c, c, 1.0],
0,
));
}
if let Some((brightness, yaw, pitch)) = input.water
@@ -129,6 +152,7 @@ impl ScreenEffects {
uv,
rect,
[brightness, brightness, brightness, 0.1],
texture_sampling("misc/underwater"),
));
}
if input.on_fire
@@ -150,11 +174,12 @@ impl ScreenEffects {
[[1.0, 1.0], [0.0, 1.0], [0.0, 0.0], [1.0, 0.0]],
rect,
[1.0, 1.0, 1.0, alpha],
0,
));
}
}
// Hud camera overlays: GUI-space full-screen blits (v grows downwards).
let gui = |rect: [f32; 4], color: [f32; 4], blend: Blend| ScreenQuad {
let gui = |rect: [f32; 4], color: [f32; 4], blend: Blend, sampling: u32| ScreenQuad {
vertices: std::array::from_fn(|i| {
let p = [[-1.0f32, 1.0], [1.0, 1.0], [1.0, -1.0], [-1.0, -1.0]][i];
ScreenVertex {
@@ -165,6 +190,7 @@ impl ScreenEffects {
}
}),
blend,
sampling,
};
let rgba = |c: u32| {
let [a, r, g, b] = c.to_be_bytes();
@@ -187,8 +213,14 @@ impl ScreenEffects {
} else {
Blend::Alpha
};
// The vignette darkens by texture × colour: black (in
// daylight its brightness fades to 0 within half a
// minute) leaves every pixel as it was.
if blend == Blend::Vignette && color & 0x00ff_ffff == 0 {
continue;
}
if let Some(rect) = sprite(texture) {
out.push(gui(rect, rgba(*color), blend));
out.push(gui(rect, rgba(*color), blend, texture_sampling(texture)));
}
}
GuiDraw::AtlasSprite {
@@ -197,7 +229,7 @@ impl ScreenEffects {
..
} => {
if let Some(rect) = sprite(name) {
out.push(gui(rect, rgba(*color), Blend::Alpha));
out.push(gui(rect, rgba(*color), Blend::Alpha, 0));
}
}
_ => {}
@@ -243,8 +275,10 @@ mod tests {
},
);
let e = ScreenEffects::default();
let flags = HashMap::new();
let input = ScreenInputs {
sprites: &sprites,
flags: &flags,
aspect: 1.6,
partial: 0.0,
block_overlay: None,
@@ -255,5 +289,80 @@ mod tests {
let quads = e.build(&input);
assert_eq!(quads.len(), 1);
assert_eq!(quads[0].blend, Blend::Vignette);
assert_eq!(quads[0].sampling, 0);
// A black vignette (faded out in daylight) changes no pixel: no quad.
// Below 1/255 the colour's channels are 0; a border warning tints it.
for (brightness, warning, drawn) in [(0.003, 0.0, 0), (0.004, 0.0, 1), (0.0, 0.5, 1)] {
let overlays = [crate::hud_overlays::vignette(320, 240, brightness, warning)];
let input = ScreenInputs {
block_overlay: None,
gui_overlays: &overlays,
..input
};
assert_eq!(e.build(&input).len(), drawn, "{brightness} {warning}");
}
}
#[test]
fn standalone_textures_sample_by_their_metadata() {
let rect = Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
};
let sprites: HashMap<_, _> = ["misc/vignette", "misc/underwater", "block/stone"]
.map(|name| (name.to_owned(), rect))
.into();
let blur = TextureFlags {
blur: true,
clamp: false,
};
let clamp = TextureFlags {
blur: false,
clamp: true,
};
let flags: HashMap<_, _> = [
("misc/vignette".to_owned(), blur),
("misc/underwater".to_owned(), clamp),
// Atlas sprites ignore the texture section.
("block/stone".to_owned(), blur),
]
.into();
let quads = ScreenEffects::default().build(&ScreenInputs {
sprites: &sprites,
flags: &flags,
aspect: 1.6,
partial: 0.0,
block_overlay: Some("block/stone".to_owned()),
water: Some((1.0, 0.0, 0.0)),
on_fire: false,
gui_overlays: &[crate::hud_overlays::vignette(320, 240, 1.0, 0.0)],
});
let sampling: Vec<_> = quads.iter().map(|q| q.sampling).collect();
assert_eq!(sampling, [0, SAMPLE_CLAMP, SAMPLE_BLUR]);
}
#[test]
fn mcmeta_texture_section() {
let parse = TextureFlags::from_mcmeta;
assert_eq!(
parse(r#"{"texture":{"blur":true}}"#),
Some(TextureFlags {
blur: true,
clamp: false
})
);
assert_eq!(
parse(r#"{"texture":{"clamp":true,"mipmap_strategy":"mean"}}"#),
Some(TextureFlags {
blur: false,
clamp: true
})
);
// Defaults (false, false) or no section: the default sampler.
assert_eq!(parse(r#"{"texture":{"blur":false}}"#), None);
assert_eq!(parse(r#"{"animation":{}}"#), None);
assert_eq!(parse("not json"), None);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,108 @@
// 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));
}
+10 -3
View File
@@ -63,6 +63,9 @@ impl SpriteAnimation {
pub fn pixels(&self, frame: usize) -> &[u8] {
&self.frames[frame]
}
pub fn frame_count(&self) -> usize {
self.frames.len()
}
}
#[derive(Default)]
@@ -93,7 +96,7 @@ pub const SPRITE_SIZE: u32 = 16;
/// with bigger textures are sampled down to fit.
const MAX_ARRAY_BYTES: u64 = 256 << 20;
/// Nearest-resample a square RGBA frame to another square size.
fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
pub(crate) fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
if from == to {
return frame.to_vec();
}
@@ -327,11 +330,15 @@ pub(crate) fn test_clip(size: u32, layer: u32, metadata: &str) -> SpriteAnimatio
.flat_map(|i| [(i % size) as u8, (i / size) as u8, blue, 255])
.collect()
};
test_frames(layer, vec![frame(0), frame(99)], metadata)
}
#[cfg(test)]
pub(crate) fn test_frames(layer: u32, frames: Vec<Vec<u8>>, metadata: &str) -> SpriteAnimation {
let metadata = AnimationMetadata::from_json(metadata).unwrap();
SpriteAnimation {
layer,
frames: Arc::new(vec![frame(0), frame(99)]),
clip: Arc::new(metadata.clip(2).unwrap().unwrap()),
clip: Arc::new(metadata.clip(frames.len() as u32).unwrap().unwrap()),
frames: Arc::new(frames),
}
}
+15 -1
View File
@@ -61,6 +61,13 @@ pub struct Style {
pub click: Option<ClickEvent>,
/// Hover text.
pub hover: Option<Box<Text>>,
/// The font (`FontDescription.Resource`), a full id like
/// `minecraft:alt`; the default font when unset.
pub font: Option<String>,
}
/// A style's `font` field: an identifier (`alt` is `minecraft:alt`).
pub fn font_id(v: &Value) -> Option<String> {
crate::command::args::parse_identifier(v.as_str()?)
}
impl Style {
pub fn color(c: u32) -> Self {
@@ -83,6 +90,9 @@ impl Style {
if self.hover.is_none() {
self.hover.clone_from(&base.hover);
}
if self.font.is_none() {
self.font.clone_from(&base.font);
}
}
pub fn is_bold(&self) -> bool {
self.bold == Some(true)
@@ -451,7 +461,7 @@ impl Text {
}
/// The style fields of a component's JSON.
const STYLE_KEYS: [&str; 8] = [
const STYLE_KEYS: [&str; 9] = [
"color",
"bold",
"italic",
@@ -460,6 +470,7 @@ const STYLE_KEYS: [&str; 8] = [
"obfuscated",
"click_event",
"hover_event",
"font",
];
/// Style.Serializer: the fields a style sets.
fn style_json(style: &Style) -> Vec<(String, Value)> {
@@ -500,6 +511,9 @@ fn style_json(style: &Style) -> Vec<(String, Value)> {
json!({ "action": "show_text", "value": hover.component() }),
));
}
if let Some(font) = &style.font {
out.push(("font".to_owned(), font.clone().into()));
}
out
}
+354
View File
@@ -0,0 +1,354 @@
//! Armour trims on worn armour: `EquipmentLayerRenderer`'s trim layer. A
//! trim's texture is `textures/trims/entity/<layer>/<pattern>.png` recoloured
//! by `PalettedTextureManager`: every pixel whose colour is in the texture's
//! base palette (its `palette` metadata section, `trim_base`) takes the colour
//! at the same index of the material's palette (`textures/palettes/trim/*`),
//! unless the equipment asset's `trim_overrides` pick another texture or
//! palette (iron armour with an iron trim uses `trim/iron_darker`).
use horizon_assets::palette::apply_palette;
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
/// An RGBA image: width, height, pixels.
pub type Image = (u32, u32, Vec<u8>);
/// `minecraft:` added to an identifier without a namespace.
fn qualified(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
/// `assets/<namespace>/<folder>/<path><suffix>` of an identifier.
fn asset_path(root: &Path, folder: &str, id: &str, suffix: &str) -> std::path::PathBuf {
let id = qualified(id);
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", &id));
root.join("assets")
.join(namespace)
.join(folder)
.join(format!("{path}{suffix}"))
}
/// `EquipmentClientInfo.TrimOverride`: when the trim matches (`when`'s
/// material and pattern, each optional), use `texture` instead of the
/// pattern's asset id and `palette` (none when absent) as the palette.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct TrimOverride {
pub material: Option<String>,
pub pattern: Option<String>,
pub texture: Option<String>,
pub palette: Option<String>,
}
impl TrimOverride {
/// An equipment asset's `trim_overrides` list.
pub fn list(equipment: &Value) -> Vec<Self> {
let Some(list) = equipment.get("trim_overrides").and_then(Value::as_array) else {
return Vec::new();
};
let id = |v: &Value, key: &str| v.get(key).and_then(Value::as_str).map(qualified);
list.iter()
.filter_map(|o| {
let when = o.get("when")?;
Some(Self {
material: id(when, "material"),
pattern: id(when, "pattern"),
texture: id(o, "texture"),
palette: id(o, "palette"),
})
})
.collect()
}
/// `TrimPredicate.matches`.
fn matches(&self, material: &str, pattern: &str) -> bool {
self.material.as_deref().is_none_or(|m| m == material)
&& self.pattern.as_deref().is_none_or(|p| p == pattern)
}
}
/// The `trim_pattern` (asset id) and `trim_material` (palette id) registries.
#[derive(Clone, Debug, Default)]
pub struct TrimRegistry {
patterns: HashMap<String, String>,
materials: HashMap<String, String>,
}
impl TrimRegistry {
/// Every `data/<namespace>/trim_pattern` and `trim_material` entry.
pub fn load(root: &Path) -> Self {
let mut out = Self::default();
let Ok(namespaces) = fs::read_dir(root.join("data")) else {
return out;
};
for ns in namespaces.flatten() {
let namespace = ns.file_name().to_string_lossy().into_owned();
for (folder, key, map) in [
("trim_pattern", "asset_id", &mut out.patterns),
("trim_material", "palette_id", &mut out.materials),
] {
let Ok(files) = fs::read_dir(ns.path().join(folder)) else {
continue;
};
for f in files.flatten() {
let path = f.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
let Some(value) = fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
else {
continue;
};
if let Some(v) = value.get(key).and_then(Value::as_str) {
let name = path.file_stem().unwrap_or_default().to_string_lossy();
map.insert(format!("{namespace}:{name}"), qualified(v));
}
}
}
}
out
}
#[cfg(test)]
fn of(patterns: &[(&str, &str)], materials: &[(&str, &str)]) -> Self {
let map = |l: &[(&str, &str)]| {
l.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
};
Self {
patterns: map(patterns),
materials: map(materials),
}
}
}
/// The texture and palette of one trim layer.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct TrimTexture {
/// `<namespace>:trims/entity/<layer>/<path>`, under `textures/`.
pub texture: String,
/// The target palette under `textures/palettes/`; none draws the
/// texture's own colours.
pub palette: Option<String>,
}
impl TrimTexture {
/// `TrimTextureKey.getOrPrepareTexture`: the trim (`material`,
/// `pattern` registry ids) on the `layer` (`humanoid`,
/// `humanoid_leggings`...) of an equipment asset with `overrides`.
pub fn resolve(
registry: &TrimRegistry,
overrides: &[TrimOverride],
material: &str,
pattern: &str,
layer: &str,
) -> Option<Self> {
let (material, pattern) = (qualified(material), qualified(pattern));
let mut texture = registry.patterns.get(&pattern)?.clone();
let mut palette = Some(registry.materials.get(&material)?.clone());
if let Some(o) = overrides.iter().find(|o| o.matches(&material, &pattern)) {
if let Some(t) = &o.texture {
texture.clone_from(t);
}
palette.clone_from(&o.palette);
}
let (namespace, path) = texture.split_once(':')?;
Some(Self {
texture: format!("{namespace}:trims/entity/{layer}/{path}"),
palette,
})
}
/// The atlas sprite name the generated texture is stored under.
pub fn sprite(&self) -> String {
format!(
"horizon/trim/{}/{}",
self.texture,
self.palette.as_deref().unwrap_or("-")
)
}
/// `PalettedTextureManager.prepareSlot`: the texture with its base
/// palette's colours swapped for the target palette's (`PaletteMapping`;
/// unchanged when a palette is missing or the sizes differ). None when
/// the texture itself is missing.
pub fn generate(&self, root: &Path) -> Option<Image> {
let path = asset_path(root, "textures", &self.texture, ".png");
let (w, h, pixels) = crate::hud::decode(&path).ok()?;
let Some(palette) = &self.palette else {
return Some((w, h, pixels));
};
let base = fs::read(path.with_extension("png.mcmeta"))
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|m| {
m.get("palette")?
.get("base_palette")?
.as_str()
.map(str::to_owned)
});
let load = |id: &str| {
crate::hud::decode(&asset_path(root, "textures/palettes", id, ".png"))
.ok()
.map(|(_, _, p)| argb(&p))
};
let mut mapped = argb(&pixels);
if let (Some(key), Some(target)) = (base.as_deref().and_then(load), load(palette)) {
// Different sizes: PaletteMapping.NONE, the texture as it is.
let _ = apply_palette(&mut mapped, &key, &target);
}
let out = mapped
.iter()
.flat_map(|p| {
let [a, r, g, b] = p.to_be_bytes();
[r, g, b, a]
})
.collect();
Some((w, h, out))
}
}
fn argb(rgba: &[u8]) -> Vec<u32> {
rgba.as_chunks::<4>()
.0
.iter()
.map(|p| u32::from_be_bytes([p[3], p[0], p[1], p[2]]))
.collect()
}
/// The largest trim texture of the resources (the size of the atlas's trim
/// slots), at least vanilla's 64x32.
pub fn slot_size(root: &Path) -> (u32, u32) {
let mut size = (64, 32);
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
return size;
};
for ns in namespaces.flatten() {
let Ok(layers) = fs::read_dir(ns.path().join("textures/trims/entity")) else {
continue;
};
for layer in layers.flatten() {
for f in fs::read_dir(layer.path()).into_iter().flatten().flatten() {
let path = f.path();
if path.extension().is_some_and(|e| e == "png")
&& let Some((w, h)) = png_size(&path)
{
size = (size.0.max(w.min(512)), size.1.max(h.min(512)));
}
}
}
}
size
}
/// The width and height in a PNG's IHDR chunk.
fn png_size(path: &Path) -> Option<(u32, u32)> {
use std::io::Read;
let mut head = [0u8; 24];
fs::File::open(path).ok()?.read_exact(&mut head).ok()?;
let be = |i: usize| u32::from_be_bytes(head[i..i + 4].try_into().unwrap());
(&head[12..16] == b"IHDR").then(|| (be(16), be(20)))
}
#[cfg(test)]
mod tests {
use super::*;
fn registry() -> TrimRegistry {
TrimRegistry::of(
&[("minecraft:coast", "minecraft:coast")],
&[
("minecraft:iron", "minecraft:trim/iron"),
("minecraft:gold", "minecraft:trim/gold"),
],
)
}
#[test]
fn material_palette_and_layer_folder() {
let t = TrimTexture::resolve(
&registry(),
&[],
"minecraft:gold",
"minecraft:coast",
"humanoid_leggings",
)
.unwrap();
assert_eq!(t.texture, "minecraft:trims/entity/humanoid_leggings/coast");
assert_eq!(t.palette.as_deref(), Some("minecraft:trim/gold"));
// Unknown registry entries draw nothing.
assert!(
TrimTexture::resolve(
&registry(),
&[],
"minecraft:wood",
"minecraft:coast",
"humanoid"
)
.is_none()
);
}
#[test]
fn equipment_overrides_pick_the_darker_palette() {
let iron = serde_json::json!({"trim_overrides": [
{"palette": "minecraft:trim/iron_darker", "when": {"material": "minecraft:iron"}},
]});
let overrides = TrimOverride::list(&iron);
let resolve =
|m| TrimTexture::resolve(&registry(), &overrides, m, "coast", "humanoid").unwrap();
assert_eq!(
resolve("iron").palette.as_deref(),
Some("minecraft:trim/iron_darker")
);
assert_eq!(
resolve("gold").palette.as_deref(),
Some("minecraft:trim/gold")
);
// An override without a palette leaves the texture's own colours.
let plain = TrimOverride::list(&serde_json::json!({"trim_overrides": [
{"texture": "minecraft:other", "when": {"pattern": "minecraft:coast"}},
]}));
let t = TrimTexture::resolve(&registry(), &plain, "gold", "coast", "humanoid").unwrap();
assert_eq!(t.texture, "minecraft:trims/entity/humanoid/other");
assert_eq!(t.palette, None);
}
#[test]
fn vanilla_trims_are_recoloured() {
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../.local/horizon-client/26.3");
if !root.join("assets/minecraft/textures/trims").is_dir() {
return;
}
let registry = TrimRegistry::load(&root);
let t = TrimTexture::resolve(
&registry,
&[],
"minecraft:gold",
"minecraft:coast",
"humanoid",
)
.unwrap();
let (w, h, gold) = t.generate(&root).unwrap();
assert_eq!((w, h), (64, 32));
let plain = TrimTexture {
palette: None,
..t.clone()
};
let (_, _, grey) = plain.generate(&root).unwrap();
// Every opaque pixel of the grey pattern takes a gold palette colour.
let gold_palette = argb(
&crate::hud::decode(&root.join("assets/minecraft/textures/palettes/trim/gold.png"))
.unwrap()
.2,
);
let mut opaque = 0;
for (g, c) in grey.chunks(4).zip(gold.chunks(4)) {
if g[3] != 0 {
opaque += 1;
let c = u32::from_be_bytes([0xFF, c[0], c[1], c[2]]);
assert!(gold_palette.contains(&c), "{c:08x}");
}
}
assert!(opaque > 0);
assert_eq!(slot_size(&root), (64, 32));
}
}
+257 -72
View File
@@ -7,9 +7,40 @@ use crate::{
};
use std::{collections::HashMap, path::Path, sync::RwLock, time::Instant};
/// Nine-slice borders (left, top, right, bottom) from `.png.mcmeta` files.
static NINE_SLICE: RwLock<Option<HashMap<String, [u32; 4]>>> = RwLock::new(None);
/// `GuiSpriteScaling.NineSlice`: the sprite's size in GUI pixels, whatever
/// its texture's resolution, its borders (left, top, right, bottom) in the
/// same units, and whether the edges and centre stretch rather than tile.
#[derive(Clone, Copy, Debug, PartialEq)]
struct NineSlice {
width: u32,
height: u32,
border: [u32; 4],
stretch_inner: bool,
}
/// Nine-slice scaling from `.png.mcmeta` files.
static NINE_SLICE: RwLock<Option<HashMap<String, NineSlice>>> = RwLock::new(None);
/// A `gui.scaling` section of type `nine_slice`.
fn parse_nine_slice(v: &serde_json::Value) -> Option<NineSlice> {
let s = &v["gui"]["scaling"];
if s["type"] != "nine_slice" {
return None;
}
let size = |k: &str| s[k].as_u64().map(|n| n as u32);
let border = &s["border"];
let border = if let Some(n) = border.as_u64() {
[n as u32; 4]
} else {
let g = |k: &str| border[k].as_u64().unwrap_or(0) as u32;
[g("left"), g("top"), g("right"), g("bottom")]
};
Some(NineSlice {
width: size("width")?,
height: size("height")?,
border,
stretch_inner: s["stretch_inner"].as_bool().unwrap_or(false),
})
}
/// Read `gui.scaling` metadata for every GUI sprite under `root`.
pub fn load_metadata(root: &Path) {
let mut map = HashMap::new();
@@ -32,47 +63,48 @@ pub fn load_metadata(root: &Path) {
let Ok(text) = std::fs::read_to_string(&p) else {
continue;
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) else {
let Some(nine_slice) = serde_json::from_str(&text)
.ok()
.as_ref()
.and_then(parse_nine_slice)
else {
continue;
};
let s = &v["gui"]["scaling"];
if s["type"] != "nine_slice" {
continue;
}
let border = &s["border"];
let b = if let Some(n) = border.as_u64() {
[n as u32; 4]
} else {
let g = |k: &str| border[k].as_u64().unwrap_or(0) as u32;
[g("left"), g("top"), g("right"), g("bottom")]
};
let key = Path::new(stripped)
.strip_prefix(&base)
.map(|r| r.to_string_lossy().replace('\\', "/"))
.unwrap_or_default();
map.insert(key, b);
map.insert(key, nine_slice);
}
}
*NINE_SLICE.write().unwrap_or_else(|e| e.into_inner()) = Some(map);
}
fn border(sprite: &str) -> [u32; 4] {
/// The sprite's nine-slice metadata; without any (no resources loaded), its
/// GUI size and the widgets' usual borders.
fn scaling(sprite: &str, r: &Rect) -> NineSlice {
NINE_SLICE
.read()
.unwrap_or_else(|e| e.into_inner())
.as_ref()
.and_then(|m| m.get(sprite).copied())
.unwrap_or(match sprite {
"gui/sprites/widget/button"
| "gui/sprites/widget/button_highlighted"
| "gui/sprites/widget/button_disabled" => [3; 4],
"gui/sprites/widget/slider_handle" | "gui/sprites/widget/slider_handle_highlighted" => {
[2, 2, 2, 3]
}
_ => [1; 4],
.unwrap_or(NineSlice {
width: r.w,
height: r.h,
border: match sprite {
"gui/sprites/widget/button"
| "gui/sprites/widget/button_highlighted"
| "gui/sprites/widget/button_disabled" => [3; 4],
"gui/sprites/widget/slider_handle"
| "gui/sprites/widget/slider_handle_highlighted" => [2, 2, 2, 3],
_ => [1; 4],
},
stretch_inner: false,
})
}
fn sub(r: Rect, x: f32, y: f32, w: f32, h: f32) -> Rect {
let (du, dv) = ((r.u1 - r.u0) / r.w as f32, (r.v1 - r.v0) / r.h as f32);
/// The region `[x, y, w, h]` of a sprite `size` GUI pixels large, in its
/// own units (`TextureAtlasSprite.getU(x / width)`).
fn sub(r: Rect, size: [f32; 2], [x, y, w, h]: [f32; 4]) -> Rect {
let (du, dv) = ((r.u1 - r.u0) / size[0], (r.v1 - r.v0) / size[1]);
Rect {
u0: r.u0 + x * du,
v0: r.v0 + y * dv,
@@ -82,8 +114,9 @@ fn sub(r: Rect, x: f32, y: f32, w: f32, h: f32) -> Rect {
h: h as u32,
}
}
/// Tile `src` (a sub-rectangle of a sprite, in its pixels) over a target area.
fn tile(batch: &mut HudBatch, r: Rect, src: [f32; 4], dst: [f32; 4], color: u32) {
/// `blitTiledSprite`: `src` (a region of a sprite `size` large, in its
/// units) repeated over `dst` from its top left, the last row and column cut.
fn tile(batch: &mut HudBatch, r: Rect, size: [f32; 2], src: [f32; 4], dst: [f32; 4], color: u32) {
let [sx, sy, sw, sh] = src;
let [dx, dy, dw, dh] = dst;
if sw <= 0.0 || sh <= 0.0 || dw <= 0.0 || dh <= 0.0 {
@@ -95,13 +128,16 @@ fn tile(batch: &mut HudBatch, r: Rect, src: [f32; 4], dst: [f32; 4], color: u32)
let mut x = 0.0;
while x < dw {
let w = sw.min(dw - x);
batch.rect_uv(dx + x, dy + y, w, h, sub(r, sx, sy, w, h), color);
batch.rect_uv(dx + x, dy + y, w, h, sub(r, size, [sx, sy, w, h]), color);
x += sw;
}
y += sh;
}
}
/// GuiGraphics.blitSprite with nine-slice scaling (inner parts tiled).
/// `GuiGraphicsExtractor.blitNineSlicedSprite`: the corners at their size,
/// the edges and centre tiled (or stretched with `stretch_inner`), all in
/// the sprite's `gui.scaling` units, so a high-resolution pack's sprite draws
/// as the default one does.
pub fn nine_slice(
batch: &mut HudBatch,
atlas: &HudAtlas,
@@ -112,47 +148,73 @@ pub fn nine_slice(
h: f32,
color: u32,
) {
let Some(r) = atlas.sprites.get(sprite).copied() else {
return;
if let Some(r) = atlas.sprites.get(sprite) {
draw_nine_slice(batch, *r, scaling(sprite, r), [x, y, w, h], color);
}
}
fn draw_nine_slice(
batch: &mut HudBatch,
r: Rect,
n: NineSlice,
[x, y, w, h]: [f32; 4],
color: u32,
) {
let size = [n.width as f32, n.height as f32];
let [sw, sh] = size;
let [l, t, rr, b] = n.border.map(|v| v as f32);
let (half_w, half_h) = ((w / 2.0).floor(), (h / 2.0).floor());
let (l, rr, t, b) = (l.min(half_w), rr.min(half_w), t.min(half_h), b.min(half_h));
// blitSprite(sprite, width, height, textureX, textureY, x, y, w, h).
let part = |batch: &mut HudBatch, tx: f32, ty: f32, dst: [f32; 4]| {
if dst[2] != 0.0 && dst[3] != 0.0 {
batch.rect_uv(
dst[0],
dst[1],
dst[2],
dst[3],
sub(r, size, [tx, ty, dst[2], dst[3]]),
color,
);
}
};
let [l, t, rr, b] = border(sprite).map(|v| v as f32);
let (sw, sh) = (r.w as f32, r.h as f32);
let l = l.min(w / 2.0);
let rr = rr.min(w / 2.0);
let t = t.min(h / 2.0);
let b = b.min(h / 2.0);
let (cw, ch) = (w - l - rr, h - t - b);
let (scw, sch) = (sw - l - rr, sh - t - b);
// Corners.
batch.rect_uv(x, y, l, t, sub(r, 0.0, 0.0, l, t), color);
batch.rect_uv(x + w - rr, y, rr, t, sub(r, sw - rr, 0.0, rr, t), color);
batch.rect_uv(x, y + h - b, l, b, sub(r, 0.0, sh - b, l, b), color);
batch.rect_uv(
x + w - rr,
y + h - b,
rr,
b,
sub(r, sw - rr, sh - b, rr, b),
color,
);
// Edges and centre.
tile(batch, r, [l, 0.0, scw, t], [x + l, y, cw, t], color);
tile(
batch,
r,
[l, sh - b, scw, b],
[x + l, y + h - b, cw, b],
color,
);
tile(batch, r, [0.0, t, l, sch], [x, y + t, l, ch], color);
tile(
batch,
r,
[sw - rr, t, rr, sch],
[x + w - rr, y + t, rr, ch],
color,
);
tile(batch, r, [l, t, scw, sch], [x + l, y + t, cw, ch], color);
// blitNineSliceInnerSegment.
let inner = |batch: &mut HudBatch, dst: [f32; 4], src: [f32; 4]| {
if dst[2] <= 0.0 || dst[3] <= 0.0 {
return;
}
if n.stretch_inner {
batch.rect_uv(dst[0], dst[1], dst[2], dst[3], sub(r, size, src), color);
} else {
tile(batch, r, size, src, dst, color);
}
};
if w == sw && h == sh {
part(batch, 0.0, 0.0, [x, y, w, h]);
return;
}
if h == sh {
part(batch, 0.0, 0.0, [x, y, l, h]);
inner(batch, [x + l, y, w - rr - l, h], [l, 0.0, sw - rr - l, sh]);
part(batch, sw - rr, 0.0, [x + w - rr, y, rr, h]);
return;
}
if w == sw {
part(batch, 0.0, 0.0, [x, y, w, t]);
inner(batch, [x, y + t, w, h - b - t], [0.0, t, sw, sh - b - t]);
part(batch, 0.0, sh - b, [x, y + h - b, w, b]);
return;
}
let (cw, ch) = (w - rr - l, h - b - t);
let (scw, sch) = (sw - rr - l, sh - b - t);
part(batch, 0.0, 0.0, [x, y, l, t]);
inner(batch, [x + l, y, cw, t], [l, 0.0, scw, t]);
part(batch, sw - rr, 0.0, [x + w - rr, y, rr, t]);
part(batch, 0.0, sh - b, [x, y + h - b, l, b]);
inner(batch, [x + l, y + h - b, cw, b], [l, sh - b, scw, b]);
part(batch, sw - rr, sh - b, [x + w - rr, y + h - b, rr, b]);
inner(batch, [x, y + t, l, ch], [0.0, t, l, sch]);
inner(batch, [x + l, y + t, cw, ch], [l, t, scw, sch]);
inner(batch, [x + w - rr, y + t, rr, ch], [sw - rr, t, rr, sch]);
}
/// Repeat a whole texture with each copy drawn `size` GUI pixels square.
pub fn tiled(
@@ -169,7 +231,6 @@ pub fn tiled(
let Some(r) = atlas.sprites.get(sprite).copied() else {
return;
};
let scale = r.w as f32 / size;
let mut yy = 0.0;
while yy < h {
let th = size.min(h - yy);
@@ -181,7 +242,7 @@ pub fn tiled(
y + yy,
tw,
th,
sub(r, 0.0, 0.0, tw * scale, th * scale),
sub(r, [size, size], [0.0, 0.0, tw, th]),
color,
);
xx += size;
@@ -1128,6 +1189,130 @@ impl EditBox {
#[cfg(test)]
mod tests {
use super::*;
/// The quads' destination rectangles and their UVs.
fn quads(batch: &HudBatch) -> Vec<([f32; 4], [f32; 4])> {
batch
.vertices
.chunks(6)
.map(|q| {
let (a, c) = (q[0], q[2]);
(
[a.pos[0], a.pos[1], c.pos[0], c.pos[1]],
[a.uv[0], a.uv[1], c.uv[0], c.uv[1]],
)
})
.collect()
}
/// Destination and UV rectangles equal to within f32 rounding.
fn assert_quad(actual: ([f32; 4], [f32; 4]), expected: ([f32; 4], [f32; 4])) {
let close = |a: [f32; 4], b: [f32; 4]| a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-5);
assert!(
close(actual.0, expected.0) && close(actual.1, expected.1),
"{actual:?} != {expected:?}"
);
}
/// A sprite filling the atlas, `w x h` texels as a 32x pack draws it.
fn sprite(w: u32, h: u32) -> Rect {
Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w,
h,
}
}
fn faithful(json: &str) -> NineSlice {
parse_nine_slice(&serde_json::from_str(json).unwrap()).unwrap()
}
#[test]
fn a_high_resolution_button_slices_in_its_gui_units() {
// Faithful 32x's widget/button: 400x40 texels, gui.scaling 200x20.
let n = faithful(
r#"{"gui":{"scaling":{"type":"nine_slice","width":200,"height":20,
"border":{"left":20,"top":4,"right":20,"bottom":4}}}}"#,
);
let mut batch = HudBatch::new([640, 480]);
draw_nine_slice(
&mut batch,
sprite(400, 40),
n,
[10.0, 5.0, 150.0, 20.0],
u32::MAX,
);
let q = quads(&batch);
// Full height: left border, the centre tiled, right border
// (blitNineSlicedSprite's height == nineSlice.height case).
assert_quad(q[0], ([10.0, 5.0, 30.0, 25.0], [0.0, 0.0, 0.1, 1.0]));
assert_quad(q[1], ([30.0, 5.0, 140.0, 25.0], [0.1, 0.0, 0.65, 1.0]));
assert_quad(q[2], ([140.0, 5.0, 160.0, 25.0], [0.9, 0.0, 1.0, 1.0]));
assert_eq!(q.len(), 3);
}
#[test]
fn nine_slice_corners_and_a_stretched_centre() {
// Faithful 32x's tooltip/frame: 200x200 texels, 100x100, border 12, stretched.
let n = faithful(
r#"{"gui":{"scaling":{"type":"nine_slice","width":100,"height":100,"border":12,"stretch_inner":true}}}"#,
);
let mut batch = HudBatch::new([640, 480]);
draw_nine_slice(
&mut batch,
sprite(200, 200),
n,
[0.0, 0.0, 300.0, 40.0],
u32::MAX,
);
let q = quads(&batch);
assert_eq!(q.len(), 9);
// Top left corner: 12 GUI pixels, 12/100 of the sprite.
assert_quad(q[0], ([0.0, 0.0, 12.0, 12.0], [0.0, 0.0, 0.12, 0.12]));
// The centre is one stretched quad (blitNineSliceInnerSegment, stretch_inner).
assert_quad(q[7], ([12.0, 12.0, 288.0, 28.0], [0.12, 0.12, 0.88, 0.88]));
// Bottom right corner.
assert_quad(q[5], ([288.0, 28.0, 300.0, 40.0], [0.88, 0.88, 1.0, 1.0]));
}
#[test]
fn a_sprite_drawn_at_its_own_size_is_one_quad() {
let n = NineSlice {
width: 50,
height: 50,
border: [10; 4],
stretch_inner: false,
};
let mut batch = HudBatch::new([640, 480]);
draw_nine_slice(
&mut batch,
sprite(100, 100),
n,
[4.0, 4.0, 50.0, 50.0],
u32::MAX,
);
let q = quads(&batch);
assert_eq!(q.len(), 1);
assert_quad(q[0], ([4.0, 4.0, 54.0, 54.0], [0.0, 0.0, 1.0, 1.0]));
}
#[test]
fn borders_shrink_to_half_the_drawn_size() {
let n = NineSlice {
width: 20,
height: 20,
border: [8; 4],
stretch_inner: true,
};
let mut batch = HudBatch::new([640, 480]);
// 9 wide: borders of floor(9 / 2) = 4, as vanilla's integer division.
draw_nine_slice(
&mut batch,
sprite(20, 20),
n,
[0.0, 0.0, 9.0, 30.0],
u32::MAX,
);
let q = quads(&batch);
assert_quad(q[0], ([0.0, 0.0, 4.0, 8.0], [0.0, 0.0, 0.2, 0.4]));
assert_quad(q[2], ([5.0, 0.0, 9.0, 8.0], [0.8, 0.0, 1.0, 0.4]));
}
#[test]
fn append_fields_highlight_the_whole_value_and_blink_their_cursor() {
use crate::hud::Blend;
+452
View File
@@ -0,0 +1,452 @@
//! `WinScreen`: the end poem, the credits and the post-credits text rolling up
//! under the logo, after the player leaves the End for the first time (with
//! the poem) or from Credits & Attribution (the credits alone). The texts come
//! from the resource root, so resource packs can replace them.
use crate::{
font::Font,
hud::{Blend, EndPortalFill, HudAtlas, HudBatch},
text::{self, Style, Text},
};
use horizon_core::random::{LegacyRandom, RandomSource};
use std::{collections::HashSet, path::Path};
/// `WinScreen.OBFUSCATE_TOKEN`: `§f§k§a§b` marks where the poem hides a name.
const OBFUSCATE_TOKEN: &str = "\u{a7}f\u{a7}k\u{a7}a\u{a7}b";
/// `WinScreen.NAME_PREFIX`.
const NAME_PREFIX: &str = " ";
/// `WinScreen.SECTION_HEADING`.
const SECTION_HEADING: &str = "============";
/// A key the screen reacts to (`keyPressed` / `keyReleased`).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Key {
/// `KeyEvent.isUp`: the up arrow scrolls back.
Up,
/// Space speeds the scroll up.
Space,
/// Each held Control key speeds it up more.
LeftControl,
RightControl,
}
impl Key {
pub fn from_code(code: winit::keyboard::KeyCode) -> Option<Self> {
use winit::keyboard::KeyCode;
match code {
KeyCode::ArrowUp => Some(Self::Up),
KeyCode::Space => Some(Self::Space),
KeyCode::ControlLeft => Some(Self::LeftControl),
KeyCode::ControlRight => Some(Self::RightControl),
_ => None,
}
}
}
pub struct WinScreen {
pub poem: bool,
lines: Vec<Text>,
centered: HashSet<usize>,
total_scroll_length: i32,
scroll: f32,
speedup_active: bool,
speedup_modifiers: HashSet<Key>,
unmodified_scroll_speed: f32,
scroll_speed: f32,
direction: i32,
}
impl WinScreen {
/// `WinScreen.<init>` and `init`: the poem (`texts/end.txt`) when `poem`,
/// the credits (`texts/credits.json`), then `texts/postcredits.txt`.
pub fn new(poem: bool, root: &Path, player: &str, font: &Font) -> Self {
let speed = if poem { 0.5 } else { 0.75 };
let mut screen = Self {
poem,
lines: Vec::new(),
centered: HashSet::new(),
total_scroll_length: 0,
scroll: 0.0,
speedup_active: false,
speedup_modifiers: HashSet::new(),
unmodified_scroll_speed: speed,
scroll_speed: speed,
direction: 1,
};
let texts = root.join("assets/minecraft/texts");
let read = |name: &str| match std::fs::read_to_string(texts.join(name)) {
Ok(s) => Some(s),
Err(e) => {
log::error!("Couldn't load credits from file minecraft:texts/{name}: {e}");
None
}
};
if poem && let Some(s) = read("end.txt") {
screen.add_poem_file(&s, player, font);
}
if let Some(s) = read("credits.json") {
match serde_json::from_str(&s) {
Ok(json) => screen.add_credits_file(&json),
Err(e) => {
log::error!("Couldn't load credits from file minecraft:texts/credits.json: {e}")
}
}
}
if poem && let Some(s) = read("postcredits.txt") {
screen.add_poem_file(&s, player, font);
}
screen.total_scroll_length = screen.lines.len() as i32 * 12;
screen
}
/// `addPoemFile`: each line with the player's name in, its obfuscated
/// spans turned into 3 to 6 scrambled characters, wrapped at 256 pixels
/// and followed by an empty line; 8 more empty lines at the end.
fn add_poem_file(&mut self, file: &str, player: &str, font: &Font) {
let mut random = LegacyRandom::new(8124371);
// BufferedReader.readLine: \n, \r or \r\n end a line; no empty last line.
let mut lines: Vec<&str> = file
.split('\n')
.map(|l| l.strip_suffix('\r').unwrap_or(l))
.collect();
if file.ends_with('\n') {
lines.pop();
}
for line in lines.iter().flat_map(|l| l.split('\r')) {
let mut line = line.replace("PLAYERNAME", player);
while let Some(pos) = line.find(OBFUSCATE_TOKEN) {
let n = random.next_int_bound(4).unwrap_or(0) as usize + 3;
line = format!(
"{}\u{a7}f\u{a7}k{}{}",
&line[..pos],
&"XXXXXXXX"[..n],
&line[pos + OBFUSCATE_TOKEN.len()..]
);
}
let text = Text::legacy(&line);
self.lines
.extend(font.split_lines(&text, 256.0).into_iter().map(|(t, _)| t));
self.add_empty_line();
}
for _ in 0..8 {
self.add_empty_line();
}
}
/// `addCreditsFile`: each section's heading, then each discipline's name
/// and its titles with the names under them.
fn add_credits_file(&mut self, json: &serde_json::Value) {
let colored = |s: &str, color: u32| Text::styled(s, Style::color(color));
let heading = || colored(SECTION_HEADING, text::WHITE);
for section in json.as_array().into_iter().flatten() {
let name = section["section"].as_str().unwrap_or_default();
self.add_credits_line(heading(), true);
self.add_credits_line(colored(name, text::YELLOW), true);
self.add_credits_line(heading(), true);
self.add_empty_line();
self.add_empty_line();
for discipline in section["disciplines"].as_array().into_iter().flatten() {
let name = discipline["discipline"].as_str().unwrap_or_default();
if !name.is_empty() {
self.add_credits_line(colored(name, text::YELLOW), true);
self.add_empty_line();
self.add_empty_line();
}
for title in discipline["titles"].as_array().into_iter().flatten() {
let name = title["title"].as_str().unwrap_or_default();
self.add_credits_line(colored(name, text::GRAY), false);
for name in title["names"].as_array().into_iter().flatten() {
let line = format!("{NAME_PREFIX}{}", name.as_str().unwrap_or_default());
self.add_credits_line(colored(&line, text::WHITE), false);
}
self.add_empty_line();
self.add_empty_line();
}
}
}
}
fn add_empty_line(&mut self) {
self.lines.push(Text::empty());
}
fn add_credits_line(&mut self, line: Text, centered: bool) {
if centered {
self.centered.insert(self.lines.len());
}
self.lines.push(line);
}
/// The lines in order and whether each is centred.
pub fn lines(&self) -> impl Iterator<Item = (&Text, bool)> {
self.lines
.iter()
.enumerate()
.map(|(i, l)| (l, self.centered.contains(&i)))
}
fn calculate_scroll_speed(&self) -> f32 {
let speed = if self.speedup_active {
self.unmodified_scroll_speed * (5.0 + self.speedup_modifiers.len() as f32 * 15.0)
} else {
self.unmodified_scroll_speed
};
speed * self.direction as f32
}
/// `keyPressed` / `keyReleased` (Escape closes the screen: `onClose`).
pub fn key(&mut self, key: Key, pressed: bool) {
match (key, pressed) {
(Key::Up, _) => self.direction = if pressed { -1 } else { 1 },
(Key::Space, _) => self.speedup_active = pressed,
(_, true) => {
self.speedup_modifiers.insert(key);
}
(_, false) => {
self.speedup_modifiers.remove(&key);
}
}
self.scroll_speed = self.calculate_scroll_speed();
}
/// `tick`: once the text has rolled past the top, the screen is done
/// (`respawn`, or back to the screen that opened it).
pub fn finished(&self, height: f32) -> bool {
let height = height as i32;
self.scroll > (self.total_scroll_length + height + height + 24) as f32
}
pub fn scroll(&self) -> f32 {
self.scroll
}
/// Stay `scroll` pixels in (until a key changes the speed), for
/// screenshots.
pub fn hold(&mut self, scroll: f32) {
self.scroll = scroll;
self.scroll_speed = 0.0;
}
/// `extractRenderState` after a frame of `delta_ticks` game ticks:
/// `extractBackground`, the vignette, then the logo and the lines
/// scrolled up. The poem's End portal background samples `end_portal`;
/// the credits draw the scrolling menu background (over the blurred
/// panorama on the title screen).
pub fn draw(
&mut self,
batch: &mut HudBatch,
atlas: &HudAtlas,
delta_ticks: f32,
end_portal: EndPortalFill,
) {
let (w, h) = (batch.width, batch.height);
if self.poem {
let start = batch.vertices.len();
batch.fill(atlas, 0.0, 0.0, w, h, u32::MAX);
batch.blend_since(start, Blend::EndPortal);
batch.end_portal = end_portal;
} else {
self.menu_background(batch, atlas, w, h);
}
// extractVignette: credits_vignette.png stretched over the screen.
if let Some(r) = atlas.sprites.get("misc/credits_vignette").copied() {
let start = batch.vertices.len();
batch.rect_uv(0.0, 0.0, w, h, r, u32::MAX);
batch.blend_since(start, Blend::Vignette);
}
self.scroll = (self.scroll + delta_ticks * self.scroll_speed).max(0.0);
let logo_x = (w / 2.0).floor() - 128.0;
let logo_y = h + 50.0;
// Everything below is drawn translated by `y_offs`.
let y_offs = -self.scroll;
// LogoRenderer(false) at full alpha.
let edition_x = (w / 2.0).floor() - 64.0;
batch.sprite_region(
atlas,
"gui/title/minecraft",
[logo_x, logo_y + y_offs, 256.0, 44.0],
256.0,
64.0,
);
batch.sprite_region(
atlas,
crate::title_edition::SPRITE,
[edition_x, logo_y + 44.0 - 7.0 + y_offs, 128.0, 14.0],
128.0,
16.0,
);
let mut y_pos = logo_y + 100.0;
let last = self.lines.len().saturating_sub(1);
for (i, line) in self.lines.iter().enumerate() {
let mut shift = y_offs;
if i == last {
// The last line stops in the middle of the screen.
let diff = y_pos + y_offs - ((h as i32 / 2) as f32 - 6.0);
if diff < 0.0 {
shift -= diff;
}
}
if y_pos + y_offs + 12.0 + 8.0 > 0.0 && y_pos + y_offs < h {
let x = if self.centered.contains(&i) {
// GuiGraphicsExtractor.centeredText: x - width / 2.
logo_x + 128.0 - (atlas.font.width_text(line) as i32 / 2) as f32
} else {
logo_x
};
batch.text_styled(atlas, line, x, y_pos + shift, u32::MAX, true);
}
y_pos += 12.0;
}
}
/// `extractMenuBackground`: `menu_background.png` tiled at 32 pixels,
/// scrolled by half the text's scroll.
fn menu_background(&self, batch: &mut HudBatch, atlas: &HudAtlas, w: f32, h: f32) {
let v = (self.scroll * 0.5).rem_euclid(32.0);
let start = batch.vertices.len();
crate::ui::tiled(
batch,
atlas,
"gui/menu_background",
0.0,
-v,
w,
h + 32.0,
32.0,
u32::MAX,
);
batch.clip_since(start, 0.0, 0.0, w, h);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A resource root with only the credits texts: every glyph is the
/// missing one, 6 pixels wide.
fn screen(poem: bool, end: &str, credits: &str, post: &str) -> WinScreen {
let root = std::env::temp_dir().join(format!(
"horizon-win-screen-{}-{poem}-{}",
std::process::id(),
end.len()
));
let texts = root.join("assets/minecraft/texts");
std::fs::create_dir_all(&texts).unwrap();
std::fs::write(texts.join("end.txt"), end).unwrap();
std::fs::write(texts.join("credits.json"), credits).unwrap();
std::fs::write(texts.join("postcredits.txt"), post).unwrap();
let atlas = HudAtlas::fonts(&root).unwrap();
let screen = WinScreen::new(poem, &root, "Steve", &atlas.font);
let _ = std::fs::remove_dir_all(&root);
screen
}
const CREDITS: &str = r#"[{"section":"Studio","disciplines":[
{"discipline":"","titles":[{"title":"Head","names":["Ada","Bo"]}]},
{"discipline":"Art","titles":[{"title":"Artist","names":[]}]}]}]"#;
#[test]
fn the_poem_names_the_player_and_scrambles_its_hidden_names() {
let s = screen(
true,
"\u{a7}3Hello PLAYERNAME.\r\n\n\u{a7}2\u{a7}f\u{a7}k\u{a7}a\u{a7}bkey\n",
"[]",
"End.",
);
let lines: Vec<(String, bool)> = s.lines().map(|(t, c)| (t.plain(), c)).collect();
// Each line then an empty one; the empty line adds no text line; 8
// empty lines after each file.
let mut want = vec!["Hello Steve.", ""];
want.push("");
// LegacyRandom(8124371).nextInt(4) + 3 scrambled characters.
let n = LegacyRandom::new(8124371).next_int_bound(4).unwrap() as usize + 3;
let hidden = format!("{}key", &"XXXXXXXX"[..n]);
want.extend([hidden.as_str(), ""]);
want.extend([""; 8]);
want.extend(["End.", ""]);
want.extend([""; 8]);
assert_eq!(
lines,
want.iter()
.map(|l| (l.to_string(), false))
.collect::<Vec<_>>()
);
let hidden_line = s.lines().nth(3).unwrap().0;
let styles: Vec<_> = hidden_line.chars().map(|(_, s)| s.obfuscated).collect();
assert!(styles[..n].iter().all(|o| *o == Some(true)));
assert_eq!(s.total_scroll_length, lines.len() as i32 * 12);
}
#[test]
fn long_poem_lines_wrap_at_256_pixels() {
// 6-pixel glyphs: 42 fit a line, and the space at each break goes.
let line = vec!["a"; 50].join(" ");
let s = screen(true, &line, "[]", "");
let lines: Vec<String> = s.lines().take(4).map(|(t, _)| t.plain()).collect();
assert_eq!(
lines.iter().map(|l| l.chars().count()).collect::<Vec<_>>(),
[41, 41, 15, 0]
);
assert!(lines.iter().all(|l| !l.starts_with(' ')));
}
#[test]
fn credits_centre_the_headings_and_list_the_names() {
let s = screen(false, "ignored", CREDITS, "ignored");
let lines: Vec<(String, bool)> = s.lines().map(|(t, c)| (t.plain(), c)).collect();
let l = |s: &str, c| (s.to_string(), c);
let names = |n: &str| l(&format!("{NAME_PREFIX}{n}"), false);
assert_eq!(
lines,
vec![
l(SECTION_HEADING, true),
l("Studio", true),
l(SECTION_HEADING, true),
l("", false),
l("", false),
l("Head", false),
names("Ada"),
names("Bo"),
l("", false),
l("", false),
l("Art", true),
l("", false),
l("", false),
l("Artist", false),
l("", false),
l("", false),
]
);
let color = |i: usize| s.lines().nth(i).unwrap().0.chars().next().unwrap().1.color;
assert_eq!(
[color(0), color(1), color(5), color(6), color(10)],
[
Some(text::WHITE),
Some(text::YELLOW),
Some(text::GRAY),
Some(text::WHITE),
Some(text::YELLOW)
]
);
}
#[test]
fn keys_set_the_scroll_speed_and_direction() {
let mut s = screen(false, "", CREDITS, "");
assert_eq!(s.scroll_speed, 0.75);
s.key(Key::Space, true);
assert_eq!(s.scroll_speed, 0.75 * 5.0);
s.key(Key::LeftControl, true);
s.key(Key::RightControl, true);
assert_eq!(s.scroll_speed, 0.75 * 35.0);
s.key(Key::Up, true);
assert_eq!(s.scroll_speed, -0.75 * 35.0);
s.key(Key::Space, false);
assert_eq!(s.scroll_speed, -0.75);
s.key(Key::Up, false);
s.key(Key::LeftControl, false);
assert_eq!(s.scroll_speed, 0.75);
// The poem rolls at 0.5 pixels a tick.
assert_eq!(screen(true, "", CREDITS, "").scroll_speed, 0.5);
}
#[test]
fn the_screen_ends_once_everything_rolled_past() {
let mut s = screen(false, "", CREDITS, "");
let atlas = HudAtlas::fonts(Path::new("/nonexistent")).unwrap();
let mut batch = HudBatch::new([640, 480]);
let h = batch.height;
let end = (s.total_scroll_length as f32 + 2.0 * h + 24.0) / 0.75;
s.draw(&mut batch, &atlas, end - 1.0, EndPortalFill::default());
assert!(!s.finished(h));
// Scrolling back never goes above the start.
s.key(Key::Up, true);
s.draw(&mut batch, &atlas, end * 2.0, EndPortalFill::default());
assert_eq!(s.scroll(), 0.0);
s.key(Key::Up, false);
s.draw(&mut batch, &atlas, end + 1.0, EndPortalFill::default());
assert!(s.finished(h));
}
}
+3
View File
@@ -430,9 +430,12 @@ fn guests_see_each_other_and_the_world_entities() {
velocity: [0.0; 3],
data: 0,
meta: Vec::new(),
defaults: Vec::new(),
equipment: Vec::new(),
attributes: Vec::new(),
precise: false,
needs_sync: false,
leash: None,
};
let first = thread::spawn(move || {
let mut bot = connect(port, "Alex", false)?;
Loaded 100 of 183 files, more files were not shown because too many files have changed in this diff. Show more