Compare commits

..
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 a7bb2259d6 Renderer: fall back when an adapter can't present to the window (#13493)
On a hybrid laptop under Wayland, request_adapter(HighPerformance) returns
the NVIDIA GPU, whose Surface::configure then fails with "Invalid surface";
wgpu's default handler panicked outside the device search.

Surface setup is now part of open_backend: an adapter whose surface offers
no format is skipped before its device opens, and configure runs in error
scopes (Renderer::configure). On either failure the backend's other
adapters are enumerated and the best one that presents is tried
(pick_adapter: HighPerformance order, never one already tried, software
only on the last try); with none, the search goes on to the next backend.
One warning line names the skipped adapter and why. The normal path opens
one device and configures once, as before.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 04:10:36 +01:00
Ludovic Marie a7df9ec019 Merge pull request #6808 from horizon-rust/perf/entities-stress
🤖 Agent: stress scenes for perf_bench (Horizon and vanilla); Horizon beats vanilla x2.2 (armor stands) to x10.8 (signs) on the M4 Pro; Python tests 607 ok, the bench mod compiles against 26.3.
2026-11-02 07:15:49 +01:00
Ludovic Marie 76aa871c1e Merge remote-tracking branch 'origin/main' into perf/entities-stress
# Conflicts:
#	mods/horizon/src/bench/java/dev/horizonlab/bench/VanillaBench.java
#	tools/perf_bench.py
2026-11-02 07:10:57 +01:00
Ludovic Marie 3a264943c7 Merge pull request #6672 from horizon-rust/perf/mesh-tint-uniform
Mesher: one-biome sections skip the tint blend
2026-11-02 06:43:12 +01:00
Ludovic Marie 5afa1de5f7 Merge pull request #6744 from horizon-rust/perf/gen-coordinator
🤖 Agent: perf-gen-light verified this head: pr_ready --quick green, chunk_generation/decoration/server_world_gen/worldgen pass on playhorizon, generation order unchanged (differential test); coordinator CPU -30% at RD 32.
2026-11-02 06:21:15 +01:00
Ludovic Marie 40e044da77 Merge pull request #6757 from horizon-rust/perf/entity-gpu-skin
🤖 Agent: mobs posed on the GPU, byte-identical to the CPU path for every mob type; stress_mobs frame CPU 4.52 → 3.26 ms; verify passed.
2026-11-02 06:19:04 +01:00
Ludovic Marie e7275ddee3 Merge pull request #6756 from horizon-rust/perf/entity-skin-pass
🤖 Agent: renderer half of GPU posing, unused until #6757; shader output byte-identical to the CPU reference on a real adapter; verify passed.
2026-11-02 06:09:56 +01:00
Ludovic Marie f7b73ed5f6 Merge pull request #6755 from horizon-rust/perf/remote-mob-index
🤖 Agent: mob lookups by id go through a validated id → slot map; 3000 mobs: 5.5 ms → 0.0125 ms per tick of searching; verify passed.
2026-11-02 06:09:52 +01:00
Ludovic Marie 1c2ed6b613 Merge pull request #6535 from horizon-rust/perf/mesh-faces
Mesher: 15% less CPU per section again (face shortcut, bit-row VisGraph), identical meshes
2026-11-02 06:02:47 +01:00
Ludovic Marie c2d352e0a3 Merge pull request #6619 from horizon-rust/perf/bench-server-join
🤖 Agent: verified by perf-net-client: main merged in, pr_ready --quick on playhorizon all green, CI verify green, and the Mac runs in the body used this branch end to end (Horizon, vanilla and vanilla-mods clients on a local Horizon server and on a vanilla 26.3 server.jar, with and without 100 bots). Tooling only (skip-release). Merging.
2026-11-02 05:48:50 +01:00
Ludovic Marie 64c0291da2 Merge pull request #6436 from horizon-rust/perf/net-replay-bench
🤖 Agent: verified by perf-net-client: main merged in, pr_ready --quick on playhorizon all green (horizon-bots + horizon-protocol 173 tests), CI verify and parity green. Tooling only (skip-release). Merging.
2026-11-02 05:46:49 +01:00
Ludovic Marie 4f1ebf7deb Merge pull request #6754 from horizon-rust/fix/item-entity-render-distance
🤖 Agent: dropped items now use vanilla's 64 × 0.25-box × view-scale range (32 blocks at RD 16), not 64 blocks; verify passed.
2026-11-02 05:46:46 +01:00
Ludovic Marie f4ee603baf Merge remote-tracking branch 'origin/main' into perf/entities-stress 2026-11-02 05:46:29 +01:00
Ludovic Marie daf749bbcd Merge branch 'perf/entity-skin-pass' into perf/entity-gpu-skin 2026-11-02 05:45:12 +01:00
Ludovic MarieandClaude Opus 5.5 27877c639e Renderer: the pack uniforms test frame sends no posed entities
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:44:55 +01:00
Ludovic Marie dbb78b8a4c Merge remote-tracking branch 'origin/main' into perf/entity-skin-pass 2026-11-02 05:44:40 +01:00
Ludovic MarieandClaude Opus 5.5 c4f9b6fc7f tests: the remote motion fixture looks mobs up through MobIndex
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:44:22 +01:00
Ludovic MarieandClaude Opus 5.5 03d815532e Keep mobs.rs's modules in order
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:26:30 +01:00
Ludovic Marie e12185820a Merge pull request #6613 from horizon-rust/perf/mob-poses-no-alloc
🤖 Agent: poses keyed by Fx hash, vertex hash identical; fmt, clippy and mobs:: (339) pass on the main-merged head.
2026-11-02 05:14:28 +01:00
Ludovic MarieandClaude Opus 5.5 8f91c1534e Format the item render range
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 05:12:04 +01:00
Ludovic MarieandClaude Opus 5.5 00e0ecbdaf Pose mob models on the GPU
Every frame the CPU posed each mob's model parts and pushed 44-byte vertices
for every face (mobs::render::draw_normals): with 1000 mobs in view that was
most of the frame's CPU time. mobs/skin.rs now flattens each model once for
the renderer's store (gfx/entity_skin.rs, the previous commit) and, per
frame, walks the poses like draw_normals but sends one matrix pair per drawn
part and one job per contiguous run; the compute pass writes the vertices.
Culling uses the posed parts' bounds; a culled mob's jobs are taken back.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:55:54 +01:00
Ludovic Marie f31198cbf7 Merge pull request #6721 from horizon-rust/perf-jobs/streaming
Streaming: run on the shared worker pool
2026-11-02 04:55:16 +01:00
Ludovic Marie b82e2a4b35 Merge pull request #6719 from horizon-rust/perf-jobs/pool
🤖 Agent: verified: verify green; pr_ready --quick green (horizon-threads 16 tests, 602 Python tests); the pool has no user yet (skip-release). First of the job-system stack (#6721, #6722).
2026-11-02 04:42:43 +01:00
Ludovic Marie c9434646de Merge pull request #6725 from horizon-rust/perf/low-end-menu
🤖 Agent: verified: pr_ready green (990 render tests, clippy, rustfmt), new tests pass on llvmpipe, blurred menus 2.6 → 7.5 FPS on llvmpipe, GPUs unchanged. Merging.
2026-11-02 04:41:06 +01:00
Ludovic Marie 9784cf63fd Merge remote-tracking branch 'origin/main' into perf/net-replay-bench 2026-11-02 04:35:39 +01:00
Ludovic Marie 84ad92068f Merge remote-tracking branch 'origin/main' into perf/bench-server-join 2026-11-02 04:35:22 +01:00
Ludovic Marie 79a60dea88 Merge remote-tracking branch 'origin/main' into perf/remote-mob-index 2026-11-02 04:33:50 +01:00
Ludovic Marie 38ce16338c Merge remote-tracking branch 'origin/main' into fix/item-entity-render-distance 2026-11-02 04:33:30 +01:00
Ludovic Marie 2eb2bc1538 Merge remote-tracking branch 'origin/main' into perf/mob-poses-no-alloc 2026-11-02 04:33:12 +01:00
Ludovic Marie 2556a20c74 Merge pull request #6202 from JustKarkat/fix/collision-scan-budget
🤖 Agent: verify passes on 835f2b522; parity 171 passed on it merged into main 0454e97e3. The 5 failures aren't this change's. The budget only ever grows (max of the caller's 1,000,000 and the sweep's cells), so behaviour changes only where the old scan aborted. world_spawn#3 (seed -7565038591901794094, y 115 vs 108) searches with no_collision_or_liquid, not a movement, and its y follows chunk decoration order. sculk, first_person, server_player_targets (600 vs 599) and pack_screen are runner timing and environment. One cost to know about: scan_cells(sweep) adds a few multiplications to every movement, negligible next to the scan. Queued by pr-sweep-M.
2026-11-02 04:19:15 +01:00
Ludovic Marie cc2bc8fb84 Merge pull request #6503 from horizon-rust/perf/mesh-fill
Mesher: ~21% less CPU per section, identical meshes
2026-11-02 04:17:18 +01:00
Ludovic Marie e9eb640ac0 Merge pull request #6695 from horizon-rust/perf/recipes-read-once
🤖 Agent: recipe folder read once per load (json_folder), rebased on main after #6720. Verified: CI verify green on this head; recipe opens 28,882 → 8,172 at start (strace).
2026-11-02 04:13:14 +01:00
Ludovic MarieandClaude Opus 5.5 d5be28d945 Streaming: run on the shared worker pool
Every streamer (the client's, and one per level on the server thread)
started N-1 worker threads of its own: a single player world ran two to
four such pools at once. They now queue their jobs on the process's
pool (horizon_threads::jobs), sized from the workers setting before any
world opens. Saves and freed meshes go first, then jobs by column
distance, as before; per-worker generator state, face cache and mesh
scratch move into the streamer's per-worker slots.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:12:08 +01:00
Ludovic MarieandClaude Opus 5.5 0bb31c7d42 Recipes: drop the imports the shared folder read left unused
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:55:56 +01:00
Ludovic MarieandClaude Opus 5.5 bf1fe4344d Recipes: the start reads the recipe folder once, not once per table
The crafting tables, the stations, the recipe book and brewing each listed and
read every file under data/minecraft/recipe again: a start to the title opened
each recipe file 7-8 times (14,581 opens for 2,043 files). json_folder::read
lists and reads a folder once and shares the bytes with any reader while one
holds them; the rest loader and /reload hold the recipe folder across the
tables, so it is read once, and nothing is kept after. Each table still parses
its own copy, with the same order, conditions and errors as before. Brewing
alone (a server join) still reads only recipe/brewing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:55:56 +01:00
Ludovic Marie 57cc476ac8 Merge pull request #6720 from horizon-rust/fix/mod-api-generated-current
🤖 Agent: regenerated mod API output (main's xtask check was stale after #6581). Verified: xtask mod-api --check passes, CI verify green on this head.
2026-11-02 03:55:44 +01:00
Ludovic Marie e058605ee6 Merge pull request #6686 from Ragbie/fix/attack-swap-pick
🤖 Agent: queued at 0d75e2e51 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:55:40 +01:00
Ludovic Marie bc807a6da1 Merge pull request #6673 from JustKarkat/fix/elytra-wall-damage
🤖 Agent: queued at 0d8198d62 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:55:36 +01:00
Ludovic Marie b8f73142f9 Merge pull request #6671 from senjinthedragon/hdr-lighting-2
🤖 Agent: queued at 2de5ff4ef (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:54:02 +01:00
Ludovic Marie 00c25b5398 Merge pull request #6669 from senjinthedragon/hdr-fog-marks
🤖 Agent: queued at f5be69df7 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:57 +01:00
Ludovic Marie 3e382789c5 Merge pull request #6666 from xMelonekMaX/fix/server-forced-flight
🤖 Agent: queued at b440c53ce (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:52 +01:00
Ludovic Marie 68a348299e Merge pull request #6661 from TREYX77/feat/native-framerate-limit
🤖 Agent: queued at 57b834671 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:49 +01:00
Ludovic Marie 3e20d5c7e6 Merge pull request #6649 from HrafnaValkyrja/fix/portals-tridents-rockets
🤖 Agent: queued at 5651a6af8 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:44 +01:00
Ludovic Marie 86fb2c8f82 Merge pull request #6647 from HrafnaValkyrja/fix/blast-wind-remaining
🤖 Agent: queued at 4aa12eb27 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:39 +01:00
Ludovic Marie f970ed281d Merge pull request #6634 from Akirabane/fix/tnt-bubble-particles
🤖 Agent: queued at 194163c33 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:35 +01:00
Ludovic Marie ebc5806996 Merge pull request #6633 from xMelonekMaX/fix/book-menu-visibility
🤖 Agent: queued at 0d95220d4 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:31 +01:00
Ludovic Marie f6a8608beb Merge pull request #6632 from sirrayi/fix/mob-hurt-armor-parity
🤖 Agent: queued at 779ab7dd4 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:27 +01:00
Ludovic Marie 6cafca9435 Merge pull request #6630 from mariuslozo-boop/fix/text-and-icon-label
🤖 Agent: queued at dd428288d (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:22 +01:00
Ludovic Marie 0caab26bc4 Merge pull request #6629 from mariuslozo-boop/fix/finish-1927
🤖 Agent: queued at 78a634a19 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:18 +01:00
Ludovic Marie 31d5f62db1 Merge pull request #6628 from mariuslozo-boop/fix/finish-3684
🤖 Agent: queued at 3c0a4db87 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:14 +01:00
Ludovic Marie 35a64d25c2 Merge pull request #6617 from Qwerner88880/hooks-book-filters
🤖 Agent: queued at 621942429 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:09 +01:00
Ludovic Marie d8db492dca Merge pull request #6585 from MSedgeMC/fix/mount-seat-no-sweep
🤖 Agent: queued at 6d3917100 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:05 +01:00
Ludovic Marie b2bda56ed3 Merge pull request #6566 from MSedgeMC/fix/text-field-value
🤖 Agent: queued at 0cfd1d821 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:53:00 +01:00
Ludovic Marie e6f5a1ae7c Merge pull request #6639 from some-du6e/optifine/fusion-tiles
🤖 Agent: queued at 8a3f5f693 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:47:19 +01:00
Ludovic Marie cb14a7a1fb Merge pull request #6627 from some-du6e/optifine/emf-variables
🤖 Agent: queued at e4b2778ed (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:47:15 +01:00
Ludovic Marie dd74d6b563 Merge pull request #6618 from navinor/fix/5533-farming-fresh-sol-e66af682
🤖 Agent: queued at 873ccc1ec (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:47:11 +01:00
Ludovic Marie e3c831ab0d Merge pull request #6572 from some-du6e/optifine/emf-block-entities
🤖 Agent: queued at 55487cb93 (with main and 53 other PRs: fmt, CI verify green).
2026-11-02 03:47:07 +01:00
Ludovic MarieandClaude Opus 5.5 00fb064655 Mod API: regenerate generated.rs after #6581's doc line
main's xtask check (mod_api::tests::generated_output_is_current) fails: the
hooks catalog's native_state_block mention landed without the regenerated file.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:35:35 +01:00
Evrim CicekandClaude Opus 5.5 0d75e2e519 Attack swap: pass the clip closure by value (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:23:23 +03:00
Evrim CicekandClaude Opus 5.5 dfaa2864d5 Attack swap e2e: a tagged pig, the world's own mobs cleared first
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:18:28 +03:00
Evrim CicekandClaude Opus 5.5 d4f736d2bf Attack swap e2e: the min-reach pig hovers at eye height, fully inside the spear's minimum
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 04:14:39 +03:00
JustKarkat 0d8198d62d A glide's wall hit hurts by the speed the client flew
The server's copy of a guest never travels with the client's own
velocity: handleMovePlayer moves its body, and server_travel starts from
whatever delta movement the copy last had, a few hundredths while
gliding. LivingEntity.handleFallFlyingCollisions reads that as the speed
the glide lost (moveHorLength), so (moveHorLength - new) * 10 - 3 stayed
negative and flying into a wall never hurt.

Carry the tick's movement into the copy's delta movement before the
travel, as handleMovePlayer does, and guard the rule with a test: a glide
into a wall at 1.2 blocks a tick hurts by minecraft:fly_into_wall.

Fixes #6494
2026-11-01 19:10:37 -06:00
SenjinandClaude Opus 5.5 2de5ff4ef1 HDR: light itself above paper white
Surfaces at the strongest block and sky light rise above paper white, sky light by how a face
meets the sun, and the whole of it eases into the peak. Part of the frame's alpha carries it
to the last pass, where it amounts to vanilla's lightmap going past 1. Off while a shader
pack's post or material stage runs. Fog and water take it away as they should.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 02:01:39 +01:00
RatmirandClaude Opus 5.5 57b8346713 Format the framerate limit hook call
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:58:50 +01:00
Evrim CicekandClaude Opus 5.5 cbed0c07bd Attack swap: the item's pick stops on a block, and its range holds the hit
Review:
- getHitEntitiesAlong returns the block it clipped when no entity is in the
  attack range, and filterHitResult keeps a block within reach, so only a
  miss falls back to the plain pick (a mob inside a spear's minimum reach
  with the floor behind it is not the target).
- Minecraft.startAttack attacks an entity only inside the main-hand item's
  attack_range.
- handleKeybinds takes the number keys before swap hands, drop, inventory,
  chat and pick-block, which act on the key here: they select the queued
  keys first.
- A remote client sends the new slot in the tick only when that tick acts
  (ensureHasSentCarriedItem); else the next tick's sync does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:58:12 +03:00
some-du6eandClaude Opus 5.5 8a3f5f6939 Fusion tiles: keep the sheet's texture section, and forget its old pixels
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:57:09 -06:00
SenjinandClaude Opus 5.5 f5be69df70 HDR: fog hides light sources, animated sprites keep shining, the vignette keeps the lift
A light source's mark ignored fog: fully fogged, it stood out, through water since #6471.
A sprite that shines all over took a threshold from one frame of its animation, so other
frames' pixels dropped out. The vignette's darkest corners lost the lift at once.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:53:20 +01:00
RatmirandClaude Opus 5.5 fc111a8fc0 Add native_framerate_limit hook so native mods can lower the frame rate
A native mod (a Dynamic FPS port) needs to cap the frame rate while the
window is unfocused or minimised. Game::framerate_limit now asks
native_framerate_limit(focused, minimized, in_world, limit) after the
inactivity throttle and Battery Mode, and keeps the lower of its own limit
and the mod's; values below 1 are ignored. The API number comes from the
framerate_limit fragment.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:52:13 +01:00
HrafnaValkyrjaandClaude Opus 5.5 5651a6af8d Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 19:49:24 -05:00
Evrim CicekandClaude Opus 5.5 d073e1dc7e A number key and an attack in one tick swing the new item at the old one's target
Minecraft.tick picks the crosshair target (raycastHitResult: the held
item's attack_range, a spear's 4.5 blocks) before handleKeybinds takes the
number keys and then the attack clicks. Horizon changed the slot when the
key came in and picked the attacked mob with the plain 3 blocks at the
click, so a spear-to-mace attribute swap swung at nothing (#6376).

The number keys now wait for the tick: its pick (block and mob, with the
active item's attack_range) runs first, then the keys select, then the
clicks act; a remote client sends the new carried slot before them.
tools/attack_swap_e2e.py drives the client against a vanilla server.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 03:48:28 +03:00
Ludovic MarieandClaude Opus 5.5 50fdee8614 perf_bench report: runs without --server (tests' Namespace) have no server condition
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:48:06 +01:00
some-du6eandClaude Opus 5.5 351c82aded Fusion tiles: the sheet's own layer shows its first tile
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:43:36 -06:00
MSedgeMC 6d3917100d Forget the seat when the rider gets up
posed_rider kept the id of the last mount, so boarding the same boat, cart or animal again
compared equal and the camera swept to the seat instead of cutting to it. Not riding clears
it, so every mount is a new seat.
2026-11-02 00:43:30 +00:00
HrafnaValkyrja 78792c54ea Merge remote-tracking branch 'origin/main' into fix/portals-tridents-rockets 2026-11-01 19:38:05 -05:00
HrafnaValkyrjaandClaude Opus 5.5 dea101aafb Portals carry thrown tridents and firework rockets
Tridents and rockets ignored nether and End portals. They are now swept for
portal blocks, saved with the existing writers, re-spawned in the other
dimension and keep their portal cooldown on arrival. A rocket boosting a
player and a stuck trident that did not move stay.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 19:37:49 -05:00
HrafnaValkyrja 4aa12eb27f Merge remote-tracking branch 'origin/main' into fix/blast-wind-remaining 2026-11-01 19:36:02 -05:00
HrafnaValkyrjaandClaude Opus 5.5 d3c7a961e8 Wind bursts push firework rockets and thrown tridents
wind_knockback skipped both; vanilla's hurtEntities pushes every entity that
does not ignoreExplosion. Adds lan_e2e --check wind-remaining and lists
rockets and trident motion in simulation.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 19:35:43 -05:00
some-du6eandClaude Opus 5.5 64688576ee Fusion packs: cut connecting texture sheets into a sprite per tile
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:29:17 -06:00
Akirabane 194163c333 Merge origin/main into fix/tnt-bubble-particles 2026-11-02 01:27:23 +01:00
AkirabaneandClaude Sonnet 5.5 f579f440f4 Primed TNT at the top of a bubble column sends its surface particles
Entity.handleOnAboveBubbleColumn ends in sendBubbleColumnParticles for every
entity at the top of a column, and PrimedTnt does not override it. The TNT
was pushed by the column but showed nothing at its surface. PrimedTnt::tick
now reports the column blocks it is at the top of (bubble_column_push_over,
added for mobs and items in #6474) and tick_tnt draws them with
Game::bubble_column_particles, which also sends them to the LAN guests.

Fixes #6626

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-02 01:22:33 +01:00
sirrayiandDeepSeek Harness 779ab7dd4c fix(mobs): a hit that bypasses armour, or isn't a horse's, wears no body piece
#5317 was merged with three deviations from vanilla 26.3 in the `ArmorHurt`
path that its review had already named (Joansitoh's agent:
`LivingEntity.java:1887`, `:1907-1909`, and `doHurtEquipment`'s amount):

- the gate was `drops::head_is_body` minus Wolf, which also matches donkey,
  mule, skeleton and zombie horse, camel, llama, nautilus, happy ghast and
  sulfur cube. `hurtArmor` is overridden by `Horse` alone — the base one is
  empty (`LivingEntity.java:1887`), `Wolf` and `Player` have their own rules —
  so those species wore a body item vanilla never touches;
- nothing tested `#bypasses_armor`, so a fall, drowning, starvation, the void
  or a magic hit wore the piece too (`hurtArmor` is called inside that gate,
  `LivingEntity.java:1907-1909`);
- the amount was `dealt`, computed after the armour, toughness and
  enchantment reductions and after the invulnerability window's subtraction,
  where vanilla hands `hurtArmor` the damage `getDamageAfterArmorAbsorb`
  received: 8 raw damage is 8/4 = 2 durability, not 1.

`Horse.hurtArmor` also asks `doHurtEquipment` for `EquipmentSlot.BODY` alone,
so the four armour slots are not this path's to wear; `hurt_mob_armor` now
wears the body piece only, which drops the slot loop with it.

`doHurtEquipment` also starts from the item's `equippable` component
(`Equippable equippable = item.get(DataComponents.EQUIPPABLE)`), which
`wearable` did not check: `equip_damage_on_hurt` defaults to true when the
component is absent (`horizon-items/src/items.rs`), so a damageable item
carrying no `equippable` — a sword put in a horse's body slot by
`/item replace` — wore out.

Tests: three cases in `mobs::armor_hurt_tests` (a donkey with a body item, a
fall on a horse, the amount before the reduction); each one fails on the
merged code and passes here. The `equippable` gate needs a live `Game`, so it
is the one part without a unit test.

Co-Authored-By: DeepSeek Harness (deepseek-v4.1-flash) <[email protected]>
2026-11-02 00:20:51 +00:00
some-du6eandClaude Opus 5.5 e4b2778edb OptiFine entity models: the local player's model knows when it's in water
Its fluids were never set, so swimming read as crawling for its
animations.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:20:21 -06:00
Maksymilian AdamkiewiczandCodex 0d95220d49 fix: hide the underlying menu while a book is open
Draw the active book without the retained inventory/container screen or its
native screen render hook, and hide that menu's slots from the agent UI.
When the book closes, leave the pointer available to a retained menu.

Add a standalone OpenBook packet proxy and book_menu check to the vanilla
server e2e harness. The check covers exclusive book widgets, page navigation,
return to the retained menu and mouse capture. It requires no container-close
fix and introduces no inventory packet queue.

This recreates the previously stacked book correction on current main,
preserving the gameplay change while isolating its network test setup.

Co-Authored-By: Codex <[email protected]>
2026-11-02 01:16:57 +01:00
Maksymilian AdamkiewiczandCodex b440c53cee Merge origin/main into fix/server-forced-flight
Keep the optional no-default-features probe build on the new horizon-client-lib package.

Co-Authored-By: Codex <[email protected]>
2026-11-02 01:16:27 +01:00
Ludovic MarieandClaude Opus 5.5 9762e4f3db Mesher: one-biome sections skip the tint blend
When every quart cell the section's blend squares can zoom to holds one
biome, each block's blended tint is that biome's colour (the mean of equal
colours), so Tints::get returns it without the per-block zoom and the
(2r+1)^2 sum. Swamp grass, whose colour follows noise, keeps the blend.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:08:23 +01:00
Ludovic MarieandClaude Opus 5.5 c33c3acf9b Mesher: as_chunks for the opacity rows (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:08:23 +01:00
Ludovic MarieandClaude Opus 5.5 44b03616c1 Mesher: ~21% less CPU per section, same meshes
Measured with chunk_load_bench (seed 1234 village, 4,440 sections, 1,598
with geometry): 265 -> 210 us of CPU per section with geometry, p99 670 ->
530 us, with the same vertices and visibility (checksum afdcd05aec013a23).

- Padded::fill copies the section row by row and reads each border run
  from a section found once (PalettedContainer::get_run), rather than a
  chunk, section and palette lookup per cell.
- Hidden blocks (all six neighbours isSolidRender) are found from an
  opacity bit row per padded row, read from a compact per-state table,
  instead of six lookups into the per-state Info.
- Biome tints keep each cell's colour once computed (a cell is in the blend
  square of up to 25 columns, and swamp grass samples noise), and mix the
  biome zoom's corner jitter once per quart cell (BiomeZoom::corner_jitter
  and select_corner, as generation already does).
- sides::sort sorts one integer key per quad (bucket, plane, index), which
  orders exactly as the stable sort did, in buffers each thread keeps; the
  sorted copy no longer replaces the mesh's recycled buffer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:08:23 +01:00
some-du6eandClaude Opus 5.5 1d20a9db1d OptiFine entity models: animations read blocking, crawling, swimming, held items and more
- is_blocking, is_using_item: the synced living entity flags (and a player's
  blocking item), as the client keeps them
- is_swimming, is_crawling: the swimming pose (a player's or mannequin's
  avatar pose, another mob's DATA_POSE, a drowned's swimming flag), crawling
  out of water
- is_holding_item_right/left: the held items on each arm, by the main arm
- is_swinging_right_arm/left_arm: the current swing's arm, the main arm when
  the swing names no hand
- is_ridden: the rider SetPassengers names
- is_sitting: foxes, pandas and camels from their own flags
- anger_time, height_above_ground, fluid_depth_up and is_wet's rain stay as
  they were, with comments naming what the client lacks

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 18:00:51 -06:00
Ludovic MarieandClaude Opus 5.5 2b78d2638e perf_bench --server-bots N: a crowd of horizon-bots and a scene that flies over them
horizon-bots prints the first bot's spawn position, so the bench knows where
the crowd walks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 01:00:45 +01:00
huiandZCode AI agent (GLM-5.3) 6219424298 The recipe book shows a filter's swapped recipe unswapped
update_recipes applied the data's recipe filters (#5910) to its own
copy, and RecipeIndex to its index, so a removed recipe never reached
the book - but book_entries wrote from the network copy, which the
filters never touched: a replace_input or replace_output showed
unswapped in the book's displays, while the property sets and the
stonecutter carried the swapped recipe.

network_recipes now runs the filters once, over its files, before the
ingredient normalization - a swapped name resolves, and every copy
agrees on the swapped recipe. update_recipes parses the already
filtered copy without running them again, so one swap cannot feed a
later one twice.

Fixes #6595.

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

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:44:18 +01:00
some-du6eandClaude Opus 5.5 a2f0a5baa8 OptiFine entity models: let the chest arm auto-deref its shapes (clippy)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:41:20 -06:00
MariuslozoandClaude Sonnet 5.5 dd428288d1 A TextAndIcon button's label stays clear of its icon; tab labels stay inside their tab
SpriteIconButton.TextAndIcon.extractContents gives the label left = x + 2,
right = x + width - spriteWidth - 4 and centerX = x + width / 2, and
ActiveTextCollector.defaultScrollingHelper centres a label that fits on
Mth.clamp(centerX, left + lineWidth / 2, right - lineWidth / 2). Title's
Button::draw_label used x + 2 .. x + width - 2 for every button, so on the
first-launch Accessibility screen a label that fits (fil_ph "Settings sa
Aksesibilidad...", about 30 languages) ended under the icon and a wider one
(fr_ca, about 35 languages) slid under it.

ui::scrolling_text_around_at takes the centre; scrolling_text_at is the
default centre (left + right) / 2 and draws as before. Button::draw_label_at
passes the icon's room and the button's middle.

MenuTabBar.MenuTabButton.renderLabel hands the tab's label to
acceptScrollingWithDefaultCenter with a 1 px margin, its box 3 px lower on an
unselected tab, gray only on an inactive tab (WithInactiveMessage). The Create
World tabs drew it unclipped, so a label wider than its tab ran into the next
one; the Statistics tabs did too, drew every label at the selected tab's
height and grayed the active unselected ones. Both draw through
ui::menu_tab_label now.

The port ledger rows of acceptScrolling, RenderingTextCollector.acceptScrolling,
acceptScrollingWithDefaultCenter, defaultScrollingHelper,
extractScrollingStringOverContents, the slider's extractWidgetRenderState,
TextAndIcon.extractContents (it has a caller: the Accessibility screen) and
MenuTabButton.renderLabel say what is drawn now.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-02 00:40:40 +01:00
MariuslozoandClaude Opus 5.5 78a634a191 Localization: sign edit's Done, the AI Agent page, the newer graphics settings and the Extras page follow the language (#1927)
Vanilla screens already read the selected language (creative inventory with every tab, its
search, survival and hotbar tabs and the Destroy Item tooltip; pause, chat, death, crafting,
furnace, anvil, enchanting, brewing, trading, advancements, statistics, world select and
Create World were checked in de_de, pl_pl and ja_jp). What was left:

- Sign edit's Done button was a hard-coded "Done": now `gui.done` (also for its narration).
- The AI Agent page's four status lines were English sentences built in code: now
  `horizon.agent.*` keys, with the connection count and the time since the last command as
  arguments (`status_lines`, a function of the socket's state, so a test reaches it).
- Leaf Culling, Frame Generation, Shadow Cache, Screenshot Quality, Distant Plant Dither, the
  Culling header, the Synchronous/Asynchronous and Leaf Culling values, their descriptions and
  the controller binds' "Hotbar: Previous/Next Slot" were English keys that `en_us.json` did not
  have, so every language showed English. They are in the table now. The Entity Culling
  description's old key had drifted from the code; it is replaced. Translucent Batching, its
  description and the GPU reason that blocks it get their translations too.
- 17 Extras and Splitscreen keys added to en_us.json after de/pl/uk/fr were written had no
  translation there: added (Particles, Hide block-break particles, Smooth chat, Message
  indicator, Keep channels for UI sounds, Splitscreen).
- zh_cn gets the new keys too. es_es, ru_ru and tr_tr still lack about 130 keys each.

`lang::scoped` runs a closure with a language that only that thread sees and returns the keys
it asked for and the language did not have, so tests render in another language without
touching the process-wide one. The tests: every text of the three graphics pages is a key of
the table; the Horizon options pages in de_de and pl_pl; the agent page and the Done button in
de_de, pl_pl, fr_fr and en_us.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-02 00:40:16 +01:00
MariuslozoandClaude Opus 5.5 3c0a4db876 A player's attributes stay through a respawn and a change of level (#3684)
The server made a new body for a player from the defaults when they died and
respawned or went to another level, so /attribute @s minecraft:scale base set
0.1 was 1.0 again (box, eye height and reach as well), and so was every other
attribute a command or a datapack had set. Vanilla keeps the same ServerPlayer
through teleport, and ServerPlayer.restoreFrom copies the base values to the
body after a death (assignBaseValues). The old body's attributes now go to the
next one (with their permanent modifiers for a level change, base values only
after a death), and a player saved between two bodies writes them to the file.

restoreFrom then gives the respawned player its max health
(setHealth(getMaxHealth())): a max health base of 40 comes back with 40, not
with the fixed 20 the respawn set.

Also: a glide the client announced before the body's own tick used a
scale-1 box for that tick (Guest::dimensions); it takes the body's scale. The
port map's LivingEntity.getScale row still said there was no scale attribute.

Tests: the respawn health, the glide box, the carried attributes, a save
between bodies; lan_e2e check scale-body (host and guest: the Nether and back,
a death and respawn, the health after it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-02 00:39:35 +01:00
some-du6eandClaude Opus 5.5 c3911b7608 OptiFine entity models: a shared .jem paints only the models it reshapes
A conduit.jem that reshapes the eye alone no longer paints the shell,
cage and wind, whose vanilla geometry keeps its texture's layout. The
part-name test gives every entry a box of its own, so swapped names show.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:35:38 -06:00
MSedgeMC 0cfd1d821c Chat screen cases: long values with codes, scrolled and selected 2026-11-01 23:29:41 +00:00
some-du6e 5b47e1f45b Merge main into optifine/emf-block-entities
# Conflicts:
#	docs/resource-packs.md
2026-11-01 17:28:49 -06:00
Ludovic MarieandClaude Opus 5.5 912941a8f7 Streaming coordinator: decoration and generation scheduling cost less
While a world loads, Streaming::update ran on one thread and at render
distance 32 took most of it: DecorationOrder::blocked asked waits_for
(36 lookups) for 24 neighbours of every wanted chunk on every update, in a
SipHash map, and schedule_generation scanned every column of the area each
time a worker finished.
- Decoration ranks use the Fx hasher; waits_for answers are kept for the
  pass (nothing it reads changes in it but decorated, which updates them).
- GenerationScan keeps the sorted candidates of a full scan and takes the
  next batches from them while the ranges stay the same and no column left;
  a removal, a dropped proto chunk or a range change scans again.
The decoration and generation order are unchanged (differential test
against the full scan through jobs finishing, columns and protos leaving
and the centre moving).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-02 00:27:23 +01:00
MSedgeMC 0a0c2fd5f1 Take a seat instead of sweeping to it
Vanilla puts a rider on its seat with Entity.moveTo, which sets the old position as well, so
the camera cuts to the cushion, boat or cart. Horizon kept the position the player stood at
as the previous one, so the first ridden tick drew the camera flying from there to the seat.
2026-11-01 23:25:36 +00:00
some-du6eandClaude Opus 5.5 7a39319074 OptiFine entity models: ender, trapped and copper chests keep vanilla's shape without their own .jem
- Trapped and ender chests read only their own files (cem_model.txt lists them with no
  fallback); copper chests have no CEM name and always draw vanilla's chest.
- An entry whose name isn't OptiFine's for the model reaches the vanilla part of that name,
  as mobs do; it is skipped only when neither exists.
- Tests: every OptiFine part name of the block entity tables lands on its vanilla part,
  book.jem reaches the lectern's book, a copper chest draws vanilla's geometry and UVs beside
  a chest.jem, and only entries no model has a part for are warned about.
- jem_files' precedence test moves next to it in horizon-optifine; read_small is private.
- docs/resource-packs.md names both test modules.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:23:25 -06:00
Aaron WangandGPT-6.1 Sol 873ccc1ecf fix(mobs): let farmers tend registered mod crops
Recognize farmland and crop templates through their retained class chains and plant loaded tagged block items in inventory order. Add vanilla-oracle classification and real content-root regressions.

Co-Authored-By: GPT-6.1 Sol <[email protected]>
2026-11-01 18:22:33 -05:00
some-du6eandClaude Opus 5.5 5f75afdbd0 OptiFine entity models: the block entity test scene has no emissive overlays
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:10:21 -06:00
MSedgeMC d02c07cde6 A text field draws its value as typed, § codes included 2026-11-01 23:07:17 +00:00
Ludovic MarieandClaude Opus 5.5 b73e5b7c98 perf_bench --server horizon|vanilla: clients join a local dedicated server
Every client of the run (Horizon, vanilla, vanilla with the performance
mods) joins the same fresh local server instead of creating a world:
seed 1234, creative, offline, the bench player an operator so the scenes'
/tp and /time work. A new server world gets an unmeasured warm-up join, so
measured joins compare the clients, not the server's generation.
startup.join_ms is connect to loaded. The vanilla driver connects with
ConnectScreen.startConnecting when its script names a server.

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

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 22:48:34 +01:00
some-du6e 3711915577 Merge main into optifine/emf-block-entities
# Conflicts:
#	crates/horizon-client-render/src/block_entity_render.rs
#	crates/horizon-client/src/game/packs.rs
2026-11-01 15:03:10 -06:00
some-du6eandClaude Opus 5.5 89b6008f8b OptiFine entity models: packs' .jem files reshape chests, shulker boxes, banners and other block entities
- block_entity_cem.rs applies a pack's .jem to the block-entity models: chest(_left/_right),
  trapped_chest(_left/_right), ender_chest, shulker_box, banner / banner_base / banner_flag,
  head_*, bell, book / enchanting_book / lectern_book, conduit and decorated_pot, with
  OptiFine's part names mapped to the vanilla ones (knob -> lock, stand -> pole, slate -> flag...)
- Entries replace or join the vanilla part's boxes and take their scale; animations come later
- The .jem's texture paints its model when the atlas has it; trapped and ender chests borrow
  the chest's geometry without its texture, copper chests take the geometry only
- Closed-chest meshes are baked from the reshaped models, per chest model
- BlockEntityShapes follow pack reloads (built on the reload worker, swapped on apply); without
  .jem files the renderer keeps sharing vanilla's shapes
- The .jem discovery and reading move from mobs/cem.rs to horizon_optifine::cem::files

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:13:19 -06:00
Maksymilian AdamkiewiczandCodex 53ba4b5a0a fix: preserve server-forced flight without toggle permission
LocalPlayer.aiStep gates flight toggling with Abilities.mayfly; it does not
clear a server-supplied flying state when that permission is false. Stop
clearing flight on every movement tick so zero-speed server-forced flight
holds the player in place, matching vanilla 26.3.

Add a local-input regression and extend the resident movement parity harness
with independent flight flags, zero/default speeds and explicit flight end.

Validation: 39 movement tests pass; eight flight scenes match both vanilla
jars; player_travel and living_motion parity pass; focused Clippy passes;
the fast-profile game binary builds. Contributor confirmed the correction
on benchmark.ogulniega.com.

Full resident scene parity also exposes an unrelated attribute-cache mismatch
for negative fall damage, reproducible without movement/abilities operations.

Full pr_ready --quick is blocked by missing ALSA development files and an
existing Python block-entity fixture missing Entities.has_mods.

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:46:52 +01:00
JustKarkatandJustKarkat's AI agent 835f2b5222 Collision scans: a movement's budget covers its sweep
A movement scan walks every cell of the box the movement expands to, so a
diagonal move covers about N x N of them. The client passes a 1,000,000
candidate budget, which a legal `movement_speed` of 400 passes within a
second of running: the scan then ended in `VisitBudget` and the game
aborted with "collision scan candidate budget exhausted" (#6124).

Vanilla walks the same box with no counter at all, so the movement is slow
there, never fatal. The budget now only has to cover the cells the scan can
visit (the sweep, and the step's own box raised by max_step), which leaves
every ordinary movement identical and makes the pathological one stall
instead of exiting.

`scan_cells` shares the cursor arithmetic with `BlockCollisionScan::new`
rather than repeating it.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-11-01 12:07:41 -06:00
133 changed files with 9818 additions and 1342 deletions

No files matched your search

+60 -2
View File
@@ -14,7 +14,8 @@
//!
//! horizon-bots --server 127.0.0.1:25565 --bots 25 --behaviour mixed --seconds 360 --report bots.json
//!
//! It prints one JSON line a second (bots online, chunks received) and writes a summary with `--report`.
//! It prints one JSON line a second (bots online, chunks received), one with the first bot's spawn position, and
//! writes a summary with `--report`.
//! A bot moves only while it holds the chunk it stands in: vanilla's client stops ticking its player in a chunk
//! it hasn't received, so a player can't outrun the server's chunk sending, on any server.
//! Each bot also keeps the chunks it holds, as a client's chunk cache does, so the report tells chunks that
@@ -102,6 +103,8 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
to test what a server takes
--timer F cheat, to test an anticheat: tick F times as fast as vanilla's client (1.5: 30 Hz)
--report FILE a JSON summary at the end (- prints it after the per-second lines)
--capture FILE record the first bot's play packets (milliseconds since it joined, id, body)
for crates/horizon-protocol/examples/net_replay.rs
";
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -177,6 +180,8 @@ struct Options {
/// The `minecraft:brand` to send, if any.
brand: Option<String>,
report: Option<String>,
/// --capture: where the first bot's play packets go.
capture: Option<String>,
/// How many times as fast as vanilla's 20 Hz the bots tick (a timer cheat above 1).
timer: f64,
}
@@ -207,6 +212,7 @@ fn options() -> Result<Options, String> {
click: None,
name: "Bot".into(),
report: None,
capture: None,
brand: None,
timer: 1.0,
};
@@ -256,6 +262,7 @@ fn options() -> Result<Options, String> {
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--capture" => o.capture = Some(value),
"--brand" => o.brand = Some(value),
"--give" => {
for part in value.split(',') {
@@ -819,12 +826,53 @@ impl BatchSize {
}
}
/// `--capture`: one record per play packet, little-endian: u32 milliseconds since the
/// bot joined, i32 packet id, u32 body length, then the body (without the id).
struct Capture {
out: std::io::BufWriter<std::fs::File>,
start: Instant,
/// The second last flushed: the process may end before the reader thread does.
flushed: u32,
}
impl Capture {
fn create(path: &str) -> Option<Self> {
match std::fs::File::create(path) {
Ok(file) => Some(Self {
out: std::io::BufWriter::new(file),
start: Instant::now(),
flushed: 0,
}),
Err(e) => {
eprintln!("--capture {path}: {e}");
None
}
}
}
fn record(&mut self, id: i32, body: &[u8]) {
use std::io::Write;
let ms = self.start.elapsed().as_millis() as u32;
let mut head = [0u8; 12];
head[..4].copy_from_slice(&ms.to_le_bytes());
head[4..8].copy_from_slice(&id.to_le_bytes());
head[8..].copy_from_slice(&(body.len() as u32).to_le_bytes());
let _ = self
.out
.write_all(&head)
.and_then(|()| self.out.write_all(body));
if ms / 1000 != self.flushed {
self.flushed = ms / 1000;
let _ = self.out.flush();
}
}
}
/// Reads the play state: answers what a client must answer, counts chunks, records teleports.
fn read(
mut reader: horizon_protocol::connection::ConnReader,
writer: Arc<Mutex<ConnWriter>>,
bot: Arc<Bot>,
track: bool,
mut capture: Option<Capture>,
) {
use ids::play::clientbound as cb;
use ids::play::serverbound as sb;
@@ -848,6 +896,9 @@ fn read(
return;
}
};
if let Some(c) = &mut capture {
c.record(id, &body);
}
let mut r = Reader::new(&body);
let sent = match id {
cb::KEEP_ALIVE => match r.i64() {
@@ -1007,7 +1058,10 @@ fn run_bot(
{
let (writer, bot) = (writer.clone(), bot.clone());
let track = behaviour == Behaviour::Fight;
thread::spawn(move || read(reader, writer, bot, track));
let capture = (index == 0)
.then(|| o.capture.as_deref().and_then(Capture::create))
.flatten();
thread::spawn(move || read(reader, writer, bot, track, capture));
}
use ids::play::serverbound as sb;
// Wait for the spawn position.
@@ -1025,6 +1079,10 @@ fn run_bot(
};
bot.joined_after_ms
.store(asked.elapsed().as_millis() as u64, Ordering::SeqCst);
if index == 0 {
// Where the bots gather, for a client that comes to watch them (tools/perf_bench.py --server-bots).
println!("{}", json!({ "spawn": spawn }));
}
let send = |body: Vec<u8>| lock(&writer).send(&body).is_ok();
if !o.no_loaded {
send(raw(sb::PLAYER_LOADED, |_| {}));
+106 -2
View File
@@ -3,6 +3,7 @@
//! from `pack.mcmeta` `language` sections, and a process-wide current
//! language used by every screen.
use std::{
cell::RefCell,
collections::{BTreeMap, HashMap},
sync::{
Arc, RwLock,
@@ -34,6 +35,15 @@ pub struct Language {
pub const DEFAULT: &str = "en_us";
static CURRENT: RwLock<Option<Arc<Language>>> = RwLock::new(None);
/// A language that only the calling thread sees, and the keys it was asked for and did not have
/// (`scoped`).
struct Scope {
language: Arc<Language>,
missed: Vec<String>,
}
thread_local! {
static SCOPE: RefCell<Option<Scope>> = const { RefCell::new(None) };
}
/// The current language's `bidirectional`, read for every drawn string.
static BIDIRECTIONAL: AtomicBool = AtomicBool::new(false);
@@ -254,8 +264,50 @@ pub fn set(language: Language) {
*CURRENT.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(language));
}
pub fn current() -> Option<Arc<Language>> {
if let Some(language) = SCOPE.with(|s| s.borrow().as_ref().map(|s| s.language.clone())) {
return Some(language);
}
CURRENT.read().unwrap_or_else(|e| e.into_inner()).clone()
}
/// Runs `f` with `language` as the current language of this thread only, and returns what it
/// returned with the keys `get` and `get_or` looked up and `language` did not have, in the order
/// asked and without repeats. Nothing is set process-wide, so a test can draw a screen in another
/// language while the tests beside it read theirs. Scopes nest: the inner one wins, and the outer
/// one's misses do not include the inner one's. For tests: `bidirectional` stays the process-wide
/// language's.
pub fn scoped<R>(language: Language, f: impl FnOnce() -> R) -> (R, Vec<String>) {
/// Puts the outer scope back, also when `f` panics.
struct Restore(Option<Scope>);
impl Drop for Restore {
fn drop(&mut self) {
SCOPE.with(|s| *s.borrow_mut() = self.0.take());
}
}
let outer = SCOPE.with(|s| {
s.borrow_mut().replace(Scope {
language: Arc::new(language),
missed: Vec::new(),
})
});
let _restore = Restore(outer);
let result = f();
let missed = SCOPE.with(|s| {
s.borrow_mut()
.as_mut()
.map(|s| std::mem::take(&mut s.missed))
});
(result, missed.unwrap_or_default())
}
/// `key` was asked for in a `scoped` language that has no text for it.
fn missed(key: &str) {
SCOPE.with(|s| {
if let Some(scope) = s.borrow_mut().as_mut()
&& !scope.missed.iter().any(|m| m == key)
{
scope.missed.push(key.to_owned());
}
});
}
/// `ClientLanguage.defaultRightToLeft` of the current language.
pub fn bidirectional() -> bool {
BIDIRECTIONAL.load(Ordering::Relaxed)
@@ -267,13 +319,19 @@ pub fn code() -> String {
pub fn get(key: &str) -> String {
current()
.and_then(|l| l.get(key).map(str::to_owned))
.unwrap_or_else(|| key.to_owned())
.unwrap_or_else(|| {
missed(key);
key.to_owned()
})
}
/// The translation, or `fallback` when the key is missing.
pub fn get_or(key: &str, fallback: &str) -> String {
current()
.and_then(|l| l.get(key).map(str::to_owned))
.unwrap_or_else(|| fallback.to_owned())
.unwrap_or_else(|| {
missed(key);
fallback.to_owned()
})
}
pub fn has(key: &str) -> bool {
current().is_some_and(|l| l.get(key).is_some())
@@ -350,6 +408,52 @@ mod tests {
);
}
#[test]
fn a_scoped_language_is_the_calling_threads_and_reports_what_it_lacks() {
let empty = Mem(HashMap::new());
let outside = code();
let ((german, english_only, fallback, elsewhere), missed) =
scoped(Language::load(&[&empty], "de_de"), || {
(
get("horizon.osk.close"),
get("horizon.no_such_key"),
get_or("horizon.no_fallback", "English"),
// Another thread keeps the process-wide language.
std::thread::spawn(code).join().unwrap(),
)
});
assert_eq!(german, "Schließen");
assert_eq!(english_only, "horizon.no_such_key");
assert_eq!(fallback, "English");
assert_eq!(elsewhere, outside);
assert_eq!(missed, ["horizon.no_such_key", "horizon.no_fallback"]);
// The scope ends with its closure.
assert_eq!(code(), outside);
}
#[test]
fn scopes_nest_and_end_when_their_closure_panics() {
let empty = Mem(HashMap::new());
let outside = code();
let (inner, missed) = scoped(Language::load(&[&empty], "fr_fr"), || {
let (inner, inner_missed) = scoped(Language::load(&[&empty], "pl_pl"), || {
(code(), get("horizon.inner_miss"))
});
assert_eq!(inner_missed, ["horizon.inner_miss"]);
assert_eq!(code(), "fr_fr");
inner.0
});
assert_eq!(inner, "pl_pl");
assert!(missed.is_empty());
let panicked = std::panic::catch_unwind(|| {
scoped(Language::load(&[&Mem(HashMap::new())], "de_de"), || {
panic!("a test failed inside a scope")
})
});
assert!(panicked.is_err());
assert_eq!(code(), outside);
}
/// `crates/horizon-client/assets/horizon/lang`, where Horizon's own strings live.
fn bundled_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -51,6 +51,10 @@ impl InteractionInput {
pub fn press(&mut self, button: Button) {
self.clicks[button as usize] += 1;
}
/// Whether a click waits for the next tick.
pub fn has_clicks(&self) -> bool {
self.clicks.iter().any(|&c| c > 0)
}
pub fn tick(&mut self, world: &mut impl InteractionWorld, attack_down: bool, use_down: bool) {
let [attack, use_item, pick] = std::mem::take(&mut self.clicks);
@@ -0,0 +1,630 @@
//! OptiFine custom entity models over the block entities' models (`block_entity_render`'s
//! `MODELS`): `chest.jem`, `shulker_box.jem`, `banner.jem`, `bell.jem`, `head_zombie.jem`... (the
//! "Block entity name" table of OptiFine's `cem_model.txt`, and Entity Model Features' one-file
//! `banner`), found and read as the mobs' are ([`horizon_optifine::cem::files`]). Each entry
//! replaces or joins the boxes of the vanilla part it names and takes its `scale`; animations
//! don't run yet. A `.jem`'s texture paints the models it reshapes instead of vanilla's when the
//! atlas has it.
//!
//! An entry's part is OptiFine's name for it, else the vanilla part's own name. Trapped and
//! ender chests take only their own file, and copper chests (no CEM name) keep vanilla's chest.
//! Lectern and enchanting-table books take theirs, else `book`. Signs, hanging signs and beds are
//! block models here, so their files are not read; copper golem statues have no CEM name.
use crate::block_entity_render::ModelPart;
use horizon_optifine::cem::{
files::{atlas_name, jem_files, load_jem},
model::{Bone, EntityModel},
};
use std::{
collections::{BTreeMap, HashMap, HashSet},
path::Path,
};
/// One model the renderer draws, the CEM names that reshape it, best first, and OptiFine's
/// names for its vanilla parts.
struct Target {
key: &'static str,
/// The vanilla model it starts from: `key` itself, or the one a block the renderer tells
/// apart shares (a trapped chest's is the chest's).
base: &'static str,
names: &'static [&'static str],
parts: &'static [(&'static str, &'static str)],
}
const CHEST: &[(&str, &str)] = &[
("root", "root"),
("base", "bottom"),
("lid", "lid"),
("knob", "lock"),
];
const SHULKER: &[(&str, &str)] = &[("root", "root"), ("base", "base"), ("lid", "lid")];
const BANNER: &[(&str, &str)] = &[("stand", "pole"), ("top", "bar")];
const FLAG: &[(&str, &str)] = &[("slate", "flag")];
const HEAD: &[(&str, &str)] = &[("root", "root"), ("head", "head")];
const DRAGON: &[(&str, &str)] = &[("root", "root"), ("head", "head"), ("jaw", "jaw")];
const PIGLIN: &[(&str, &str)] = &[
("root", "root"),
("head", "head"),
("left_ear", "left_ear"),
("right_ear", "right_ear"),
];
const BELL: &[(&str, &str)] = &[
("root", "root"),
("body", "bell_body"),
("base", "bell_base"),
];
const BOOK: &[(&str, &str)] = &[
("root", "root"),
("cover_right", "right_lid"),
("cover_left", "left_lid"),
("pages_right", "right_pages"),
("pages_left", "left_pages"),
("flipping_page_right", "flip_page1"),
("flipping_page_left", "flip_page2"),
("book_spine", "seam"),
];
const POT_BASE: &[(&str, &str)] = &[("neck", "neck"), ("top", "top"), ("bottom", "bottom")];
const POT_SIDES: &[(&str, &str)] = &[
("front", "front"),
("back", "back"),
("left", "left"),
("right", "right"),
];
const fn model(
key: &'static str,
names: &'static [&'static str],
parts: &'static [(&'static str, &'static str)],
) -> Target {
Target {
key,
base: key,
names,
parts,
}
}
const fn copy(
key: &'static str,
base: &'static str,
names: &'static [&'static str],
parts: &'static [(&'static str, &'static str)],
) -> Target {
Target {
key,
base,
names,
parts,
}
}
/// In order: a copy comes after its base.
const TARGETS: &[Target] = &[
model("chest", &["chest"], CHEST),
model("double_chest_left", &["chest_left"], CHEST),
model("double_chest_right", &["chest_right"], CHEST),
copy("trapped_chest", "chest", &["trapped_chest"], CHEST),
copy(
"trapped_chest_left",
"double_chest_left",
&["trapped_chest_left"],
CHEST,
),
copy(
"trapped_chest_right",
"double_chest_right",
&["trapped_chest_right"],
CHEST,
),
copy("ender_chest", "chest", &["ender_chest"], CHEST),
model("shulker_box", &["shulker_box"], SHULKER),
model("standing_banner", &["banner_base", "banner"], BANNER),
model("wall_banner", &["banner_base", "banner"], BANNER),
model("standing_banner_flag", &["banner_flag", "banner"], FLAG),
model("wall_banner_flag", &["banner_flag", "banner"], FLAG),
model("skeleton_skull", &["head_skeleton"], HEAD),
model("wither_skeleton_skull", &["head_wither_skeleton"], HEAD),
model("zombie_head", &["head_zombie"], HEAD),
model("creeper_head", &["head_creeper"], HEAD),
model("dragon_skull", &["head_dragon"], DRAGON),
model("piglin_head", &["head_piglin"], PIGLIN),
model("player_head", &["head_player"], HEAD),
model("bell", &["bell"], BELL),
model("book", &["enchanting_book", "book"], BOOK),
copy("lectern_book", "book", &["lectern_book", "book"], BOOK),
model("conduit_shell", &["conduit"], &[("base", "shell")]),
model("conduit_cage", &["conduit"], &[("cage", "shell")]),
model("conduit_wind", &["conduit"], &[("wind", "wind")]),
model("conduit_eye", &["conduit"], &[("eye", "eye")]),
model("decorated_pot_base", &["decorated_pot"], POT_BASE),
model("decorated_pot_sides", &["decorated_pot"], POT_SIDES),
];
/// Every model, reshaped or vanilla's, and the atlas names of the `.jem` textures by model.
pub type Reshaped = (
HashMap<&'static str, ModelPart>,
HashMap<&'static str, String>,
);
/// The models the packs under `root` reshape, over `vanilla`'s; None when no block entity has
/// a `.jem` there.
pub fn reshape(root: &Path, vanilla: &HashMap<&'static str, ModelPart>) -> Option<Reshaped> {
let (reshaped, unused) = reshape_all(root, vanilla)?;
for (path, part) in unused {
log::warn!("Horizon: custom entity model {path}: no part {part}");
}
Some(reshaped)
}
/// [`reshape`]'s models, and each file's entries whose part none of the models it reshapes has.
fn reshape_all(
root: &Path,
vanilla: &HashMap<&'static str, ModelPart>,
) -> Option<(Reshaped, Vec<(String, String)>)> {
let files = jem_files(root);
let named = |t: &Target| t.names.iter().any(|n| files.contains_key(*n));
if !TARGETS.iter().any(named) {
return None;
}
let mut models = vanilla.clone();
let mut textures = HashMap::new();
let mut reshaped = HashSet::new();
// Each file parsed once; which of its entries found a part in some model.
let mut parsed: HashMap<&str, Option<EntityModel>> = HashMap::new();
let mut used: BTreeMap<&str, Vec<bool>> = BTreeMap::new();
for t in TARGETS {
let Some(model) = vanilla.get(t.base) else {
continue;
};
let found = t
.names
.iter()
.flat_map(|name| files.get(*name).into_iter().flatten())
.map(String::as_str)
.find(|&path| {
parsed
.entry(path)
.or_insert_with(|| load_jem(root, path))
.is_some()
});
let Some(path) = found else {
// A copy of a reshaped base that has no file of its own stays vanilla.
if t.key != t.base && reshaped.contains(t.base) {
models.insert(t.key, model.clone());
}
continue;
};
let Some(Some(jem)) = parsed.get(path) else {
continue;
};
let mut out = model.clone();
let hits = apply(&mut out, jem, t.parts);
// A file shared by several models (the conduit's, the pot's) paints only the models
// it reshapes: vanilla geometry keeps its own texture's layout.
let reshapes = hits.iter().any(|hit| *hit);
let marks = used
.entry(path)
.or_insert_with(|| vec![false; jem.parts.len()]);
for (mark, hit) in marks.iter_mut().zip(hits) {
*mark |= hit;
}
models.insert(t.key, out);
reshaped.insert(t.key);
if reshapes && let Some(texture) = jem.texture.as_deref().and_then(atlas_name) {
textures.insert(t.key, texture);
}
}
let mut unused = Vec::new();
for (path, marks) in &used {
let Some(Some(jem)) = parsed.get(path) else {
continue;
};
for (p, _) in jem.parts.iter().zip(marks).filter(|(_, hit)| !**hit) {
unused.push(((*path).to_owned(), p.part.clone()));
}
}
Some(((models, textures), unused))
}
/// `jem`'s entries on `model`, OptiFine's part names read through `parts`, else as the vanilla
/// part's own name: a replacing entry clears its part's boxes, and each joins the part as a child
/// `cem#<n>`. Which entries found their part.
fn apply(model: &mut ModelPart, jem: &EntityModel, parts: &[(&str, &str)]) -> Vec<bool> {
let mut count = 0;
let mut hits = Vec::with_capacity(jem.parts.len());
for p in &jem.parts {
let mapped = parts.iter().find(|(of, _)| *of == p.part).map(|&(_, v)| v);
let target = match mapped {
Some(vanilla) if find_mut(model, vanilla).is_some() => vanilla,
_ => p.part.as_str(),
};
let Some(node) = find_mut(model, target) else {
hits.push(false);
continue;
};
if !p.attach {
node.polygons.clear();
}
let (name, mut bone) = part(&p.bone, &mut count);
bone.pose[6..9].fill(p.scale);
node.children.push((name, bone));
hits.push(true);
}
hits
}
/// The model itself for `root`, else its first part named `name`, depth first.
fn find_mut<'a>(model: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
fn child<'a>(part: &'a mut ModelPart, name: &str) -> Option<&'a mut ModelPart> {
part.children.iter_mut().find_map(|(n, c)| {
if n.as_str() == name {
Some(c)
} else {
child(c, name)
}
})
}
if name == "root" {
return Some(model);
}
child(model, name)
}
/// A baked bone as a model part, named `cem#<n>`: unique in the model, so no vanilla pose
/// change by name reaches it.
fn part(bone: &Bone, count: &mut usize) -> (String, ModelPart) {
let name = format!("cem#{count}");
*count += 1;
let [x, y, z] = bone.pivot;
let [rx, ry, rz] = bone.rotation;
let out = ModelPart {
pose: [x, y, z, rx, ry, rz, 1.0, 1.0, 1.0],
polygons: bone.quads.iter().map(|q| q.vertices).collect(),
children: bone.children.iter().map(|c| part(c, count)).collect(),
};
(name, out)
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::{collections::BTreeSet, fs};
/// A part with one face and `children`.
pub(crate) fn plane(children: Vec<(&str, ModelPart)>) -> ModelPart {
ModelPart {
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
polygons: vec![[[0.0; 5]; 4]],
children: children
.into_iter()
.map(|(n, c)| (n.to_owned(), c))
.collect(),
}
}
pub(crate) fn chest() -> ModelPart {
let mut root = plane(vec![
("bottom", plane(vec![])),
("lid", plane(vec![])),
("lock", plane(vec![])),
]);
root.polygons.clear();
root
}
pub(crate) const BOX: &str =
r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
/// A fresh pack folder named for `test`, holding `files` under `assets/minecraft/`.
pub(crate) fn pack(test: &str, files: &[(&str, String)]) -> std::path::PathBuf {
let root =
std::env::temp_dir().join(format!("horizon-be-cem-{test}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
for (path, text) in files {
let path = root.join("assets/minecraft").join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
root
}
/// A `.jem` replacing each of `parts` with one box.
fn jem(parts: &[&str]) -> String {
let entries: Vec<_> = parts
.iter()
.map(|p| format!(r#"{{"part": "{p}", {BOX}}}"#))
.collect();
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
}
/// A `.jem` replacing each of `parts` with a box of its own: the `i`th is `i + 1` pixels
/// deep, so a part that took another entry's box shows.
fn jem_sized(parts: &[&str]) -> String {
let entries: Vec<_> = parts
.iter()
.enumerate()
.map(|(i, p)| {
let depth = i + 1;
format!(
r#"{{"part": "{p}", "boxes": [{{"coordinates": [0, 0, 0, 1, 1, {depth}], "textureOffset": [0, 0]}}]}}"#
)
})
.collect();
format!(r#"{{"models": [{}]}}"#, entries.join(", "))
}
/// How deep (z) the custom box under `model`'s part `name` is.
fn depth(model: &ModelPart, name: &str) -> f32 {
let part = model.child(name).unwrap();
let zs = part
.children
.iter()
.flat_map(|(_, c)| c.polygons.iter().flatten().map(|v| v[2]));
let (lo, hi) = zs.fold((f32::MAX, f32::MIN), |(lo, hi), z| (lo.min(z), hi.max(z)));
hi - lo
}
/// Whether `model`'s part `name` was replaced by one custom box: its own face gone, and one
/// child of a box's six faces.
fn replaced(model: &ModelPart, name: &str) -> bool {
let part = model.child(name).unwrap();
match part.children.as_slice() {
[(_, only)] => part.polygons.is_empty() && only.polygons.len() == 6,
_ => false,
}
}
#[test]
fn every_optifine_part_name_lands_on_its_vanilla_part() {
// File, OptiFine's part, the model it reshapes, vanilla's part there.
let cases = [
("chest", "base", "chest", "bottom"),
("chest", "lid", "chest", "lid"),
("chest", "knob", "chest", "lock"),
("chest_left", "base", "double_chest_left", "bottom"),
("chest_left", "lid", "double_chest_left", "lid"),
("chest_left", "knob", "double_chest_left", "lock"),
("chest_right", "base", "double_chest_right", "bottom"),
("chest_right", "lid", "double_chest_right", "lid"),
("chest_right", "knob", "double_chest_right", "lock"),
("trapped_chest", "base", "trapped_chest", "bottom"),
("trapped_chest", "knob", "trapped_chest", "lock"),
("trapped_chest_left", "base", "trapped_chest_left", "bottom"),
("trapped_chest_left", "knob", "trapped_chest_left", "lock"),
(
"trapped_chest_right",
"base",
"trapped_chest_right",
"bottom",
),
("trapped_chest_right", "knob", "trapped_chest_right", "lock"),
("ender_chest", "base", "ender_chest", "bottom"),
("ender_chest", "knob", "ender_chest", "lock"),
("shulker_box", "base", "shulker_box", "base"),
("shulker_box", "lid", "shulker_box", "lid"),
("banner", "stand", "standing_banner", "pole"),
("banner", "top", "standing_banner", "bar"),
("banner", "top", "wall_banner", "bar"),
("banner", "slate", "standing_banner_flag", "flag"),
("banner", "slate", "wall_banner_flag", "flag"),
("head_skeleton", "head", "skeleton_skull", "head"),
(
"head_wither_skeleton",
"head",
"wither_skeleton_skull",
"head",
),
("head_zombie", "head", "zombie_head", "head"),
("head_creeper", "head", "creeper_head", "head"),
("head_dragon", "head", "dragon_skull", "head"),
("head_dragon", "jaw", "dragon_skull", "jaw"),
("head_piglin", "head", "piglin_head", "head"),
("head_piglin", "left_ear", "piglin_head", "left_ear"),
("head_piglin", "right_ear", "piglin_head", "right_ear"),
("head_player", "head", "player_head", "head"),
("bell", "body", "bell", "bell_body"),
("bell", "base", "bell", "bell_base"),
("enchanting_book", "cover_right", "book", "right_lid"),
("enchanting_book", "cover_left", "book", "left_lid"),
("enchanting_book", "pages_right", "book", "right_pages"),
("enchanting_book", "pages_left", "book", "left_pages"),
(
"enchanting_book",
"flipping_page_right",
"book",
"flip_page1",
),
(
"enchanting_book",
"flipping_page_left",
"book",
"flip_page2",
),
("enchanting_book", "book_spine", "book", "seam"),
("lectern_book", "cover_right", "lectern_book", "right_lid"),
("lectern_book", "pages_left", "lectern_book", "left_pages"),
(
"lectern_book",
"flipping_page_right",
"lectern_book",
"flip_page1",
),
("lectern_book", "book_spine", "lectern_book", "seam"),
("conduit", "base", "conduit_shell", "shell"),
("conduit", "cage", "conduit_cage", "shell"),
("conduit", "wind", "conduit_wind", "wind"),
("conduit", "eye", "conduit_eye", "eye"),
("decorated_pot", "neck", "decorated_pot_base", "neck"),
("decorated_pot", "top", "decorated_pot_base", "top"),
("decorated_pot", "bottom", "decorated_pot_base", "bottom"),
("decorated_pot", "front", "decorated_pot_sides", "front"),
("decorated_pot", "back", "decorated_pot_sides", "back"),
("decorated_pot", "left", "decorated_pot_sides", "left"),
("decorated_pot", "right", "decorated_pot_sides", "right"),
];
// One file per name with each of its parts, and a stub of each model holding its parts
// (chest models all of theirs, so a misplaced entry shows).
let mut files: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
let mut trees: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for (file, part, key, vanilla) in cases {
files.entry(file).or_default().insert(part);
trees.entry(key).or_default().insert(vanilla);
}
for key in ["chest", "double_chest_left", "double_chest_right"] {
trees
.entry(key)
.or_default()
.extend(["bottom", "lid", "lock"]);
}
let files_by_name = files.clone();
let files: Vec<_> = files
.iter()
.map(|(file, parts)| {
let parts: Vec<_> = parts.iter().copied().collect();
(format!("optifine/cem/{file}.jem"), jem_sized(&parts))
})
.collect();
let files: Vec<_> = files.iter().map(|(p, t)| (p.as_str(), t.clone())).collect();
let root = pack("parts", &files);
let vanilla: HashMap<&'static str, ModelPart> = trees
.iter()
.map(|(key, parts)| {
let parts = parts.iter().map(|p| (*p, plane(vec![]))).collect();
(*key, plane(parts))
})
.collect();
let result = reshape_all(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let ((models, _), unused) = result.unwrap();
for (file, part, key, vanilla) in cases {
assert!(
replaced(&models[key], vanilla),
"{file}.jem {part}: {key} {vanilla}"
);
// The box is this entry's own: swapped names (left and right, body and base) show.
let own = files_by_name[file].iter().position(|p| *p == part).unwrap() + 1;
assert_eq!(
depth(&models[key], vanilla),
own as f32,
"{file}.jem {part}: {key} {vanilla}"
);
}
assert_eq!(unused, Vec::new());
}
#[test]
fn a_chest_jem_reshapes_the_chest_alone_and_warns_of_entries_without_a_part() {
let root = pack(
"chest",
&[(
"optifine/cem/chest.jem",
format!(
r#"{{"texture": "textures/entity/chest/fancy.png", "models": [
{{"part": "lid", {BOX}}},
{{"part": "knob", "attach": true, "scale": 2, {BOX}}},
{{"part": "bottom", {BOX}}},
{{"part": "tail", {BOX}}}]}}"#
),
)],
);
let vanilla: HashMap<_, _> = [("chest", chest()), ("double_chest_left", chest())].into();
let result = reshape_all(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let ((models, textures), unused) = result.unwrap();
let chest = &models["chest"];
// Replaced: the lid's own face goes and the custom box (6 faces) joins it.
assert!(replaced(chest, "lid"));
// `bottom` is no OptiFine name but vanilla's own: it reaches the part as well.
assert!(replaced(chest, "bottom"));
// Attached: the lock keeps its face beside the scaled box.
let lock = chest.child("lock").unwrap();
assert_eq!((lock.polygons.len(), lock.children.len()), (1, 1));
assert_eq!(lock.children[0].1.pose[6..], [2.0, 2.0, 2.0]);
// `tail` names no chest part: the one warning.
assert_eq!(
unused,
[(
"assets/minecraft/optifine/cem/chest.jem".to_owned(),
"tail".to_owned()
)]
);
// The double chest has no file, nor do trapped and ender chests: vanilla's own, and
// only the chest takes the texture.
for key in ["double_chest_left", "trapped_chest", "ender_chest"] {
let lid = models[key].child("lid").unwrap();
assert_eq!((lid.polygons.len(), lid.children.len()), (1, 0), "{key}");
}
assert_eq!(
textures,
HashMap::from([("chest", "entity/chest/fancy".to_owned())])
);
}
#[test]
fn banners_split_one_file_and_books_fall_back() {
let root = pack(
"banner",
&[
("optifine/cem/banner.jem", jem(&["stand", "top", "slate"])),
("emf/cem/enchanting_book.jem", jem(&["cover_left"])),
],
);
let banner = plane(vec![("pole", plane(vec![])), ("bar", plane(vec![]))]);
let wall = plane(vec![("bar", plane(vec![]))]);
let flag = plane(vec![("flag", plane(vec![]))]);
let book = plane(vec![("left_lid", plane(vec![]))]);
let vanilla: HashMap<_, _> = [
("standing_banner", banner),
("wall_banner", wall),
("wall_banner_flag", flag),
("book", book),
]
.into();
let result = reshape_all(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let ((models, textures), unused) = result.unwrap();
assert!(replaced(&models["standing_banner"], "pole"));
assert!(replaced(&models["standing_banner"], "bar"));
assert!(replaced(&models["wall_banner"], "bar"));
assert!(replaced(&models["wall_banner_flag"], "flag"));
assert!(replaced(&models["book"], "left_lid"));
// The lectern's book has no file of its own nor `book.jem`: vanilla's.
let lectern = models["lectern_book"].child("left_lid").unwrap();
assert_eq!((lectern.polygons.len(), lectern.children.len()), (1, 0));
assert!(textures.is_empty());
// Each entry found its part in one of the models: no warning.
assert_eq!(unused, Vec::new());
}
#[test]
fn book_jem_reshapes_the_lectern_book_without_its_own_file() {
let root = pack("book", &[("optifine/cem/book.jem", jem(&["cover_left"]))]);
let vanilla: HashMap<_, _> = [("book", plane(vec![("left_lid", plane(vec![]))]))].into();
let result = reshape(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let (models, _) = result.unwrap();
assert!(replaced(&models["book"], "left_lid"));
assert!(replaced(&models["lectern_book"], "left_lid"));
}
#[test]
fn a_shared_file_paints_only_the_models_it_reshapes() {
// The conduit's file reshapes its eye alone: the shell, cage and wind keep their
// vanilla geometry and so their own textures.
let file = format!(
r#"{{"texture": "textures/entity/conduit/custom.png", "models": [{{"part": "eye", {BOX}}}]}}"#
);
let root = pack("conduit", &[("optifine/cem/conduit.jem", file)]);
let vanilla: HashMap<_, _> = [
"conduit_shell",
"conduit_cage",
"conduit_wind",
"conduit_eye",
]
.map(|key| {
let part = if key == "conduit_eye" { "eye" } else { "shell" };
(key, plane(vec![(part, plane(vec![]))]))
})
.into();
let result = reshape(&root, &vanilla);
fs::remove_dir_all(&root).unwrap();
let (models, textures) = result.unwrap();
assert!(replaced(&models["conduit_eye"], "eye"));
let painted: BTreeSet<_> = textures.keys().copied().collect();
assert_eq!(painted, BTreeSet::from(["conduit_eye"]));
assert_eq!(textures["conduit_eye"], "entity/conduit/custom");
}
}
@@ -20,7 +20,12 @@ use horizon_world::{
registry::{BlockRegistry, BlockStateId},
};
use serde_json::Value;
use std::{collections::HashMap, f32::consts::PI, path::Path, sync::Mutex};
use std::{
collections::HashMap,
f32::consts::PI,
path::Path,
sync::{Arc, Mutex, PoisonError, RwLock},
};
/// One baked `ModelPart`: initial pose, textured polygons (pixel units,
/// normalized UVs) and named children.
@@ -802,16 +807,102 @@ pub struct Scene<'a> {
/// Empty by default (a dedicated server): no state has a block entity model.
#[derive(Default)]
pub struct BlockEntityRenderer {
models: HashMap<&'static str, ModelPart>,
vanilla: Arc<BlockEntityShapes>,
/// What draws: `vanilla`, or the packs' custom entity models over it.
shapes: RwLock<Arc<BlockEntityShapes>>,
kinds: Vec<Kind>,
// Closed chest geometry: dimension lighting, chest part, horizontal facing.
closed_chests: [[[Vec<Vertex>; 4]; 3]; 2],
/// Item id to its `PROVIDES_POTTERY_PATTERN` side texture (`Sheets.DECORATED_POT_MAPPER`).
pot_patterns: HashMap<String, String>,
/// `Block.shouldRenderFace` for End gateway faces (its occlusion pair cache needs a lock).
visibility: Option<Mutex<FaceVisibility>>,
sign_texts: Mutex<SignTexts>,
}
/// The block-entity models and what is baked from them: vanilla's, or the packs' custom entity
/// models over them ([`crate::block_entity_cem`]).
#[derive(Default)]
pub struct BlockEntityShapes {
models: HashMap<&'static str, ModelPart>,
/// Closed chest geometry by chest model: dimension lighting, horizontal facing.
closed_chests: HashMap<&'static str, [[Vec<Vertex>; 4]; 2]>,
/// A `.jem`'s texture (atlas name) by model, drawn instead of vanilla's.
textures: HashMap<&'static str, String>,
}
/// The chest models, each baked with its lid closed.
const CHEST_MODELS: [&str; 7] = [
"chest",
"double_chest_left",
"double_chest_right",
"trapped_chest",
"trapped_chest_left",
"trapped_chest_right",
"ender_chest",
];
impl BlockEntityShapes {
fn new(
models: HashMap<&'static str, ModelPart>,
textures: HashMap<&'static str, String>,
) -> Self {
let closed_chests = CHEST_MODELS
.iter()
.filter_map(|key| Some((*key, closed_chest(models.get(key)?))))
.collect();
Self {
models,
closed_chests,
textures,
}
}
/// The texture model `key` draws with: its `.jem`'s when the atlas has it, else `vanilla`.
fn texture<'a>(
&'a self,
key: &str,
vanilla: &'a str,
sprites: &HashMap<String, Rect>,
) -> &'a str {
match self.textures.get(key) {
Some(texture) if sprites.contains_key(texture) => texture,
_ => vanilla,
}
}
}
/// A chest model with its lid closed, for each dimension lighting and horizontal facing, its UVs
/// over the whole texture.
fn closed_chest(model: &ModelPart) -> [[Vec<Vertex>; 4]; 2] {
[false, true].map(|nether| {
[0.0f32, 90.0, 180.0, 270.0].map(|y_rot| {
let mut vertices = Vec::new();
draw_part(
model,
"root",
around(
Mat4::from_rotation_y((-y_rot).to_radians()),
Vec3::new(0.5, 0.0, 0.5),
),
&|name, p| {
if name == "lid" || name == "lock" {
p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0);
}
},
Paint {
sprite: Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
tint: 0xFFFFFF,
light: 0,
lights: diffuse_lights(nether),
glow: None,
},
&mut vertices,
);
vertices
})
})
}
/// Laid-out sign text by position. `AbstractSignRenderer` lays the text out
/// again each frame, glyph by glyph; its vertices only change with what is
/// in the key, so they are kept relative to the block and moved to the camera.
@@ -894,6 +985,8 @@ impl BlockEntityRenderer {
.collect();
let mut renderer = Self::new(models, registry);
renderer.visibility = Some(Mutex::new(visibility));
// The packs' custom entity models (`.jem` files), before anything draws.
renderer.set_shapes(renderer.shapes_for(root));
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
let items = match horizon_world::item_defaults::read(root) {
Ok(items) => items,
@@ -920,44 +1013,7 @@ impl BlockEntityRenderer {
Ok(renderer)
}
fn new(models: HashMap<&'static str, ModelPart>, registry: &BlockRegistry) -> Self {
let closed_chests = [false, true].map(|nether| {
["chest", "double_chest_left", "double_chest_right"].map(|name| {
[0.0f32, 90.0, 180.0, 270.0].map(|y_rot| {
let mut vertices = Vec::new();
if let Some(model) = models.get(name) {
draw_part(
model,
"root",
around(
Mat4::from_rotation_y((-y_rot).to_radians()),
Vec3::new(0.5, 0.0, 0.5),
),
&|name, p| {
if name == "lid" || name == "lock" {
p[3] = crate::block_entity_anim::chest_lid_x_rot(0.0);
}
},
Paint {
sprite: Rect {
u0: 0.0,
v0: 0.0,
u1: 1.0,
v1: 1.0,
w: 16,
h: 16,
},
tint: 0xFFFFFF,
light: 0,
lights: diffuse_lights(nether),
glow: None,
},
&mut vertices,
);
}
vertices
})
})
});
let vanilla = Arc::new(BlockEntityShapes::new(models, HashMap::new()));
let kinds = (0..registry.len() as u32)
.map(|id| {
registry.state(BlockStateId(id)).map_or(Kind::None, |s| {
@@ -970,14 +1026,31 @@ impl BlockEntityRenderer {
})
.collect();
Self {
models,
shapes: RwLock::new(vanilla.clone()),
vanilla,
kinds,
closed_chests,
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
}
}
/// The models the packs under `root` give, their `.jem` files over vanilla's, for
/// [`Self::set_shapes`] (a pack reload): vanilla's own, shared, without any.
pub fn shapes_for(&self, root: &Path) -> Arc<BlockEntityShapes> {
match crate::block_entity_cem::reshape(root, &self.vanilla.models) {
Some((models, textures)) => Arc::new(BlockEntityShapes::new(models, textures)),
None => self.vanilla.clone(),
}
}
pub fn set_shapes(&self, shapes: Arc<BlockEntityShapes>) {
*self.shapes.write().unwrap_or_else(PoisonError::into_inner) = shapes;
}
fn shapes(&self) -> Arc<BlockEntityShapes> {
self.shapes
.read()
.unwrap_or_else(PoisonError::into_inner)
.clone()
}
fn kind(&self, state: u32) -> &Kind {
self.kinds.get(state as usize).unwrap_or(&Kind::None)
}
@@ -1271,6 +1344,7 @@ impl BlockEntityRenderer {
world_chests: bool,
out: &mut Vec<Vertex>,
) {
let shapes = self.shapes();
let sprite = |name: &str| scene.sprites.get(name).copied();
let paint = |name: &str, tint: u32, packed: u8| {
let rect = if crumbling {
@@ -1363,12 +1437,34 @@ impl BlockEntityRenderer {
} else {
*texture
};
let model = match part {
ChestPart::Single => "chest",
ChestPart::Left => "double_chest_left",
ChestPart::Right => "double_chest_right",
// Trapped and ender chests have models of their own when a `.jem` reshapes
// them; copper chests have no CEM name and draw vanilla's chest.
let shapes: &BlockEntityShapes =
if matches!(block.as_str(), "chest" | "trapped_chest" | "ender_chest") {
&shapes
} else {
&self.vanilla
};
let (own, chest) = match (block.as_str(), part) {
("trapped_chest", ChestPart::Single) => ("trapped_chest", "chest"),
("trapped_chest", ChestPart::Left) => {
("trapped_chest_left", "double_chest_left")
}
("trapped_chest", ChestPart::Right) => {
("trapped_chest_right", "double_chest_right")
}
("ender_chest", _) => ("ender_chest", "chest"),
(_, ChestPart::Single) => ("chest", "chest"),
(_, ChestPart::Left) => ("double_chest_left", "double_chest_left"),
(_, ChestPart::Right) => ("double_chest_right", "double_chest_right"),
};
let Some(model) = self.models.get(model) else {
let key = if shapes.models.contains_key(own) {
own
} else {
chest
};
let texture = shapes.texture(key, texture, scene.sprites);
let Some(model) = shapes.models.get(key) else {
return;
};
let (open, other) = self.chest_openness(
@@ -1384,9 +1480,11 @@ impl BlockEntityRenderer {
packed = (packed >> 4).max(l >> 4) << 4 | (packed & 15).max(l & 15);
}
if world_chests && open == 0.0 {
if let Some(paint) = paint(texture, 0xFFFFFF, packed) {
let vertices = &self.closed_chests[usize::from(scene.nether)]
[*part as usize][*y_rot as usize / 90];
if let (Some(paint), Some(closed)) = (
paint(texture, 0xFFFFFF, packed),
shapes.closed_chests.get(key),
) {
let vertices = &closed[usize::from(scene.nether)][*y_rot as usize / 90];
let origin = base.w_axis.truncate();
let passes = std::iter::once((paint.sprite, paint.light))
.chain(paint.glow.map(|g| (g, FULL_BRIGHT)));
@@ -1421,8 +1519,12 @@ impl BlockEntityRenderer {
}
Kind::Shulker { texture, facing } => {
let (Some(model), Some(paint)) = (
self.models.get("shulker_box"),
paint(texture, 0xFFFFFF, packed),
shapes.models.get("shulker_box"),
paint(
shapes.texture("shulker_box", texture, scene.sprites),
0xFFFFFF,
packed,
),
) else {
return;
};
@@ -1446,12 +1548,13 @@ impl BlockEntityRenderer {
draw_part(model, "root", m, &lid, paint, out);
}
Kind::Banner { color, wall, angle } => {
let (main, flag) = if *wall {
let (main_key, flag_key) = if *wall {
("wall_banner", "wall_banner_flag")
} else {
("standing_banner", "standing_banner_flag")
};
let (Some(main), Some(flag)) = (self.models.get(main), self.models.get(flag))
let (Some(main), Some(flag)) =
(shapes.models.get(main_key), shapes.models.get(flag_key))
else {
return;
};
@@ -1472,8 +1575,13 @@ impl BlockEntityRenderer {
p[3] = (-0.0125 + 0.01 * (2.0 * PI * phase).mcos()) * PI;
}
};
if let Some(paint) = paint("entity/banner/banner_base", 0xFFFFFF, packed) {
let banner_base = "entity/banner/banner_base";
let main_texture = shapes.texture(main_key, banner_base, scene.sprites);
if let Some(paint) = paint(main_texture, 0xFFFFFF, packed) {
draw_part(main, "root", m, &none, paint, out);
}
let flag_texture = shapes.texture(flag_key, banner_base, scene.sprites);
if let Some(paint) = paint(flag_texture, 0xFFFFFF, packed) {
draw_part(flag, "root", m, &wave, paint, out);
}
// Pattern passes must not multiply the crumbling decal.
@@ -1515,9 +1623,12 @@ impl BlockEntityRenderer {
None
};
let has_profile = skin.is_some();
let texture = skin.as_deref().unwrap_or(texture);
// A `.jem` texture stands in for the default skin, not a profile's.
let texture = skin
.as_deref()
.unwrap_or_else(|| shapes.texture(model, texture, scene.sprites));
let (Some(model), Some(paint)) =
(self.models.get(model), paint(texture, 0xFFFFFF, packed))
(shapes.models.get(model), paint(texture, 0xFFFFFF, packed))
else {
return;
};
@@ -1559,8 +1670,12 @@ impl BlockEntityRenderer {
}
Kind::Bell => {
let (Some(model), Some(paint)) = (
self.models.get("bell"),
paint("entity/bell/bell_body", 0xFFFFFF, packed),
shapes.models.get("bell"),
paint(
shapes.texture("bell", "entity/bell/bell_body", scene.sprites),
0xFFFFFF,
packed,
),
) else {
return;
};
@@ -1588,8 +1703,8 @@ impl BlockEntityRenderer {
}
Kind::DecoratedPot { y_rot } => {
let (Some(base_model), Some(sides)) = (
self.models.get("decorated_pot_base"),
self.models.get("decorated_pot_sides"),
shapes.models.get("decorated_pot_base"),
shapes.models.get("decorated_pot_sides"),
) else {
return;
};
@@ -1617,17 +1732,20 @@ impl BlockEntityRenderer {
}
}
let first = out.len();
if let Some(paint) =
paint("entity/decorated_pot/decorated_pot_base", 0xFFFFFF, packed)
{
let pot_base = "entity/decorated_pot/decorated_pot_base";
let pot_base = shapes.texture("decorated_pot_base", pot_base, scene.sprites);
if let Some(paint) = paint(pot_base, 0xFFFFFF, packed) {
draw_part(base_model, "root", m, &none, paint, out);
}
// PotDecorations (back, left, right, front); items without a pattern use the blank side.
// PotDecorations (back, left, right, front); items without a pattern use the blank
// side (a `.jem`'s texture, if any).
let blank = "entity/decorated_pot/decorated_pot_side";
let blank = shapes.texture("decorated_pot_sides", blank, scene.sprites);
for (side, item) in ["back", "left", "right", "front"].iter().zip(&a.sherds) {
let texture = item
.as_ref()
.and_then(|i| self.pot_patterns.get(i))
.map_or("entity/decorated_pot/decorated_pot_side", String::as_str);
.map_or(blank, String::as_str);
if let (Some(part), Some(paint)) =
(sides.child(side), paint(texture, 0xFFFFFF, packed))
{
@@ -1677,11 +1795,14 @@ impl BlockEntityRenderer {
let (ticks, active, hunting, raw_rotation) = (scene.anim)(pos).conduit;
// ConduitRenderer.extractRenderState.
let rotation = (raw_rotation + if active { scene.partial } else { 0.0 }) * -0.0375;
let tex = |name: &str| paint(name, 0xFFFFFF, packed);
let tex = |key: &str, name: &str| {
paint(shapes.texture(key, name, scene.sprites), 0xFFFFFF, packed)
};
if !active {
let (Some(model), Some(paint)) =
(self.models.get("conduit_shell"), tex("entity/conduit/base"))
else {
let (Some(model), Some(paint)) = (
shapes.models.get("conduit_shell"),
tex("conduit_shell", "entity/conduit/base"),
) else {
return;
};
let m = base
@@ -1696,9 +1817,10 @@ impl BlockEntityRenderer {
let hh = (anim_time * 0.1).msin() / 2.0 + 0.5;
let hh = hh * hh + hh;
let lift = Vec3::new(0.5, 0.3 + hh * 0.2, 0.5);
if let (Some(model), Some(paint)) =
(self.models.get("conduit_cage"), tex("entity/conduit/cage"))
{
if let (Some(model), Some(paint)) = (
shapes.models.get("conduit_cage"),
tex("conduit_cage", "entity/conduit/cage"),
) {
let axis = Vec3::new(0.5, 1.0, 0.5).normalize();
let m = base
* Mat4::from_translation(lift)
@@ -1714,7 +1836,9 @@ impl BlockEntityRenderer {
} else {
"entity/conduit/wind"
};
if let (Some(model), Some(paint)) = (self.models.get("conduit_wind"), tex(wind)) {
if let (Some(model), Some(paint)) =
(shapes.models.get("conduit_wind"), tex("conduit_wind", wind))
{
let turn = match phase {
1 => Mat4::from_rotation_x(PI / 2.0),
2 => Mat4::from_rotation_z(PI / 2.0),
@@ -1732,7 +1856,9 @@ impl BlockEntityRenderer {
} else {
"entity/conduit/closed_eye"
};
if let (Some(model), Some(paint)) = (self.models.get("conduit_eye"), tex(eye)) {
if let (Some(model), Some(paint)) =
(shapes.models.get("conduit_eye"), tex("conduit_eye", eye))
{
let m = base
* Mat4::from_translation(lift)
* Mat4::from_scale(Vec3::splat(0.5))
@@ -1743,9 +1869,14 @@ impl BlockEntityRenderer {
}
}
Kind::EnchantingTable => {
let book = "entity/enchantment/enchanting_table_book";
let (Some(model), Some(paint)) = (
self.models.get("book"),
paint("entity/enchantment/enchanting_table_book", 0xFFFFFF, packed),
shapes.models.get("book"),
paint(
shapes.texture("book", book, scene.sprites),
0xFFFFFF,
packed,
),
) else {
return;
};
@@ -1764,9 +1895,16 @@ impl BlockEntityRenderer {
draw_part(model, "root", m, &book_pose(openness, ff1, ff2), paint, out);
}
Kind::Lectern { y_rot } => {
// Its own `.jem`'s book, else the enchanting table's.
let key = if shapes.models.contains_key("lectern_book") {
"lectern_book"
} else {
"book"
};
let book = "entity/enchantment/enchanting_table_book";
let (Some(model), Some(paint)) = (
self.models.get("book"),
paint("entity/enchantment/enchanting_table_book", 0xFFFFFF, packed),
shapes.models.get(key),
paint(shapes.texture(key, book, scene.sprites), 0xFFFFFF, packed),
) else {
return;
};
@@ -1797,7 +1935,7 @@ impl BlockEntityRenderer {
_ => "copper_golem",
};
let (Some(model), Some(paint)) =
(self.models.get(key), paint(texture, 0xFFFFFF, packed))
(shapes.models.get(key), paint(texture, 0xFFFFFF, packed))
else {
return;
};
@@ -2449,7 +2587,8 @@ mod tests {
#[test]
fn cached_sign_text_matches_and_follows_changes() {
let renderer = BlockEntityRenderer {
models: HashMap::new(),
vanilla: Default::default(),
shapes: Default::default(),
kinds: vec![
Kind::Sign {
hanging: false,
@@ -2462,7 +2601,6 @@ mod tests {
angle: 90.0,
},
],
closed_chests: Default::default(),
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
@@ -2646,4 +2784,131 @@ mod tests {
"entitySolid culls the pot's back faces"
);
}
#[test]
fn a_chest_jem_reshapes_the_closed_chest_and_paints_its_texture() {
use crate::block_entity_cem::tests::{BOX, chest, pack};
let vanilla = Arc::new(BlockEntityShapes::new(
HashMap::from([("chest", chest())]),
HashMap::new(),
));
let renderer = BlockEntityRenderer {
shapes: RwLock::new(vanilla.clone()),
vanilla,
kinds: Vec::new(),
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
};
// No block entity's `.jem` (a mob's only): vanilla's shapes, not a copy.
let empty = pack("render-none", &[("optifine/cem/zombie.jem", "{}".into())]);
assert!(Arc::ptr_eq(&renderer.shapes_for(&empty), &renderer.vanilla));
let root = pack(
"render-chest",
&[(
"optifine/cem/chest.jem",
format!(
r#"{{"texture": "textures/entity/chest/fancy.png",
"models": [{{"part": "lid", {BOX}}}]}}"#
),
)],
);
renderer.set_shapes(renderer.shapes_for(&root));
let _ = std::fs::remove_dir_all(&root);
let _ = std::fs::remove_dir_all(&empty);
let rect = |u0: f32| Rect {
u0,
v0: 0.0,
u1: u0 + 0.2,
v1: 0.2,
w: 16,
h: 16,
};
let sprites: HashMap<String, Rect> = [
("entity/chest/normal", rect(0.0)),
("entity/chest/ender", rect(0.2)),
("entity/chest/fancy", rect(0.4)),
("entity/chest/copper", rect(0.6)),
]
.map(|(name, r)| (name.to_owned(), r))
.into();
let world = World::new(BlockStateId(0), 1);
let scene = Scene {
camera: DVec3::ZERO,
orientation: Quat::IDENTITY,
anim: &|_| BlockAnim::default(),
texts: None,
partial: 0.0,
game_time: 0,
max_y: 320,
world: &world,
sprites: &sprites,
emissive: &Default::default(),
nether: false,
lid: &|_| 0.0,
sign: &|_| None,
banner: &|_| Vec::new(),
skull: &|_| None,
glyph: &|_| (None, 0.0),
glyphs: 0,
};
// A closed single chest of `block` drawn by `renderer`: positions and UVs.
let draw = |renderer: &BlockEntityRenderer, block: &str, texture: &'static str| {
let kind = Kind::Chest {
block: block.into(),
texture,
part: ChestPart::Single,
y_rot: 0.0,
double_with: None,
};
let mut out = Vec::new();
renderer.render_one(
&scene,
[0; 3],
&kind,
Mat4::IDENTITY,
0xF0,
diffuse_lights(false),
false,
true,
&mut out,
);
out.iter().map(|v| (v.pos, v.uv)).collect::<Vec<_>>()
};
// The chest's three faces become the bottom's, the lock's and the custom lid's six, in
// the `.jem`'s texture. Ender and copper chests have no file of their own: vanilla's
// three faces in their own textures. (Trapped chests: `block_entity_cem`'s tests; their
// texture turns festive in late December.)
for (block, texture, u0, faces) in [
("chest", "entity/chest/normal", 0.4, 8),
("ender_chest", "entity/chest/ender", 0.2, 3),
("copper_chest", "entity/chest/copper", 0.6, 3),
] {
let out = draw(&renderer, block, texture);
assert_eq!(out.len(), faces * 4, "{block}");
assert!(
out.iter().all(|(_, uv)| (u0..=u0 + 0.2).contains(&uv[0])),
"{block}"
);
}
// A copper chest draws exactly what it draws without any pack.
let plain = BlockEntityRenderer {
shapes: RwLock::new(renderer.vanilla.clone()),
vanilla: renderer.vanilla.clone(),
kinds: Vec::new(),
pot_patterns: HashMap::new(),
visibility: None,
sign_texts: Mutex::default(),
};
assert_eq!(
draw(&renderer, "copper_chest", "entity/chest/copper"),
draw(&plain, "copper_chest", "entity/chest/copper")
);
// The atlas has no such texture: vanilla's.
let shapes = renderer.shapes();
assert_eq!(
shapes.texture("chest", "entity/chest/normal", &HashMap::new()),
"entity/chest/normal"
);
}
}
+24 -18
View File
@@ -9,7 +9,7 @@ use crate::{
};
use horizon_world::loot::PackOverlay;
use serde_json::Value;
use std::{collections::HashSet, fs, path::Path};
use std::{collections::HashSet, path::Path};
pub use horizon_client_core::potions::{
BASE_POTION_COLOR, Category, EFFECTS, EffectDef, POTIONS, PotionDef, all_effects,
@@ -158,6 +158,28 @@ pub struct Brewing {
/// Items accepted in the bottle slots (`PotionIngredient.isPotionInput`).
pub inputs: HashSet<String>,
}
/// The files right in `recipe/brewing/`, parsed, by id: taken from the whole
/// recipe folder's read when another table holds it (the start loads them
/// together), else read alone. A file that doesn't parse is left out.
fn brewing_sources(root: &Path) -> Vec<(String, Value)> {
let recipe = root.join("data/minecraft/recipe");
let (folder, prefix) = match crate::json_folder::held(&recipe) {
Some(folder) => (folder, "brewing/"),
None => match crate::json_folder::read(&recipe.join("brewing")) {
Ok(folder) => (folder, ""),
Err(_) => return Vec::new(),
},
};
folder
.files
.iter()
.filter_map(|f| {
let name = f.name.strip_prefix(prefix).filter(|n| !n.contains('/'))?;
let v = serde_json::from_slice::<Value>(f.bytes().ok()?).ok()?;
Some((format!("minecraft:brewing/{name}"), v))
})
.collect()
}
impl Brewing {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
// NeoForge's load conditions: a file whose don't hold is silently
@@ -172,23 +194,7 @@ impl Brewing {
let conditions_hold = |v: &Value| {
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
};
let mut sources: Vec<(String, Value)> =
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
.flatten()
.flatten()
.filter_map(|e| {
let bytes = fs::read(e.path()).ok()?;
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
Some((
format!(
"minecraft:brewing/{}",
e.file_name().to_string_lossy().strip_suffix(".json")?
),
v,
))
})
.collect();
let mut sources: Vec<(String, Value)> = brewing_sources(root);
// The world's packs' brewing recipes over the game's own: the top
// pack's file for each id replaces it (`SimpleJsonResourceReloadListener`).
for (id, files) in overlay.kind("recipe") {
+2 -1
View File
@@ -518,7 +518,8 @@ pub fn panic_report(info: &std::panic::PanicHookInfo, side: &'static str) -> Rep
}
}
/// Whether `name` is one of the streaming pool's threads, vanilla's `Util` worker threads.
/// Whether `name` is one of the shared worker pool's threads (`horizon_threads::jobs`), vanilla's
/// `Util` worker threads.
fn is_worker_thread(name: Option<&str>) -> bool {
name.is_some_and(|n| n.starts_with("horizon-worker-"))
}
+31 -5
View File
@@ -31,6 +31,9 @@ struct Globals {
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
// [0] where the lift starts, [1] where it is full.
hdr_windows: array<vec4<f32>, 2>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water.
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -69,6 +72,8 @@ struct Out {
// HDR tier of a light-giving particle (mesher::hdr_tier), from the overlay's
// high bits (particles.rs): fs_hdr_mark marks the frame with it.
@location(13) @interpolate(flat) tier: u32,
// HDR lighting (`hdr_scene`), 0 for what isn't lit.
@location(14) scene: f32,
}
// Shipped light.glsl; normals are already transformed and RGBA8_SNORM packed.
fn entity_light(normal: vec3<f32>, nether: bool) -> f32 {
@@ -107,6 +112,16 @@ fn native_colored_light(pos: vec3<f32>) -> vec3<f32> {
}
return min(rgb, vec3(1.0));
}
// HDR lighting: how far an entity's light carries it above paper white, 0 to 1, as terrain's.
fn hdr_scene(block: f32, sky: f32) -> f32 {
let b = smoothstep(11.0, 15.0, block);
let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x;
return max(b, s);
}
// The frame's alpha for it, between 1 (none) and the light sources' marks below 0.75.
fn hdr_scene_alpha(scene: f32) -> f32 {
return 1.0 - 0.24 * scene;
}
fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
overlay: u32, color: vec4<f32>, light: vec4<u32>, normal: vec4<f32>) -> Out {
var out: Out;
@@ -120,6 +135,7 @@ fn entity_vertex(position: vec3<f32>, uv: vec2<f32>,
let unlit = overlay == 2u || overlay == 4u || overlay == 6u || overlay == 9u || overlay == 10u
|| overlay == 11u || overlay == 12u;
out.light_color = select(lm, vec4(1.0), unlit);
out.scene = select(hdr_scene(l.x / 16.0, l.y / 16.0), 0.0, unlit);
if globals.colored_count.x > 0u && !unlit {
out.light_color = vec4(min(out.light_color.rgb + native_colored_light(position), vec3(1.0)), out.light_color.a);
}
@@ -328,8 +344,14 @@ fn shadow_color(in: Out) -> vec4<f32> {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
let texel = textureSample(atlas, atlas_sampler, uv);
if in.tier == 0u || texel.a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb)));
if texel.a < 0.1 { discard; }
// Fog hides a light source and the light on a surface as it hides everything else.
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
if in.tier == 0u {
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(mix(in.scene, globals.hdr_light.y, fogged)));
}
return vec4(0.0, 0.0, 0.0, hdr_mark(in.tier, brightness(texel.rgb * in.color.rgb) * (1.0 - fogged)));
}
fn brightness(c: vec3<f32>) -> f32 {
let l = pow(max(c, vec3(0.0)), vec3(2.2));
@@ -348,7 +370,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
// no longer shines; fs_hdr_mark then marks the light sources it holds.
@fragment fn fs_hdr_hand_unmark(in: Out) -> @location(0) vec4<f32> {
if textureSample(atlas, atlas_sampler, in.uv).a < 0.1 { discard; }
return vec4(0.0, 0.0, 0.0, 1.0);
return vec4(0.0, 0.0, 0.0, hdr_scene_alpha(in.scene));
}
fn celestial_color(in: Out) -> vec4<f32> {
@@ -375,8 +397,12 @@ fn celestial_color(in: Out) -> vec4<f32> {
let added = color.rgb * color.a;
// The sun at the brightest tier, the moon at its own (in.tier); geometry that sets
// none (the End sky, the End flash) stays brightest.
let keep = 1.0 - hdr_mark(select(in.tier, 4u, in.tier == 0u), brightness(added));
return vec4(added, keep * smoothstep(0.1, 0.3, max(added.r, max(added.g, added.b))));
let tier = select(in.tier, 4u, in.tier == 0u);
let y = brightness(added);
// Only what's past the tier's window takes the mark: a soft edge keeps the sky's light,
// or it would come out darker than the sky around it.
if y < globals.hdr_windows[1][clamp(tier, 1u, 4u) - 1u] { return vec4(added, 0.0); }
return vec4(added, 1.0 - hdr_mark(tier, y));
}
// RenderPipelines.ENERGY_SWIRL (entity.fsh with EMISSIVE and NO_OVERLAY): the sprite, scrolled by
+10
View File
@@ -947,6 +947,16 @@ impl Font {
.sum::<f32>()
.ceil()
}
/// `Font.width(FormattedCharSequence)` of a sequence a component built
/// with `FormattedCharSequence.forward`: every character is a glyph, a
/// `§` included (`StringDecomposer.iterate`, not `iterateFormatted`).
pub fn width_drawn(&self, t: &Text) -> f32 {
crate::bidi::visual(t)
.chars()
.map(|(c, s)| self.advance(c, s))
.sum::<f32>()
.ceil()
}
/// `StringSplitter.splitLines`: each line and whether it continues a
/// wrapped one. A line breaks at a `\n`, or once a character overflows
/// `max_width` (never the first visible one) at the last space so far, or
+411 -95
View File
@@ -110,6 +110,7 @@ mod arena;
pub mod compositor;
mod cull;
mod end_sky;
pub mod entity_skin;
mod flat;
mod frame_gen;
mod hdr;
@@ -329,6 +330,7 @@ struct Globals {
/// HDR output's tier windows, low to very bright: where the lift starts, where it is
/// full (`hdr::Hdr::windows`); zero without HDR.
hdr_windows: [[f32; 4]; 2],
hdr_light: [f32; 4],
}
pub type SectionKey = (i32, i32, i32);
/// The water mirror's targets: the world as a camera mirrored about the water plane sees
@@ -626,6 +628,9 @@ pub struct Frame<'a> {
pub glowing: &'a [Vertex],
/// Runs of `entities` by entity, while a shader pack draws them (`pack_draw::EntitySpan`).
pub entity_spans: &'a [pack_draw::EntitySpan],
/// Ordinary entity models posed on the GPU (`entity_skin`), drawn before `entities`'
/// ordinary ones; only when `Renderer::skins_entities` said so.
pub skin: Option<&'a entity_skin::SkinFrame>,
pub weather: &'a [crate::weather::Vertex],
pub weather_time: crate::weather::Time,
/// Horizon Extras' dynamic lights, nearest first (`dynamic_light::select`); empty when off.
@@ -855,6 +860,8 @@ pub struct Renderer {
entity_buffer: wgpu::Buffer,
entity_capacity: u64,
entity_batch: EntityBatch,
/// Ordinary entity models posed on the GPU; `None` without compute shaders.
entity_skin: Option<entity_skin::EntitySkin>,
group: wgpu::BindGroup,
globals: wgpu::Buffer,
/// The resource packs' LabPBR maps (normals, specular), for native shader packs.
@@ -1451,6 +1458,9 @@ struct Opened {
device: wgpu::Device,
queue: wgpu::Queue,
device_lost: Arc<std::sync::atomic::AtomicBool>,
/// The window surface's configuration, set on `device` during the search, and the
/// surface's capabilities on `adapter`. None offscreen.
configured: Option<(wgpu::SurfaceConfiguration, wgpu::SurfaceCapabilities)>,
}
/// Logs the adapter and records it for telemetry and crash reports.
fn describe_adapter(adapter: &wgpu::Adapter) {
@@ -1513,6 +1523,88 @@ fn surface_format(
.or_else(|| formats.iter().copied().find(|f| !f.is_srgb()))
.unwrap_or(default)
}
/// Sets the window surface up on `device`: the swapchain format (`surface_format`), the
/// copies screenshots and frame generation make, and Mailbox when the surface offers it
/// (`keep_mode`, a replaced renderer's mode, first). `caps` are the surface's capabilities
/// on `adapter`. Errs, instead of wgpu's panic, when the surface can't be configured on
/// this device: a hybrid laptop's discrete GPU under Wayland accepts the surface in
/// `request_adapter` and fails only here (#13493).
#[allow(clippy::too_many_arguments)]
fn configure_surface(
surface: &wgpu::Surface<'_>,
adapter: &wgpu::Adapter,
device: &wgpu::Device,
device_lost: &std::sync::atomic::AtomicBool,
caps: wgpu::SurfaceCapabilities,
size: winit::dpi::PhysicalSize<u32>,
keep_mode: Option<wgpu::PresentMode>,
log_step: bool,
) -> std::result::Result<(wgpu::SurfaceConfiguration, wgpu::SurfaceCapabilities), String> {
let max_side = device.limits().max_texture_dimension_2d;
let mut config = surface
.get_default_config(
adapter,
size.width.clamp(1, max_side),
size.height.clamp(1, max_side),
)
.ok_or("surface has no supported configuration")?;
config.format = surface_format(&caps.formats, config.format);
config.view_formats = vec![config.format.remove_srgb_suffix()];
// Screenshots copy the presented frame back from the swapchain, frame
// generation's present thread copies its frames in.
if caps.usages.contains(wgpu::TextureUsages::COPY_SRC) {
config.usage |= wgpu::TextureUsages::COPY_SRC;
}
if caps.usages.contains(wgpu::TextureUsages::COPY_DST) {
config.usage |= wgpu::TextureUsages::COPY_DST;
}
config.present_mode = keep_mode
.filter(|mode| caps.present_modes.contains(mode))
.unwrap_or_else(|| {
if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
wgpu::PresentMode::Mailbox
} else {
wgpu::PresentMode::Fifo
}
});
if log_step {
startup_step(format_args!(
"device ready; configuring the surface ({}x{} {:?}, {:?})",
config.width, config.height, config.format, config.present_mode
));
}
Renderer::configure(device, surface, &config, None, device_lost)?;
Ok((config, caps))
}
/// What `pick_adapter` knows of one of a backend's adapters.
#[derive(Clone, Copy, Debug)]
struct AdapterChoice {
kind: wgpu::DeviceType,
/// The window surface offers it formats (`Surface::get_capabilities`).
presents: bool,
/// Already tried, and it could not present.
tried: bool,
}
/// The adapter to try after one that could not present to the window: one not tried yet
/// that the surface offers formats on, a software one only when `allow_cpu`, ranked as
/// `request_adapter` ranks for `HighPerformance` (discrete, integrated, other, virtual,
/// CPU), the first listed among equals. A hybrid laptop's integrated GPU drives the
/// Wayland compositor and presents when its discrete GPU can't (#13493).
fn pick_adapter(adapters: &[AdapterChoice], allow_cpu: bool) -> Option<usize> {
let rank = |kind| match kind {
wgpu::DeviceType::DiscreteGpu => 0,
wgpu::DeviceType::IntegratedGpu => 1,
wgpu::DeviceType::Other => 2,
wgpu::DeviceType::VirtualGpu => 3,
wgpu::DeviceType::Cpu => 4,
};
adapters
.iter()
.enumerate()
.filter(|(_, a)| a.presents && !a.tried && (allow_cpu || !is_software(a.kind)))
.min_by_key(|(_, a)| rank(a.kind))
.map(|(i, _)| i)
}
/// Opens a device on the first of `backend_order`'s backends that gives one.
/// Offscreen rendering needs no surface, so it can use a GPU the window's
/// display cannot present from (a headless compositor).
@@ -1522,37 +1614,64 @@ async fn open_device(
api: GraphicsApi,
layers: u32,
) -> Result<Opened> {
// A software adapter (WARP, llvmpipe) is a last resort: a later backend may
// have the real GPU (#3978: a Fermi GPU with no Vulkan and no DX12 driver
// still has OpenGL).
match open_device_with(window, offscreen, api, layers, false).await {
Ok(opened) => Ok(opened),
Err(first) => match open_device_with(window, offscreen, api, layers, true).await {
let wgpu_backend = std::env::var_os("WGPU_BACKEND").is_some();
let mut first = None;
for (order, allow_cpu) in device_search(api.with_env(), std::env::consts::OS, wgpu_backend) {
match open_device_with(window, offscreen, &order, layers, allow_cpu).await {
Ok(opened) => {
log::warn!(
"Horizon renderer: no GPU adapter could be used ({first}); \
rendering on the CPU, which is slow"
);
Ok(opened)
if let Some(first) = &first {
log::warn!(
"Horizon renderer: the chosen graphics API gave no GPU ({first}); {}",
if allow_cpu {
"rendering on the CPU, which is slow"
} else {
"using the GPU through another one"
}
);
}
return Ok(opened);
}
Err(_) => Err(first),
},
Err(e) => {
first.get_or_insert(e);
}
}
}
Err(first.unwrap_or_else(|| NO_GPU_DRIVER.into()))
}
/// `open_device`, taking an adapter of the CPU type (software rendering) only
/// when `allow_cpu`.
/// The tries `open_device` makes, in turn: the chosen API's backends (`backend_order`) on a
/// real GPU; when the player chose one API, the other backends `Auto` would try, on a real
/// GPU; and last the chosen backends with a software adapter (WARP, llvmpipe) allowed.
/// Software rendering takes most of a weak CPU even on the title screen: an Intel HD 2500 has
/// DirectX 12 only through WARP and its GPU through OpenGL (#5185), and a Fermi GPU has no
/// Vulkan or DirectX 12 driver but OpenGL (#3978). DirectX 12 never falls back to Vulkan: the
/// launcher chooses it after Vulkan crashed in the driver (#746), which the game can't catch.
fn device_search(
api: GraphicsApi,
os: &str,
wgpu_backend: bool,
) -> Vec<(Vec<wgpu::Backends>, bool)> {
let chosen = backend_order(api, os, wgpu_backend);
let others: Vec<_> = backend_order(GraphicsApi::Auto, os, wgpu_backend)
.into_iter()
.filter(|b| !chosen.contains(b))
.filter(|&b| !(api == GraphicsApi::Dx12 && b == wgpu::Backends::VULKAN))
.collect();
let mut tries = vec![(chosen.clone(), false)];
if !others.is_empty() {
tries.push((others, false));
}
tries.push((chosen, true));
tries
}
/// `open_device` on `order`'s backends in turn, taking an adapter of the CPU type
/// (software rendering) only when `allow_cpu`.
async fn open_device_with(
window: &Arc<Window>,
offscreen: bool,
api: GraphicsApi,
order: &[wgpu::Backends],
layers: u32,
allow_cpu: bool,
) -> Result<Opened> {
let order = backend_order(
api.with_env(),
std::env::consts::OS,
std::env::var_os("WGPU_BACKEND").is_some(),
);
let mut failures = Vec::new();
for (attempt, backends) in order.iter().copied().enumerate() {
let name = backends_name(backends);
@@ -1654,11 +1773,92 @@ async fn open_backend(
adapter.get_info().name
)));
}
describe_adapter(&adapter);
// Adapters that could not present to the window: the next one of this backend
// that can is tried before the next backend (#13493).
let mut tried = Vec::new();
let mut adapter = adapter;
loop {
let why = match open_adapter(name, window, surface.as_ref(), &adapter, layers).await {
Ok(adapted) => {
return Ok(Opened {
instance,
surface,
adapter,
device: adapted.device,
queue: adapted.queue,
device_lost: adapted.device_lost,
configured: adapted.configured,
});
}
Err(AdapterFailure::Failed(e)) => return Err(NoDevice::Failed(e)),
Err(AdapterFailure::CannotPresent(why)) => why,
};
let info = adapter.get_info();
tried.push(info.clone());
let others = instance.enumerate_adapters(wgpu::Backends::all()).await;
let choices: Vec<_> = others
.iter()
.map(|other| AdapterChoice {
kind: other.get_info().device_type,
presents: surface
.as_ref()
.is_some_and(|s| !s.get_capabilities(other).formats.is_empty()),
tried: tried.contains(&other.get_info()),
})
.collect();
let Some(next) = pick_adapter(&choices, allow_cpu) else {
log::warn!(
"Horizon renderer: skipping {} on {name}: it can't present to the window \
({why}), and no other {name} adapter can",
info.name
);
return Err(NoDevice::Failed(format!(
"{name}: {} can't present to the window ({why})",
info.name
)));
};
adapter = others[next].clone();
log::warn!(
"Horizon renderer: skipping {} on {name}: it can't present to the window ({why}); \
trying {}",
info.name,
adapter.get_info().name
);
}
}
/// Why an adapter gave no device: it can't present to the window, so another adapter of
/// the backend may, or another failure.
enum AdapterFailure {
CannotPresent(String),
Failed(String),
}
/// A device opened on one adapter, with the window surface set up on it.
struct Adapted {
device: wgpu::Device,
queue: wgpu::Queue,
device_lost: Arc<std::sync::atomic::AtomicBool>,
configured: Option<(wgpu::SurfaceConfiguration, wgpu::SurfaceCapabilities)>,
}
/// Opens a device on `adapter` and configures the window's `surface` on it (none
/// offscreen). An adapter the surface offers no format on fails before its device opens.
async fn open_adapter(
name: &str,
window: &Arc<Window>,
surface: Option<&wgpu::Surface<'static>>,
adapter: &wgpu::Adapter,
layers: u32,
) -> std::result::Result<Adapted, AdapterFailure> {
let caps = surface.map(|surface| surface.get_capabilities(adapter));
if caps.as_ref().is_some_and(|caps| caps.formats.is_empty()) {
return Err(AdapterFailure::CannotPresent(
"the window surface offers it no format".into(),
));
}
describe_adapter(adapter);
let info = adapter.get_info();
let limits = adapter.limits();
if let Err(e) = check_layers(&adapter, layers) {
return Err(NoDevice::Failed(format!("{name}: {e}")));
if let Err(e) = check_layers(adapter, layers) {
return Err(AdapterFailure::Failed(format!("{name}: {e}")));
}
// Mappable primary buffers: section pages that no longer fit in
// video memory go to system memory. Shader packs' 16-bit normalized buffers (render
@@ -1666,9 +1866,9 @@ async fn open_backend(
// stand-in neither clamps nor drops NaN. Subgroups: shader packs' compute programs
// (`MC_GL_KHR_shader_subgroup_*`); storage buffers in vertex shaders, clearing and
// atomics on images: their buffers and images.
let features = timing::GpuTimer::features(&adapter)
| cull::Cull::features(&adapter)
| oit::features(&adapter)
let features = timing::GpuTimer::features(adapter)
| cull::Cull::features(adapter)
| oit::features(adapter)
| (adapter.features()
& (wgpu::Features::INDIRECT_FIRST_INSTANCE
| wgpu::Features::MAPPABLE_PRIMARY_BUFFERS
@@ -1703,7 +1903,7 @@ async fn open_backend(
};
#[cfg(not(target_os = "macos"))]
let opened = match info.backend {
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(&adapter, &desc),
wgpu::Backend::Vulkan => horizon_hdr::open_vulkan_device(adapter, &desc),
_ => None,
};
#[cfg(target_os = "macos")]
@@ -1712,31 +1912,42 @@ async fn open_backend(
Some(opened) => opened,
None => adapter.request_device(&desc).await,
};
match opened {
Ok((device, queue)) => {
let device_lost = Arc::new(std::sync::atomic::AtomicBool::new(false));
let lost = device_lost.clone();
device.set_device_lost_callback(move |reason, message| {
// A device destroyed on purpose was not lost.
if reason == wgpu::DeviceLostReason::Destroyed {
return;
}
log::warn!(
"Horizon renderer: the graphics device was lost ({reason:?}: {message})"
);
lost.store(true, std::sync::atomic::Ordering::Relaxed);
});
Ok(Opened {
instance,
let (device, queue) = match opened {
Ok(opened) => opened,
Err(e) => return Err(AdapterFailure::Failed(format!("{name} device: {e}"))),
};
let device_lost = Arc::new(std::sync::atomic::AtomicBool::new(false));
let lost = device_lost.clone();
device.set_device_lost_callback(move |reason, message| {
// A device destroyed on purpose was not lost.
if reason == wgpu::DeviceLostReason::Destroyed {
return;
}
log::warn!("Horizon renderer: the graphics device was lost ({reason:?}: {message})");
lost.store(true, std::sync::atomic::Ordering::Relaxed);
});
let configured = match (surface, caps) {
(Some(surface), Some(caps)) => Some(
configure_surface(
surface,
adapter,
device,
queue,
device_lost,
})
}
Err(e) => Err(NoDevice::Failed(format!("{name} device: {e}"))),
}
&device,
&device_lost,
caps,
window.inner_size(),
None,
true,
)
.map_err(AdapterFailure::CannotPresent)?,
),
_ => None,
};
Ok(Adapted {
device,
queue,
device_lost,
configured,
})
}
/// What to tell a player whose system offers no usable graphics adapter.
/// Vulkan is loaded at run time (the game links no graphics library), so a
@@ -1836,6 +2047,7 @@ impl Renderer {
device,
queue,
device_lost,
configured,
} = match previous {
// Reload resources on the same device; replacing live Vulkan
// devices can invalidate the driver's swapchain state on the same
@@ -1862,6 +2074,7 @@ impl Renderer {
device: renderer.device.clone(),
queue: renderer.queue.clone(),
device_lost: renderer.device_lost.clone(),
configured: None,
}
}
None => {
@@ -1879,42 +2092,21 @@ impl Renderer {
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
let size = window.inner_size();
let max_side = device.limits().max_texture_dimension_2d;
let (config, present_modes, hdr_output) = if let Some(surface) = &surface {
let mut config = surface
.get_default_config(
&adapter,
size.width.clamp(1, max_side),
size.height.clamp(1, max_side),
)
.ok_or("surface has no supported configuration")?;
let caps = surface.get_capabilities(&adapter);
config.format = surface_format(&caps.formats, config.format);
config.view_formats = vec![config.format.remove_srgb_suffix()];
// Screenshots copy the presented frame back from the swapchain, frame
// generation's present thread copies its frames in.
if caps.usages.contains(wgpu::TextureUsages::COPY_SRC) {
config.usage |= wgpu::TextureUsages::COPY_SRC;
}
if caps.usages.contains(wgpu::TextureUsages::COPY_DST) {
config.usage |= wgpu::TextureUsages::COPY_DST;
}
config.present_mode = previous
.map(|renderer| renderer.config.present_mode)
.filter(|mode| caps.present_modes.contains(mode))
.unwrap_or_else(|| {
if caps.present_modes.contains(&wgpu::PresentMode::Mailbox) {
wgpu::PresentMode::Mailbox
} else {
wgpu::PresentMode::Fifo
}
});
if previous.is_none() {
startup_step(format_args!(
"device ready; configuring the surface ({}x{} {:?}, {:?})",
config.width, config.height, config.format, config.present_mode
));
}
surface.configure(&device, &config);
let configured = match (&surface, configured) {
(Some(_), Some(configured)) => Some(configured),
(Some(surface), None) => Some(configure_surface(
surface,
&adapter,
&device,
&device_lost,
surface.get_capabilities(&adapter),
size,
previous.map(|renderer| renderer.config.present_mode),
previous.is_none(),
)?),
(None, _) => None,
};
let (config, present_modes, hdr_output) = if let Some((config, caps)) = configured {
let hdr_output = hdr::output(&caps);
crate::graphics::offer_hdr(hdr_output.is_some());
if previous.is_none() {
@@ -1923,7 +2115,7 @@ impl Renderer {
hdr_output.map_or("not offered by this display", hdr::Output::name)
);
}
(config, caps.present_modes.clone(), hdr_output)
(config, caps.present_modes, hdr_output)
} else {
let format = wgpu::TextureFormat::Bgra8Unorm;
let config = wgpu::SurfaceConfiguration {
@@ -3442,10 +3634,14 @@ impl Renderer {
mapped_at_creation: false,
});
let entity_capacity = 1 << 14;
let entity_skin = entity_skin::EntitySkin::new(
&device,
animate::SpriteAnimator::compute(&adapter, &device),
);
let entity_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity vertices"),
size: entity_capacity * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
usage: entity_skin::EntitySkin::usage(entity_skin.is_some()),
mapped_at_creation: false,
});
let flat_wanted = flat::wanted(&adapter, &device);
@@ -3671,6 +3867,7 @@ impl Renderer {
entity_buffer,
entity_capacity,
entity_batch: EntityBatch::default(),
entity_skin,
group,
globals,
pbr_maps,
@@ -4840,6 +5037,23 @@ impl Renderer {
}
/// The share of real frames frame generation shows a made frame before (1 while it
/// keeps up), `None` while it isn't running.
/// Whether the game may pose ordinary entity models on the GPU (`Frame::skin`): not
/// while a shader pack or a native pack draws entities from their vertices, nor while
/// frame generation follows them.
/// The posed models' store size (`SkinFrame::base`), with entity skinning.
pub fn skinned_models(&self) -> Option<u32> {
self.entity_skin
.as_ref()
.map(entity_skin::EntitySkin::stored)
}
pub fn skins_entities(&self) -> bool {
self.entity_skin.is_some()
&& !self.frame_gen.on
&& self.pack_post.is_none()
&& self.native.is_none()
}
pub fn frame_generation_share(&self) -> Option<f32> {
self.frame_gen.made_share
}
@@ -5688,6 +5902,17 @@ impl Renderer {
0.0,
],
hdr_windows: self.hdr.as_ref().map_or([[0.0; 4]; 2], hdr::Hdr::windows),
hdr_light: [
frame.sky.sky_light_factor,
// The underwater overlay: the sky and fog are the water's own colour there.
if frame.screen.iter().any(|q| q.world_tint) {
0.0
} else {
frame.sky.sky_light_factor
},
0.0,
0.0,
],
};
self.uploads
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
@@ -6370,6 +6595,12 @@ impl Renderer {
}
// GameRenderer.useImprovedTransparency. A shader pack keeps its own
// path.
let hdr_lighting = self.native.is_none()
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& std::env::var_os("HORIZON_HDR_LIGHTING").is_none_or(|v| v != "0");
if let Some(hdr) = &mut self.hdr {
hdr.lighting = hdr_lighting;
}
let oit_on = self.improved_transparency
&& !frame.title
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
@@ -6394,6 +6625,13 @@ impl Renderer {
oit_on,
);
self.entity_batch.append_shadow_only(frame.shadow_only);
// The posed models' vertices come first: the ordinary entities' draw takes them in.
let skin = frame.skin.filter(|skin| match &mut self.entity_skin {
Some(skinner) => skinner.register(&self.device, &mut self.uploads, &mut encoder, skin),
None => false,
});
let posed = skin.map_or(0, |s| s.vertices);
let entity_quads = entity_quads + posed / 4;
let weather_quads = frame.weather.len() as u32 / 4;
let world_quads = entity_quads
+ text_quads
@@ -6449,7 +6687,7 @@ impl Renderer {
camera,
);
// Frame generation's made frame draws its own hand, halfway, after the others.
let made_hand_vertex = self.entity_batch.combined.len() as i32;
let made_hand_vertex = (posed as usize + self.entity_batch.combined.len()) as i32;
if fg {
self.frame_gen
.halfway_hand(frame.hand, hand_view, &mut self.entity_batch.combined);
@@ -6466,24 +6704,34 @@ impl Renderer {
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
let see_through_quads = (frame.see_through.len() / 4) as u32;
let shadow_only_quads = (frame.shadow_only.len() / 4) as u32;
let entity_vertices = all_entities.len() as u64;
let entity_vertices = u64::from(posed) + all_entities.len() as u64;
if entity_vertices > self.entity_capacity {
self.entity_capacity = entity_vertices.next_power_of_two();
self.entity_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity vertices"),
size: self.entity_capacity * std::mem::size_of::<Vertex>() as u64,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
usage: entity_skin::EntitySkin::usage(self.entity_skin.is_some()),
mapped_at_creation: false,
});
}
if entity_vertices > 0 {
if !all_entities.is_empty() {
self.uploads.buffer(
&mut encoder,
&self.entity_buffer,
0,
u64::from(posed) * std::mem::size_of::<Vertex>() as u64,
bytemuck::cast_slice(all_entities),
);
}
if let (Some(skinner), Some(skin)) = (&mut self.entity_skin, skin) {
skinner.dispatch(
&self.device,
&mut self.uploads,
&mut encoder,
skin,
&self.entity_buffer,
0,
);
}
self.entity_outline.prepare(
&self.device,
&mut self.uploads,
@@ -8504,8 +8752,9 @@ impl Renderer {
if let Some(radius) = menu_blur {
let format = self.config.format;
let device = &self.device;
let software = is_software(self.info.device_type);
self.menu_blur
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format))
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format, software))
.apply(
&self.device,
&self.queue,
@@ -9294,6 +9543,73 @@ mod tests {
assert_eq!(order(api, "macos"), [Backends::default()]);
}
}
#[test]
fn a_chosen_api_without_a_gpu_tries_the_others_before_software() {
use super::{GraphicsApi, device_search};
use wgpu::Backends;
let (v, d, g) = (Backends::VULKAN, Backends::DX12, Backends::GL);
// #5185: DirectX 12 chosen on an Intel HD 2500 (WARP only): OpenGL's GPU before WARP.
assert_eq!(
device_search(GraphicsApi::Dx12, "windows", false),
[(vec![d], false), (vec![g], false), (vec![d], true)]
);
assert_eq!(
device_search(GraphicsApi::Vulkan, "windows", false),
[(vec![v], false), (vec![d, g], false), (vec![v], true)]
);
assert_eq!(
device_search(GraphicsApi::Auto, "windows", false),
[(vec![v, d, g], false), (vec![v, d, g], true)]
);
assert_eq!(
device_search(GraphicsApi::OpenGl, "linux", false),
[(vec![g], false), (vec![v], false), (vec![g], true)]
);
// WGPU_BACKEND and macOS: one set of backends, nothing else to try.
let all = Backends::default();
for (os, env) in [("windows", true), ("macos", false)] {
assert_eq!(
device_search(GraphicsApi::Vulkan, os, env),
[(vec![all], false), (vec![all], true)]
);
}
}
/// #13493: a hybrid laptop under Wayland. The NVIDIA GPU can't present to the window,
/// so the integrated GPU that drives the compositor is next, never the one that failed.
#[test]
fn an_adapter_that_cannot_present_gives_way_to_one_that_can() {
use super::{AdapterChoice, pick_adapter};
use wgpu::DeviceType::{Cpu, DiscreteGpu, IntegratedGpu, Other, VirtualGpu};
let a = |kind, presents, tried| AdapterChoice {
kind,
presents,
tried,
};
// The NVIDIA GPU failed to configure, the Intel one presents: the Intel one.
let hybrid = [a(DiscreteGpu, true, true), a(IntegratedGpu, true, false)];
assert_eq!(pick_adapter(&hybrid, false), Some(1));
// Among adapters that present, HighPerformance's order: discrete first, then
// integrated, other, virtual; the first listed among equals.
let many = [
a(VirtualGpu, true, false),
a(Other, true, false),
a(IntegratedGpu, true, false),
a(DiscreteGpu, false, false),
a(DiscreteGpu, true, false),
a(DiscreteGpu, true, false),
];
assert_eq!(pick_adapter(&many, false), Some(4));
assert_eq!(pick_adapter(&many[..4], false), Some(2));
assert_eq!(pick_adapter(&many[..2], false), Some(1));
// Nothing left that presents: the next backend (OpenGL) is tried.
let none = [a(DiscreteGpu, true, true), a(IntegratedGpu, false, false)];
assert_eq!(pick_adapter(&none, false), None);
assert_eq!(pick_adapter(&[], false), None);
// A software adapter only on the search's last, software-allowed try.
let llvmpipe = [a(DiscreteGpu, true, true), a(Cpu, true, false)];
assert_eq!(pick_adapter(&llvmpipe, false), None);
assert_eq!(pick_adapter(&llvmpipe, true), Some(1));
}
/// The second try at a backend shows the adapters wgpu hides for reporting
/// no Vulkan conformance version (Mesa's hasvk, which drives Ivy Bridge and
/// Haswell, reports 0.0.0.0 and works).
@@ -0,0 +1,511 @@
//! Entity models posed on the GPU. The CPU path (`mobs::render::draw`) walks a
//! model's parts and pushes every vertex of every polygon, transformed by its
//! part's matrix: ~280 vertices of 44 bytes per mob, then copied by material
//! and uploaded each frame. Here each model's vertices are uploaded once, in
//! model space, and a frame only sends each drawn part's matrices and one job
//! per run of drawn parts; `entity_skin.wgsl` writes the same vertices, in the
//! same order, into the entity vertex buffer, after the CPU's ordinary entities
//! (`EntityBatch` leaves the gap). Every pass that draws ordinary entities
//! then draws them as before.
//!
//! The game decides which draws come here (`SkinFrame`): models whose
//! polygons all face an axis, drawn as ordinary opaque or cutout entities.
//! The rest stays on the CPU path.
use super::upload::Uploads;
use crate::mesher::Vertex;
/// A model vertex: position / 16, model UV, the face's normal as an axis (0..2
/// = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in its model.
#[repr(C)]
#[derive(Clone, Copy, Debug, PartialEq, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinVertex {
pub pos: [f32; 3],
pub uv: [f32; 2],
pub axis: u32,
pub part: u32,
}
/// A drawn part: its geometry matrix and its normal matrix, by columns, the
/// normal matrix's columns normalized when the part's scale is not uniform.
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinPart {
pub matrix: [f32; 16],
pub normal: [f32; 9],
}
/// One run of a model's vertices drawn with one texture rect, colour, light
/// and overlay. `dst` counts from the frame's first posed vertex.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
pub struct SkinJob {
pub first: u32,
pub count: u32,
pub part: u32,
pub dst: u32,
pub rect: [f32; 4],
pub scroll: f32,
pub color: u32,
pub light: u32,
pub overlay: u32,
/// 1 when the overlay is the armour glint: the model UV rides in `glint_uv`.
pub glint: u32,
pub spare: [u32; 3],
}
const _: () = assert!(std::mem::size_of::<Vertex>() == 44);
const _: () = assert!(std::mem::size_of::<SkinVertex>() == 7 * 4);
const _: () = assert!(std::mem::size_of::<SkinPart>() == 25 * 4);
const _: () = assert!(std::mem::size_of::<SkinJob>() == 16 * 4);
/// What the game hands the renderer for one frame.
#[derive(Default)]
pub struct SkinFrame {
/// The model store restarted (models are registered again from 0).
pub reset: bool,
/// The store's size before `new_model`: the renderer takes the frame only
/// when its own store is that large (it missed none of the earlier ones).
pub base: u32,
/// Model vertices registered since the last frame, at the store's end.
pub new_model: Vec<SkinVertex>,
pub parts: Vec<SkinPart>,
pub jobs: Vec<SkinJob>,
/// Vertices the jobs write: a whole number of quads.
pub vertices: u32,
}
impl SkinFrame {
/// Starts a frame's draws; models not yet handed to the renderer stay.
pub fn clear_draws(&mut self) {
self.parts.clear();
self.jobs.clear();
self.vertices = 0;
}
/// The renderer took the new models.
pub fn handed(&mut self) {
self.base += self.new_model.len() as u32;
self.new_model.clear();
self.reset = false;
}
/// Where the draws stand, to take back a mob's (`truncate`).
pub fn mark(&self) -> (usize, usize, u32) {
(self.jobs.len(), self.parts.len(), self.vertices)
}
/// Takes back the draws since `mark` (a mob culled after drawing).
pub fn truncate(&mut self, (jobs, parts, vertices): (usize, usize, u32)) {
self.jobs.truncate(jobs);
self.parts.truncate(parts);
self.vertices = vertices;
}
/// Whether the renderer has anything to take.
pub fn pending(&self) -> bool {
self.reset || !self.new_model.is_empty() || !self.jobs.is_empty()
}
}
/// Jobs per row of workgroups (the dispatch limit is 65535 per dimension).
const ROW: u32 = 32_768;
pub(super) struct EntitySkin {
pipeline: wgpu::ComputePipeline,
layout: wgpu::BindGroupLayout,
model: Vec<SkinVertex>,
model_buffer: Option<wgpu::Buffer>,
parts: Grow,
jobs: Grow,
/// The jobs with `dst` moved to the frame's gap, as uploaded.
staged: Vec<SkinJob>,
/// The bind group and the buffers it binds (buffers compare by identity).
group: Option<(wgpu::BindGroup, [wgpu::Buffer; 4])>,
}
/// A storage buffer that grows to the next power of two.
struct Grow {
label: &'static str,
buffer: Option<wgpu::Buffer>,
}
impl Grow {
fn ensure(&mut self, device: &wgpu::Device, bytes: u64) -> bool {
let bytes = bytes.max(256).next_power_of_two();
if self.buffer.as_ref().is_some_and(|b| b.size() >= bytes) {
return false;
}
self.buffer = Some(device.create_buffer(&wgpu::BufferDescriptor {
label: Some(self.label),
size: bytes,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}));
true
}
}
impl EntitySkin {
/// The entity vertex buffer's usage: the jobs write into it.
pub(super) fn usage(skinning: bool) -> wgpu::BufferUsages {
let usage = wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST;
if skinning {
usage | wgpu::BufferUsages::STORAGE
} else {
usage
}
}
/// The compute pipeline, or `None` without compute shaders (the CPU path
/// then draws every model).
pub(super) fn new(device: &wgpu::Device, compute: bool) -> Option<Self> {
// HORIZON_NO_ENTITY_SKIN: every model on the CPU path (A/B runs, a driver's bug).
if !compute
|| device.limits().max_storage_buffers_per_shader_stage < 4
|| std::env::var_os("HORIZON_NO_ENTITY_SKIN").is_some()
{
return None;
}
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let internal = device.push_error_scope(wgpu::ErrorFilter::Internal);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Entity skinning"),
source: wgpu::ShaderSource::Wgsl(include_str!("entity_skin.wgsl").into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Entity skinning"),
layout: None,
module: &module,
entry_point: Some("skin"),
compilation_options: Default::default(),
cache: None,
});
let layout = pipeline.get_bind_group_layout(0);
let internal = pollster::block_on(internal.pop());
if let Some(e) = internal.or(pollster::block_on(validation.pop())) {
log::warn!("Horizon renderer: entity models posed on the CPU ({e})");
return None;
}
Some(Self {
pipeline,
layout,
model: Vec::new(),
model_buffer: None,
parts: Grow {
label: "Entity skinning parts",
buffer: None,
},
jobs: Grow {
label: "Entity skinning jobs",
buffer: None,
},
staged: Vec::new(),
group: None,
})
}
/// The model vertices stored (`SkinFrame::base` of the next frame).
pub(super) fn stored(&self) -> u32 {
self.model.len() as u32
}
/// Takes the frame's new models; whether its jobs can run (the store
/// holds every model they read).
pub(super) fn register(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
frame: &SkinFrame,
) -> bool {
if frame.reset {
self.model.clear();
self.model_buffer = None;
}
if self.model.len() as u32 != frame.base {
// Out of step with the game's store: it restarts it.
return false;
}
if frame.new_model.is_empty() {
return self.model_buffer.is_some() || frame.jobs.is_empty();
}
let old = self.model.len();
self.model.extend_from_slice(&frame.new_model);
let bytes = (self.model.len() * std::mem::size_of::<SkinVertex>()) as u64;
if self
.model_buffer
.as_ref()
.is_some_and(|b| b.size() >= bytes)
{
let buffer = self.model_buffer.as_ref().expect("checked");
uploads.buffer(
encoder,
buffer,
(old * std::mem::size_of::<SkinVertex>()) as u64,
bytemuck::cast_slice(&frame.new_model),
);
} else {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Entity skinning models"),
size: bytes.max(256).next_power_of_two(),
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
uploads.buffer(encoder, &buffer, 0, bytemuck::cast_slice(&self.model));
self.model_buffer = Some(buffer);
}
true
}
/// Records the frame's jobs: they write `frame.vertices` vertices into
/// `out` from vertex `base` on. Call after the CPU vertices' upload.
#[allow(clippy::too_many_arguments)]
pub(super) fn dispatch(
&mut self,
device: &wgpu::Device,
uploads: &mut Uploads,
encoder: &mut wgpu::CommandEncoder,
frame: &SkinFrame,
out: &wgpu::Buffer,
base: u32,
) {
if frame.jobs.is_empty() {
return;
}
let Some(model) = &self.model_buffer else {
return;
};
// Whole rows of workgroups past `ROW` jobs: the padding jobs write nothing.
let count = frame.jobs.len() as u32;
let rows = count.div_ceil(ROW);
let padded = if rows > 1 {
(rows * ROW) as usize
} else {
frame.jobs.len()
};
self.staged.clear();
self.staged.extend(frame.jobs.iter().map(|job| SkinJob {
dst: job.dst + base,
..*job
}));
self.staged.resize(padded, SkinJob::default());
self.parts.ensure(
device,
(frame.parts.len() * std::mem::size_of::<SkinPart>()) as u64,
);
self.jobs
.ensure(device, (padded * std::mem::size_of::<SkinJob>()) as u64);
let (Some(parts), Some(jobs)) = (&self.parts.buffer, &self.jobs.buffer) else {
return;
};
uploads.buffer(encoder, parts, 0, bytemuck::cast_slice(&frame.parts));
uploads.buffer(encoder, jobs, 0, bytemuck::cast_slice(&self.staged));
let key = [model.clone(), parts.clone(), jobs.clone(), out.clone()];
if self.group.as_ref().is_none_or(|(_, k)| *k != key) {
let entries: Vec<_> = key
.iter()
.enumerate()
.map(|(binding, buffer)| wgpu::BindGroupEntry {
binding: binding as u32,
resource: buffer.as_entire_binding(),
})
.collect();
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Entity skinning"),
layout: &self.layout,
entries: &entries,
});
self.group = Some((group, key));
}
let Some((group, _)) = &self.group else {
return;
};
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: Some("Entity skinning"),
timestamp_writes: None,
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, group, &[]);
pass.dispatch_workgroups(count.min(ROW), rows, 1);
}
}
/// What `entity_skin.wgsl` writes for `frame`'s jobs, computed on the CPU
/// the same way: the reference its tests hold the shader and the game's walk
/// (`mobs::skin`) to. `model` is the whole store.
pub fn expand(model: &[SkinVertex], frame: &SkinFrame) -> Vec<Vertex> {
let mut out = vec![bytemuck::Zeroable::zeroed(); frame.vertices as usize];
for job in &frame.jobs {
for k in 0..job.count {
let v = model[(job.first + k) as usize];
let part = &frame.parts[(job.part + v.part) as usize];
let m = glam::Mat4::from_cols_array(&part.matrix);
let pos = m.transform_point3(glam::Vec3::from_array(v.pos));
let c = (v.axis % 3) as usize * 3;
let column = glam::Vec3::from_slice(&part.normal[c..c + 3]);
let n = if v.axis >= 3 { -column } else { column };
let packed = n.to_array().map(|v| (v.clamp(-1.0, 1.0) * 127.0) as i8);
let u = if job.scroll == 0.0 {
v.uv[0]
} else {
(v.uv[0] + job.scroll) % 1.0
};
let r = job.rect;
out[(job.dst + k) as usize] = Vertex {
pos: pos.to_array(),
uv: [r[0] + (r[2] - r[0]) * u, r[1] + (r[3] - r[1]) * v.uv[1]],
layer: job.overlay,
color: job.color,
light: job.light,
normal: [packed[0], packed[1], packed[2], -127],
glint_uv: if job.glint != 0 { v.uv } else { [0.0, 0.0] },
};
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
let instance = crate::gfx::test_gpu::instance();
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).ok()?;
pollster::block_on(adapter.request_device(&Default::default())).ok()
}
/// A cube's 24 vertices for part `part`, its faces on every axis.
fn cube(part: u32) -> Vec<SkinVertex> {
let mut out = Vec::new();
for axis in 0..6u32 {
for corner in 0..4u32 {
let a = (axis % 3) as usize;
let sign = if axis >= 3 { -0.25 } else { 0.25 };
let mut pos = [0.0f32; 3];
pos[a] = sign;
pos[(a + 1) % 3] = if corner & 1 == 0 { -0.25 } else { 0.25 } + 0.1 * part as f32;
pos[(a + 2) % 3] = if corner & 2 == 0 { -0.3 } else { 0.3 };
out.push(SkinVertex {
pos,
uv: [corner as f32 / 3.0, 0.125 * axis as f32 + 0.0625],
axis,
part,
});
}
}
out
}
fn part(m: glam::Mat4, normal: glam::Mat3) -> SkinPart {
SkinPart {
matrix: m.to_cols_array(),
normal: normal.to_cols_array(),
}
}
/// Two draws of a two-part model with turned, scaled parts, a scrolled
/// texture and the armour glint: the shader writes what `expand` does,
/// to the bit, after the vertices before them.
#[test]
fn the_shader_writes_what_the_cpu_reference_computes() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let Some(mut skin) = EntitySkin::new(&device, true) else {
eprintln!("no compute shaders: skipped");
return;
};
let model: Vec<SkinVertex> = cube(0).into_iter().chain(cube(1)).collect();
let turn =
glam::Mat4::from_quat(glam::Quat::from_euler(glam::EulerRot::ZYX, 0.3, -1.1, 2.0));
let base = glam::Mat4::from_translation(glam::Vec3::new(-3.7, 1.25, 9.1)) * turn;
let child = base
* glam::Mat4::from_translation(glam::Vec3::new(0.2, -0.4, 0.05))
* glam::Mat4::from_scale(glam::Vec3::new(1.0, 2.5, 0.5));
let normals = glam::Mat3::from_mat4(child).inverse().transpose();
let normals = glam::Mat3::from_cols(
normals.x_axis.normalize_or_zero(),
normals.y_axis.normalize_or_zero(),
normals.z_axis.normalize_or_zero(),
);
let frame = SkinFrame {
reset: false,
base: 0,
new_model: model.clone(),
parts: vec![
part(base, glam::Mat3::from_mat4(turn)),
part(child, normals),
part(base * turn, glam::Mat3::from_mat4(turn * turn)),
part(child * turn, glam::Mat3::IDENTITY),
],
jobs: vec![
SkinJob {
first: 0,
count: 48,
part: 0,
dst: 0,
rect: [0.25, 0.5, 0.375, 0.5625],
scroll: 0.0,
color: 0xff80_40ff,
light: 0xf0_70,
overlay: 1,
..Default::default()
},
SkinJob {
first: 24,
count: 24,
part: 2,
dst: 48,
rect: [0.0, 0.0, 0.0625, 0.0625],
scroll: 0.73,
color: u32::MAX,
light: 0xf0f0,
overlay: 36,
glint: 1,
..Default::default()
},
],
vertices: 72,
};
let expected = expand(&model, &frame);
// The posed vertices land after 5 others, which they leave alone.
let before = 5u32;
let out = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: u64::from(before + frame.vertices) * 44,
usage: EntitySkin::usage(true) | wgpu::BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let sentinel = vec![0xabu8; before as usize * 44];
queue.write_buffer(&out, 0, &sentinel);
let mut uploads = Uploads::new(&device);
let mut encoder = device.create_command_encoder(&Default::default());
assert!(skin.register(&device, &mut uploads, &mut encoder, &frame));
skin.dispatch(&device, &mut uploads, &mut encoder, &frame, &out, before);
let read = device.create_buffer(&wgpu::BufferDescriptor {
label: None,
size: out.size(),
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
encoder.copy_buffer_to_buffer(&out, 0, &read, 0, out.size());
uploads.finish(&encoder);
queue.submit([encoder.finish()]);
read.slice(..)
.map_async(wgpu::MapMode::Read, |r| r.unwrap());
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
let data = read.slice(..).get_mapped_range().unwrap();
assert_eq!(&data[..sentinel.len()], &sentinel[..]);
let got: &[Vertex] = bytemuck::cast_slice(&data[sentinel.len()..]);
for (i, (got, want)) in got.iter().zip(&expected).enumerate() {
assert_eq!(
bytemuck::bytes_of(got),
bytemuck::bytes_of(want),
"vertex {i}: {got:?} != {want:?}"
);
}
}
}
@@ -0,0 +1,110 @@
// Entity models posed on the GPU (gfx/entity_skin.rs): each job is one draw of
// a model (`mobs::render::draw`), and writes the same vertices the CPU path
// would push, in the same order, into the entity vertex buffer. One workgroup
// per job; its invocations walk the model's vertices.
//
// Words, not structs: every record is read and written as u32 words, so the
// layouts are the Rust ones byte for byte.
// A model vertex (7 words): position / 16, model UV, the face's normal as an
// axis (0..2 = +x, +y, +z; 3..5 = -x, -y, -z), the part's index in the model.
const MODEL_WORDS: u32 = 7u;
// A posed part (25 words): the geometry matrix (16, by columns), then the
// normal matrix (9, by columns), its columns already normalized when the
// part's scale is not uniform (`ModelNormals.trusted` false).
const PART_WORDS: u32 = 25u;
// A job (16 words): first model vertex, vertex count, first part, first output
// vertex, the texture rect (4 floats), scroll, colour, light, overlay,
// whether the overlay is the armour glint (its model UV in `glint_uv`), 3 spare.
const JOB_WORDS: u32 = 16u;
// `mesher::Vertex` (11 words): pos (3), uv (2), layer, color, light, normal
// (4 x i8), glint_uv (2).
const VERTEX_WORDS: u32 = 11u;
@group(0) @binding(0) var<storage, read> model: array<u32>;
@group(0) @binding(1) var<storage, read> parts: array<u32>;
@group(0) @binding(2) var<storage, read> jobs: array<u32>;
@group(0) @binding(3) var<storage, read_write> out: array<u32>;
fn f(words: u32) -> f32 {
return bitcast<f32>(words);
}
fn part_f(part: u32, i: u32) -> f32 {
return f(parts[part * PART_WORDS + i]);
}
// `mobs::render::scroll_u`.
fn scroll_u(u: f32, scroll: f32) -> f32 {
if scroll == 0.0 {
return u;
}
// Rust's `%` on floats: the remainder with the dividend's sign.
let s = u + scroll;
return s - trunc(s);
}
// `Polygon::vertex_normal`'s packing: clamped, times 127, cut toward zero.
fn pack(n: vec3<f32>) -> u32 {
let b = vec3<i32>(clamp(n, vec3(-1.0), vec3(1.0)) * 127.0);
return (u32(b.x) & 0xffu) | ((u32(b.y) & 0xffu) << 8u) | ((u32(b.z) & 0xffu) << 16u)
| (u32(-127 & 0xff) << 24u);
}
@compute @workgroup_size(64)
fn skin(
@builtin(workgroup_id) group: vec3<u32>,
@builtin(num_workgroups) groups: vec3<u32>,
@builtin(local_invocation_index) lane: u32,
) {
let job = group.y * groups.x + group.x;
if job >= arrayLength(&jobs) / JOB_WORDS {
return;
}
let j = job * JOB_WORDS;
let first = jobs[j];
let count = jobs[j + 1u];
let first_part = jobs[j + 2u];
let dst = jobs[j + 3u];
let rect = vec4(f(jobs[j + 4u]), f(jobs[j + 5u]), f(jobs[j + 6u]), f(jobs[j + 7u]));
let scroll = f(jobs[j + 8u]);
let color = jobs[j + 9u];
let light = jobs[j + 10u];
let overlay = jobs[j + 11u];
let glint = jobs[j + 12u] != 0u;
for (var k = lane; k < count; k += 64u) {
let v = (first + k) * MODEL_WORDS;
let p = vec3(f(model[v]), f(model[v + 1u]), f(model[v + 2u]));
let uv = vec2(f(model[v + 3u]), f(model[v + 4u]));
let axis = model[v + 5u];
let part = first_part + model[v + 6u];
// glam's Mat4::transform_point3, in its order.
var pos = vec3(part_f(part, 0u), part_f(part, 1u), part_f(part, 2u)) * p.x;
pos = vec3(part_f(part, 4u), part_f(part, 5u), part_f(part, 6u)) * p.y + pos;
pos = vec3(part_f(part, 8u), part_f(part, 9u), part_f(part, 10u)) * p.z + pos;
pos = vec3(part_f(part, 12u), part_f(part, 13u), part_f(part, 14u)) + pos;
// An axis normal times the normal matrix is that column, exactly.
let c = 16u + (axis % 3u) * 3u;
var n = vec3(part_f(part, c), part_f(part, c + 1u), part_f(part, c + 2u));
if axis >= 3u {
n = -n;
}
let o = (dst + k) * VERTEX_WORDS;
out[o] = bitcast<u32>(pos.x);
out[o + 1u] = bitcast<u32>(pos.y);
out[o + 2u] = bitcast<u32>(pos.z);
out[o + 3u] = bitcast<u32>(rect.x + (rect.z - rect.x) * scroll_u(uv.x, scroll));
out[o + 4u] = bitcast<u32>(rect.y + (rect.w - rect.y) * uv.y);
out[o + 5u] = overlay;
out[o + 6u] = color;
out[o + 7u] = light;
out[o + 8u] = pack(n);
if glint {
out[o + 9u] = bitcast<u32>(uv.x);
out[o + 10u] = bitcast<u32>(uv.y);
} else {
out[o + 9u] = 0u;
out[o + 10u] = 0u;
}
}
}
@@ -149,6 +149,9 @@ pub struct Hdr {
pub ui: f32,
/// Which pixels of a light source shine (`Masks`).
pub masks: Masks,
/// HDR lighting: off while a shader pack's post or material stage runs, the pack's own, and
/// with `HORIZON_HDR_LIGHTING=0`, to compare.
pub lighting: bool,
/// Calibration's test picture while it runs.
pub calibration: Option<Calibration>,
}
@@ -293,6 +296,7 @@ impl Hdr {
tiers: TIERS,
ui: 1.0,
masks: MASKS,
lighting: true,
calibration: None,
}
}
@@ -539,6 +543,11 @@ impl Hdr {
]);
params[5] = self.black.clamp(0.0, paper);
params[6] = self.ui.clamp(0.25, 1.0);
params[7] = if self.lighting {
scene_gain(self.peak, self.paper_white)
} else {
1.0
};
for (i, t) in self.tiers.iter().enumerate() {
params[8 + i] = t.share;
}
@@ -618,6 +627,12 @@ fn logo_mask(device: &wgpu::Device, queue: &wgpu::Queue) -> wgpu::TextureView {
.create_view(&Default::default())
}
/// What the strongest light multiplies a surface by, in HDR lighting: 4, which looked right
/// across biomes; less only where there is little headroom to ease into (`hdr.wgsl`).
fn scene_gain(peak: f32, paper_white: f32) -> f32 {
(peak / paper_white.max(1.0) * 1.75).clamp(1.0, 4.0)
}
#[cfg(test)]
mod tests {
use super::{Output, pick};
@@ -670,3 +685,18 @@ mod tests {
assert_eq!(pick(&caps(&[(F::Bgra8UnormSrgb, S::SRGB)]), None), None);
}
}
#[cfg(test)]
mod lighting_tests {
use super::scene_gain;
#[test]
fn the_lighting_scales_with_the_headroom() {
// Enough headroom: the full strength, whatever the paper white.
assert_eq!(scene_gain(1000.0, 430.0), 4.0);
assert_eq!(scene_gain(1060.0, 203.0), 4.0);
// Little headroom, and none.
assert_eq!(scene_gain(600.0, 400.0), 2.625);
assert_eq!(scene_gain(200.0, 480.0), 1.0);
}
}
@@ -969,6 +969,7 @@ mod initial_history_tests {
emissive_layers: &[],
shadow_only: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
clouds: None,
@@ -2226,4 +2226,112 @@ mod tests {
}
}
}
/// Horizon's graphics captions are English sentences that the language table keys by
/// themselves, so a caption, description or value that is not in `en_us.json` shows in
/// English in every language. Every text the three pages show, with each setting cycled
/// through its values (#1927).
#[test]
fn every_graphics_text_is_in_the_language_table() {
let (_, missed) = lang::scoped(lang::Language::load(&[], "de_de"), || {
for hdr in [false, true] {
let mut s = Settings::default();
s.horizon.hdr = hdr;
s.horizon.taa = true;
for level in [Level::Simple, Level::Advanced, Level::Hdr] {
for _ in 0..8 {
for row in extra_rows(level, &s) {
match row {
Row::Header(name) => {
lang::get(name);
}
Row::Setting(row) => {
row.item.label(&s);
let text = description(row.item, &s, row.text);
lang::get(text);
lang::get(row.cost.label());
if let Some(why) = row.item.blocked(&s) {
lang::get(why);
}
}
}
}
for row in extra_rows(level, &s) {
if let Row::Setting(Setting {
item: Item::Horizon(h),
..
}) = row
{
h.cycle(&mut s, false);
}
}
}
}
}
for button in ["Advanced...", "Simple...", "Done"] {
lang::get(button);
}
});
// `labelled` also looks up values that are already words in the language ("An") or
// numbers with a unit ("12 chunks", "Auto (8)"); those are not keys, and "FSR 3.1" is
// a name.
let missed: Vec<_> = missed
.into_iter()
.filter(|m| !m.starts_with(|c: char| c.is_ascii_digit()) && !m.starts_with("Auto ("))
.filter(|m| !["An", "Aus", "FSR 3.1", "key.keyboard.unknown"].contains(&m.as_str()))
.collect();
assert_eq!(missed, Vec::<String>::new());
}
/// Leaf Culling, Frame Generation and Shadow Cache came with English captions only (#1927).
#[test]
fn newer_graphics_settings_are_translated() {
let mut s = Settings::default();
s.horizon.leaf_culling = LeafCullingMode::Hollow;
s.horizon.entity_culling = EntityCullingMode::Asynchronous;
for (code, leaf, entity, leaf_text) in [
(
"de_de",
"Laub-Culling: Hohl",
"Objekt-Culling: Asynchron",
"Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.",
),
(
"pl_pl",
"Pomijanie liści: Puste",
"Pomijanie bytów: Asynchroniczne",
"Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.",
),
(
"uk_ua",
"Відсікання листя: Порожнє",
"Відсік. сутностей: Асинхронно",
"Прибирає грані між листям: швидше, але прогалини прозорі.",
),
(
"zh_cn",
"树叶剔除: 镂空",
"实体剔除: 异步",
"省略树叶之间的面:更快,但缝隙会透视。",
),
(
"fr_fr",
"Occlusion feuillage: Creux",
"Occlusion des entités: Asynchrone",
"Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.",
),
] {
let (shown, _) = lang::scoped(lang::Language::load(&[], code), || {
let item = |h| Item::Horizon(h);
(
item(H::LeafCulling).label(&s).plain(),
item(H::EntityCulling).label(&s).plain(),
lang::get(description(item(H::LeafCulling), &s, "")),
)
});
assert_eq!(
shown,
(leaf.into(), entity.into(), leaf_text.into()),
"{code}"
);
}
}
}
+20 -3
View File
@@ -20,6 +20,8 @@ struct Params {
black: f32,
// The HUD and menus' brightness as a share of paper white.
ui: f32,
// HDR lighting: what the strongest light multiplies a surface by, 1 for none.
scene: f32,
// Per tier, low to very bright (hdr.rs `Tier`): the share of the headroom
// its fully lifted pixels get.
share: vec4<f32>,
@@ -53,7 +55,7 @@ fn shows_world(c: vec3<f32>, w: vec3<f32>) -> bool {
return true;
}
let f = (c.r + c.g + c.b) / max(w.r + w.g + w.b, 1.0 / 255.0);
return f <= 1.01 && f > 0.5 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
return f <= 1.01 && f > 0.02 && all(abs(c - f * w) <= 0.03 * w + 1.0 / 255.0);
}
// Linear BT.709 to BT.2020 (ITU-R BT.2087), columns first.
@@ -168,8 +170,23 @@ fn calibration_nits(p: vec2<f32>, size: vec2<f32>) -> f32 {
// lower the brighter, and where in the band how much (hdr_mark). The hand
// writes its own alpha there (Hdr::hand_light_view): 1, or a light source it holds.
var lifted = false;
if params.world > 0.5 && params.peak > 1.0 && w.a < 0.75
&& (hand || (!covered && shows_world(c, w.rgb))) {
let shows = params.world > 0.5 && params.peak > 1.0
&& (hand || (!covered && shows_world(c, w.rgb)));
// HDR lighting: alpha between 0.76 and 1 is how much of it the surface gets. Vanilla's
// light is albedo times lightmap, so this is the lightmap going past 1. It eases into the
// peak from 60% of it, so bright textures keep their detail.
if shows && w.a >= 0.75 {
let top = max(light.r, max(light.g, light.b));
let gain = 1.0 + (params.scene - 1.0) * (1.0 - w.a) / 0.24;
let v = top * gain;
let knee = max(0.6 * params.peak, 1.0);
var out = v;
if v > knee && params.peak > knee {
out = knee + (params.peak - knee) * (1.0 - exp(-(v - knee) / (params.peak - knee)));
}
light *= select(gain, out / max(top, 1e-4), v > knee);
}
if shows && w.a < 0.75 {
let band = u32(clamp(floor((w.a + 0.005) / 0.175), 0.0, 3.0));
let tier = 3u - band;
let lift = 1.0 - clamp((w.a - 0.175 * f32(band)) / 0.165, 0.0, 1.0);
@@ -0,0 +1,154 @@
//! A data folder's files read from the disk once while a load holds them.
//! Several tables are built from the same folder (the recipes: the crafting
//! tables, the stations, the recipe book, brewing), and each used to list and
//! read every file again: on a hard disk or under a virus scanner, each open
//! costs. The first reader lists and reads the folder; later readers share
//! those bytes for as long as one of them holds the [`Folder`], then it is
//! gone (nothing is kept past the load).
use std::{
fs, io,
path::{Path, PathBuf},
sync::{Arc, Mutex, Weak},
};
/// One file: its path inside the folder without `.json` (`a/b`, as an id has
/// it), the file, and its bytes or the error reading it gave.
pub struct File {
pub name: String,
pub path: PathBuf,
bytes: Result<Box<[u8]>, (io::ErrorKind, String)>,
}
impl File {
pub fn bytes(&self) -> io::Result<&[u8]> {
self.bytes
.as_deref()
.map_err(|(kind, message)| io::Error::new(*kind, message.clone()))
}
}
/// Every `.json` file under a folder, its subfolders' too, in name order.
pub struct Folder {
pub files: Vec<File>,
}
static OPEN: Mutex<Vec<(PathBuf, Weak<Folder>)>> = Mutex::new(Vec::new());
fn find(open: &[(PathBuf, Weak<Folder>)], dir: &Path) -> Option<Arc<Folder>> {
open.iter()
.find(|(d, _)| d == dir)
.and_then(|(_, f)| f.upgrade())
}
/// `dir`'s files if another reader holds them, without reading anything.
pub fn held(dir: &Path) -> Option<Arc<Folder>> {
find(&OPEN.lock().unwrap_or_else(|e| e.into_inner()), dir)
}
/// The `.json` files under `dir`: shared with any other reader holding them,
/// else listed and read now. An error when `dir` itself can't be listed.
pub fn read(dir: &Path) -> io::Result<Arc<Folder>> {
if let Some(folder) = held(dir) {
return Ok(folder);
}
let files = list(dir)?
.into_iter()
.map(|(name, path)| {
let bytes = fs::read(&path)
.map(Vec::into_boxed_slice)
.map_err(|e| (e.kind(), e.to_string()));
File { name, path, bytes }
})
.collect();
let folder = Arc::new(Folder { files });
let mut open = OPEN.lock().unwrap_or_else(|e| e.into_inner());
// Another reader may have read it meanwhile: share one copy.
if let Some(theirs) = find(&open, dir) {
return Ok(theirs);
}
open.retain(|(_, f)| f.strong_count() > 0);
open.push((dir.to_owned(), Arc::downgrade(&folder)));
Ok(folder)
}
/// The `.json` files under `dir` and its subfolders, in name order: each
/// one's path inside the folder without the extension (`a/b`) and its file.
/// An error when `dir` itself can't be listed; a subfolder or an entry that
/// can't be is warned and skipped, as vanilla's `PathPackResources` keeps
/// what it found.
pub fn list(dir: &Path) -> io::Result<Vec<(String, PathBuf)>> {
let mut files = Vec::new();
let mut stack = vec![fs::read_dir(dir)?];
while let Some(entries) = stack.pop() {
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
log::warn!("Horizon: failed to list an entry of {}: {e}", dir.display());
continue;
}
};
let path = entry.path();
if path.is_dir() {
match fs::read_dir(&path) {
Ok(list) => stack.push(list),
Err(e) => log::warn!("Horizon: failed to list {}: {e}", path.display()),
}
} else if path.extension().is_some_and(|e| e == "json") {
files.push(path);
}
}
}
let mut named: Vec<(String, PathBuf)> = files
.into_iter()
.map(|path| {
let inside = path.strip_prefix(dir).unwrap_or(&path).with_extension("");
let name: Vec<_> = inside.iter().map(|c| c.to_string_lossy()).collect();
(name.join("/"), path)
})
.collect();
// Vanilla lists resources by id, in a map keyed by it
// (`FallbackResourceManager`): `crushing` before `crushing/iron`, where
// a path's components put the subfolder's file first.
named.sort_by(|a, b| a.0.cmp(&b.0));
Ok(named)
}
#[cfg(test)]
mod tests {
use super::*;
fn folder(tag: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("horizon-json-folder-{tag}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("brewing")).unwrap();
fs::write(dir.join("stick.json"), b"{\"a\":1}").unwrap();
fs::write(dir.join("brewing/awkward.json"), b"{}").unwrap();
fs::write(dir.join("notes.txt"), b"not json").unwrap();
dir
}
#[test]
fn readers_share_the_bytes_while_one_holds_them_and_read_again_after() {
let dir = folder("share");
let first = read(&dir).unwrap();
let names: Vec<_> = first.files.iter().map(|f| f.name.as_str()).collect();
assert_eq!(names, ["brewing/awkward", "stick"]);
assert_eq!(first.files[1].bytes().unwrap(), b"{\"a\":1}");
// Held: a change on the disk isn't seen, the copy read is shared.
fs::write(dir.join("stick.json"), b"{\"a\":2}").unwrap();
let second = read(&dir).unwrap();
assert!(Arc::ptr_eq(&first, &second));
drop((first, second));
// Let go: the next reader reads the folder again.
assert_eq!(read(&dir).unwrap().files[1].bytes().unwrap(), b"{\"a\":2}");
fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_folder_that_cannot_be_listed_is_an_error() {
let dir =
std::env::temp_dir().join(format!("horizon-json-folder-none-{}", std::process::id()));
assert!(read(&dir).is_err());
}
}
+2
View File
@@ -11,6 +11,7 @@ pub mod biome;
pub mod block_edit;
pub mod block_entities;
pub mod block_entity_anim;
pub mod block_entity_cem;
pub mod block_entity_render;
pub mod border_render;
pub mod breaking;
@@ -48,6 +49,7 @@ pub mod hud_overlays;
pub mod inventory_effects;
pub mod java_text;
pub mod jigsaw_block;
pub mod json_folder;
pub mod level_loading;
pub mod light;
pub mod lod;
+157 -47
View File
@@ -4,6 +4,11 @@
//! framebuffer pixels wide, before the screen is drawn over it. Works in the
//! frame's own (gamma) values, as vanilla's main target does, and needs a
//! swapchain that can be copied from.
//!
//! On a software adapter (WARP, llvmpipe) the rounds run at half the frame's
//! width and height with half the radius: the same blur, a quarter of the
//! pixels. At full size they took 250 ms of a CPU per frame at 1440x900, and
//! every blurred menu ran at 3 FPS (#5185).
/// `blur.json`: the passes' directions, ping-ponging between the copied
/// frame and a swap texture; the last one writes the frame.
@@ -27,15 +32,20 @@ pub struct MenuBlur {
layout: wgpu::BindGroupLayout,
pipeline: wgpu::RenderPipeline,
sampler: wgpu::Sampler,
/// One per pass, then the half-size copy's.
uniforms: Vec<wgpu::Buffer>,
/// The copied frame (`minecraft:main`) and `swap`.
/// The copied frame (`minecraft:main`) and `swap`; at half size, then the
/// frame copied at full size.
targets: Vec<(wgpu::Texture, wgpu::TextureView)>,
size: [u32; 2],
format: wgpu::TextureFormat,
/// Blur at half size (`new`'s `software`).
half: bool,
}
impl MenuBlur {
/// `format` is the swapchain's; the blur works in its non-sRGB view.
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat) -> Self {
/// `format` is the swapchain's; the blur works in its non-sRGB view. `software`:
/// the adapter renders on the CPU, so the blur runs at half size.
pub fn new(device: &wgpu::Device, format: wgpu::TextureFormat, software: bool) -> Self {
let format = format.remove_srgb_suffix();
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Menu blur"),
@@ -111,8 +121,7 @@ impl MenuBlur {
address_mode_v: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
let uniforms = PASSES
.iter()
let uniforms = (0..=PASSES.len())
.map(|_| {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Menu blur pass"),
@@ -130,6 +139,15 @@ impl MenuBlur {
targets: Vec::new(),
size: [0, 0],
format,
half: software,
}
}
/// The size the blur's rounds run at for a frame of `size`.
fn work_size(&self, size: [u32; 2]) -> [u32; 2] {
if self.half {
size.map(|s| s.div_ceil(2))
} else {
size
}
}
fn ensure(&mut self, device: &wgpu::Device, size: [u32; 2]) {
@@ -137,8 +155,15 @@ impl MenuBlur {
return;
}
self.size = size;
self.targets = (0..2)
.map(|_| {
let work = self.work_size(size);
let sizes = if self.half {
vec![work, work, size]
} else {
vec![work, work]
};
self.targets = sizes
.into_iter()
.map(|size| {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Menu blur target"),
size: wgpu::Extent3d {
@@ -180,65 +205,112 @@ impl MenuBlur {
mut timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
self.ensure(device, size);
let work = self.work_size(size);
let copy = if self.half { 2 } else { 0 };
encoder.copy_texture_to_texture(
frame.as_image_copy(),
self.targets[0].0.as_image_copy(),
self.targets[copy].0.as_image_copy(),
wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
);
let radius = if self.half { radius / 2.0 } else { radius };
if self.half {
// A radius of 0 samples each half-size pixel's 2x2 block at its shared
// corner: their mean.
let data = Uniform {
size_dir: [size[0] as f32, size[1] as f32, 0.0, 0.0],
radius: [0.0; 4],
};
let (source, output) = (&self.targets[2].1, &self.targets[0].1);
self.pass(
device,
queue,
encoder,
PASSES.len(),
data,
source,
output,
None,
);
}
for (i, dir) in PASSES.iter().enumerate() {
let data = Uniform {
size_dir: [size[0] as f32, size[1] as f32, dir[0], dir[1]],
size_dir: [work[0] as f32, work[1] as f32, dir[0], dir[1]],
radius: [radius, 0.0, 0.0, 0.0],
};
queue.write_buffer(&self.uniforms[i], 0, bytemuck::bytes_of(&data));
let source = &self.targets[i % 2].1;
// The last pass writes the frame; from half size it samples up as it blurs.
let output = if i + 1 == PASSES.len() {
target
} else {
&self.targets[(i + 1) % 2].1
};
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu blur pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[i].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: output,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.as_deref_mut().and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
self.pass(
device,
queue,
encoder,
i,
data,
source,
output,
timing.as_deref_mut(),
);
}
}
/// One `box_blur` pass from `source` into `output` with uniform buffer `slot`.
#[allow(clippy::too_many_arguments)]
fn pass(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
slot: usize,
data: Uniform,
source: &wgpu::TextureView,
output: &wgpu::TextureView,
timing: Option<&mut crate::gfx::pack_timing::PassTiming>,
) {
queue.write_buffer(&self.uniforms[slot], 0, bytemuck::bytes_of(&data));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Menu blur pass"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(source),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: self.uniforms[slot].as_entire_binding(),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: output,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Menu blur pass"),
timestamp_writes: timing.and_then(|t| t.writes("Menu blur")),
color_attachments: &attachments,
..Default::default()
});
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.draw(0..3, 0..1);
}
}
#[cfg(test)]
@@ -318,7 +390,7 @@ mod tests {
else {
return;
};
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb);
let mut blur = MenuBlur::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, false);
let size = [64, 8];
let first = blurred(&device, &queue, &mut blur, size);
// The stripes are blurred into greys.
@@ -327,6 +399,44 @@ mod tests {
assert_eq!(blurred(&device, &queue, &mut blur, size), first);
}
/// On a software adapter the blur runs at half size and still blurs the
/// frame much as at full size: stripes two pixels wide turn to an even grey.
#[test]
fn the_half_size_blur_blurs_like_the_full_size_one() {
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
let Ok(adapter) = pollster::block_on(instance.request_adapter(&Default::default())) else {
return;
};
let Ok((device, queue)) = pollster::block_on(adapter.request_device(&Default::default()))
else {
return;
};
let format = wgpu::TextureFormat::Rgba8UnormSrgb;
let size = [64, 8];
let full = blurred(
&device,
&queue,
&mut MenuBlur::new(&device, format, false),
size,
);
let half = blurred(
&device,
&queue,
&mut MenuBlur::new(&device, format, true),
size,
);
// Away from the edges, both are within a few levels of the stripes' mean grey.
for row in 0..size[1] as usize {
for x in 8..56 {
let at = (row * size[0] as usize + x) * 4;
for (name, frame) in [("full", &full), ("half", &half)] {
let v = frame[at];
assert!((120..=136).contains(&v), "{name} size: {v} at ({x}, {row})");
}
}
}
}
#[test]
fn box_blur_shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("menu_blur.wgsl")).unwrap();
+360 -123
View File
@@ -10,9 +10,9 @@ mod sides;
use crate::connected::{Pick, TileLayer};
use crate::{
assets::{Assets, RenderAssets},
biome::Biomes,
biome::{Biomes, GrassModifier},
blocks::{BlockFacts, RenderShape},
chunk::{Chunk, MIN_Y},
chunk::{Chunk, Light, MIN_Y},
graphics::LeafCullingMode,
models::catalog::BakedFace,
};
@@ -73,6 +73,15 @@ pub struct TerrainVertex {
/// RGBA8: tint multiplied by directional shade and ambient occlusion.
pub color: u32,
}
/// `v.round().clamp(0.0, 65535.0) as u16`, without the `roundf` call x86-64's
/// baseline instruction set makes of `f32::round`: clamped first (the same
/// result), the fraction is exact below 2^23, and every float above is whole.
#[inline]
fn round_to_u16(v: f32) -> u16 {
let v = v.clamp(0.0, f32::from(u16::MAX));
let whole = v as u32;
(whole + u32::from(v - whole as f32 >= 0.5)) as u16
}
/// Fixed-point steps per unit of `TerrainVertex` UVs, which span `[0, 2)`.
pub const UV_STEPS: f32 = 32768.0;
/// Fixed-point steps per block of distant-terrain UVs (`TerrainVertex::tiled`),
@@ -82,7 +91,7 @@ impl TerrainVertex {
pub fn new(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
Self {
pos,
uv: uv.map(|v| (v * UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
uv: uv.map(|v| round_to_u16(v * UV_STEPS)),
layer,
light,
color,
@@ -93,7 +102,7 @@ impl TerrainVertex {
pub fn tiled(pos: [f32; 3], uv: [f32; 2], layer: u16, color: u32, light: u16) -> Self {
Self {
pos,
uv: uv.map(|v| (v * TILED_UV_STEPS).round().clamp(0.0, f32::from(u16::MAX)) as u16),
uv: uv.map(|v| round_to_u16(v * TILED_UV_STEPS)),
layer,
light,
color,
@@ -116,6 +125,30 @@ mod vertex_tests {
}
}
#[test]
fn uv_rounding_matches_f32_round() {
let reference = |v: f32| v.round().clamp(0.0, f32::from(u16::MAX)) as u16;
let mut values = vec![
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
-0.0,
0.5,
1.5,
65534.5,
1e9,
];
for bits in (0..u32::MAX).step_by(9973) {
values.push(f32::from_bits(bits));
}
for i in 0..=140_000 {
values.push(i as f32 * 0.5 - 2.0);
values.push(i as f32 * 0.5 - 2.0 + f32::EPSILON);
}
for v in values {
assert_eq!(super::round_to_u16(v), reference(v), "{v:e}");
}
}
#[test]
fn other_uvs_round_within_a_step() {
// A flowing fluid's rotated UV.
let uv = [0.5 + 0.25 * 0.3f32.sin(), 0.5 - 0.25 * 0.3f32.cos()];
@@ -333,94 +366,112 @@ pub const ALL_VISIBLE: u64 = (1 << 36) - 1;
/// VisGraph.resolve: flood fill non-opaque cells and connect the faces each region touches.
#[cfg(test)]
fn visibility(pad: &Padded, info: &[Info]) -> u64 {
visibility_reusing(pad, info, &mut Vec::new())
let solid: Vec<bool> = info.iter().map(|i| i.solid).collect();
let mut rows = Vec::new();
opaque_rows(pad, &solid, &mut rows);
visibility_rows(&open_rows(&rows))
}
fn visibility_reusing(pad: &Padded, info: &[Info], stack: &mut Vec<usize>) -> u64 {
// The all-visible shortcut can leave a partially filled stack behind.
stack.clear();
// Opaque cells are already visited: resolve each state's opacity once,
// then reuse this map for the flood fill and the uniform-section shortcuts.
let mut seen = [false; 4096];
let mut opaque_count = 0;
for y in 0..16 {
for z in 0..16 {
let row = pi(0, y, z);
for x in 0..16 {
let opaque = info[pad.blocks[row + x] as usize].solid;
seen[(y as usize) << 8 | (z as usize) << 4 | x] = opaque;
opaque_count += usize::from(opaque);
}
}
/// The section's non-opaque cells by row (`y << 4 | z`, bit x) from the padded
/// opacity rows (`MeshScratch::opaque`).
fn open_rows(opaque: &[u32]) -> [u16; 256] {
std::array::from_fn(|r| {
let (y, z) = ((r >> 4) as i32, (r & 15) as i32);
!(opaque[((y + B) as usize) * P + (z + B) as usize] >> B) as u16
})
}
/// The cells of `open` connected to `seeds` (a subset) within the row: an
/// occluded fill each way, doubling its reach at each step.
#[inline]
fn fill_row(seeds: u16, open: u16) -> u16 {
let (mut up, mut through_up) = (seeds, open);
let (mut down, mut through_down) = (seeds, open);
for step in [1, 2, 4, 8] {
up |= through_up & up << step;
through_up &= through_up << step;
down |= through_down & down >> step;
through_down &= through_down >> step;
}
if opaque_count == 0 {
up | down
}
/// `visibility` on bit rows, one region at a time: a region grows a row at
/// a time (each row filled along its open runs at once) by sweeps over the
/// rows it can reach, until a sweep adds nothing.
fn visibility_rows(open: &[u16; 256]) -> u64 {
let open_count: u32 = open.iter().map(|r| r.count_ones()).sum();
if open_count == 4096 {
return ALL_VISIBLE;
}
if opaque_count == 4096 {
if open_count == 0 {
return 0;
}
let mut unvisited = *open;
let mut region = [0u16; 256];
let mut result = 0u64;
for start in 0..4096usize {
if seen[start] {
continue;
}
let mut faces = 0u8;
seen[start] = true;
stack.push(start);
while let Some(c) = stack.pop() {
let (x, y, z) = ((c & 15) as i32, (c >> 8) as i32, ((c >> 4) & 15) as i32);
if y == 0 {
faces |= 1 << 0;
for start in 0..256 {
while unvisited[start] != 0 {
let seed = unvisited[start] & unvisited[start].wrapping_neg();
region[start] = fill_row(seed, unvisited[start]);
let (mut lo, mut hi) = (start, start);
loop {
let mut grown = false;
let (first, last) = (lo.saturating_sub(16), (hi + 16).min(255));
for r in (first..=last).chain((first..=last).rev()) {
let mut near = region[r];
if r >= 16 {
near |= region[r - 16];
}
if r + 16 < 256 {
near |= region[r + 16];
}
if r & 15 != 0 {
near |= region[r - 1];
}
if r & 15 != 15 {
near |= region[r + 1];
}
let reached = near & unvisited[r];
if reached & !region[r] != 0 {
region[r] = fill_row(reached, unvisited[r]);
(lo, hi) = (lo.min(r), hi.max(r));
grown = true;
}
}
if !grown {
break;
}
}
if y == 15 {
faces |= 1 << 1;
}
if z == 0 {
faces |= 1 << 2;
}
if z == 15 {
faces |= 1 << 3;
}
if x == 0 {
faces |= 1 << 4;
}
if x == 15 {
faces |= 1 << 5;
let mut faces = 0u8;
for r in lo..=hi {
let row = std::mem::take(&mut region[r]);
if row == 0 {
continue;
}
unvisited[r] &= !row;
let (y, z) = (r >> 4, r & 15);
faces |= u8::from(y == 0)
| u8::from(y == 15) << 1
| u8::from(z == 0) << 2
| u8::from(z == 15) << 3
| u8::from(row & 1 != 0) << 4
| u8::from(row & 0x8000 != 0) << 5;
}
// A single connected region touching all six faces sets every
// pair (including the diagonal); unvisited cavities cannot add more.
if faces == 0b11_1111 {
return ALL_VISIBLE;
}
for (dx, dy, dz) in [
(1, 0, 0),
(-1, 0, 0),
(0, 1, 0),
(0, -1, 0),
(0, 0, 1),
(0, 0, -1),
] {
let (nx, ny, nz) = (x + dx, y + dy, z + dz);
if !(0..16).contains(&nx) || !(0..16).contains(&ny) || !(0..16).contains(&nz) {
continue;
}
let n = (ny << 8 | nz << 4 | nx) as usize;
if !seen[n] {
seen[n] = true;
stack.push(n);
}
}
}
for a in 0..6 {
if faces & (1 << a) != 0 {
for b in 0..6 {
if faces & (1 << b) != 0 {
result |= 1 << (a * 6 + b);
for a in 0..6 {
if faces & (1 << a) != 0 {
for b in 0..6 {
if faces & (1 << b) != 0 {
result |= 1 << (a * 6 + b);
}
}
}
}
}
if result == ALL_VISIBLE {
return result;
if result == ALL_VISIBLE {
return result;
}
}
}
result
@@ -505,6 +556,13 @@ pub struct MeshFacts {
}
pub struct MeshTables {
info: Vec<Info>,
/// `Info::solid` by state, packed apart: the opacity mask of a section
/// reads 8,000 states, and a few bytes each stay in cache where `Info`
/// does not.
solid: Vec<bool>,
/// By state, bit `d` set where its occlusion face toward direction `d` is the
/// full block (`Info::face` 1): a face looking at it never shows.
full_faces: Vec<u8>,
pub visibility: FaceVisibility,
water_still: u16,
water_flow: u16,
@@ -673,6 +731,11 @@ impl MeshTables {
});
}
Ok(Self {
solid: info.iter().map(|i| i.solid).collect(),
full_faces: info
.iter()
.map(|i| (0..6).fold(0, |bits, d| bits | u8::from(i.face[d] == 1) << d))
.collect(),
info,
visibility,
water_still: layer("minecraft:block/water_still")?,
@@ -842,49 +905,107 @@ impl Padded {
}
fn fill(&mut self, region: &[[Option<&Chunk>; 3]; 3], sy: i32, own: &mut [u32; 4096]) {
self.blocks.resize(P * P * P, 0);
self.blocks.fill(0);
self.light.resize(P * P * P, 0);
let (blocks, light) = (&mut self.blocks, &mut self.light);
let base_y = region[1][1].map_or(MIN_Y, Chunk::min_y) + sy * 16;
// The section itself is unpacked in one pass; the border cells of the
// neighbouring sections are read one by one.
// The section itself is unpacked in one pass and copied row by row;
// the border cells of the neighbouring sections are read in runs, from
// sections found once per layer.
own.fill(0);
let center = region[1][1].map(|c| &c.sections[sy as usize]);
if let Some(section) = center {
section.unpack(own);
let center = region[1][1];
if let Some(c) = center {
c.sections[sy as usize].unpack(own);
}
for y in -B..16 + B {
let wy = base_y + y;
for z in -B..16 + B {
for x in -B..16 + B {
let chunk = region[((z + 16) >> 4) as usize][((x + 16) >> 4) as usize];
let i = pi(x, y, z);
if let Some(c) = chunk {
blocks[i] =
if (0..16).contains(&x) && (0..16).contains(&y) && (0..16).contains(&z)
{
own[(y << 8 | z << 4 | x) as usize]
} else {
c.get(x & 15, wy, z & 15)
};
light[i] = c.light(x & 15, wy, z & 15);
} else if let Some(c) = region[1][1] {
// A neighbour not loaded continues this chunk's edge
// column. As lit air, the outermost chunks' water drew
// a wall toward it (fluids show both sides), a light
// sheet in the water where far terrain carries on.
// Vanilla draws no chunk without its neighbours.
let (ex, ez) = (x.clamp(0, 15), z.clamp(0, 15));
blocks[i] = c.get(ex, wy, ez);
light[i] = c.light(ex, wy, ez);
} else {
light[i] = 0xF0;
// Where each chunk column's cells of this layer come from, found once.
let sources: [[Source; 3]; 3] = std::array::from_fn(|rz| {
std::array::from_fn(|rx| match region[rz][rx] {
None => Source::Missing,
// `Chunk::get` and `Chunk::light` outside the column's sections.
Some(c) => {
let s = (wy - c.min_y()) >> 4;
match c.sections.get(s as usize).filter(|_| s >= 0) {
Some(section) => Source::Section(section),
None => Source::Outside(if s < 0 { 0 } else { 0xF0 }),
}
}
}
})
});
for z in -B..16 + B {
let row = pi(-B, y, z);
let rz = ((z + 16) >> 4) as usize;
let (west, rest) = blocks[row..row + P].split_at_mut(B as usize);
let (middle, east) = rest.split_at_mut(16);
let (west_light, rest) = light[row..row + P].split_at_mut(B as usize);
let (middle_light, east_light) = rest.split_at_mut(16);
let own_row = (rz == 1 && (0..16).contains(&y))
.then(|| &own[(y << 8 | z << 4) as usize..][..16]);
let at = |x: i32| crate::chunk::index(x, wy, z);
let [w, m, e] = &sources[rz];
let edge = (center, wy, z);
fill_run(west, west_light, w, None, at(-B), -B, edge);
fill_run(middle, middle_light, m, own_row, at(0), 0, edge);
fill_run(east, east_light, e, None, at(16), 16, edge);
}
}
}
}
/// Where the cells of one chunk column at one height come from (`Padded::fill`).
enum Source<'a> {
/// A neighbour not loaded.
Missing,
/// Above or below the column's sections: air with this light.
Outside(u8),
Section(&'a crate::chunk::Section),
}
/// Fills one run of a padded row, `blocks.len()` cells from x = `x0`, starting
/// at section index `at`; `own` is the centre section's row, already unpacked.
#[inline(always)]
fn fill_run(
blocks: &mut [u32],
light: &mut [u8],
source: &Source,
own: Option<&[u32]>,
at: usize,
x0: i32,
(center, wy, z): (Option<&Chunk>, i32, i32),
) {
match source {
Source::Section(section) => {
match own {
Some(own) => blocks.copy_from_slice(own),
None => section.blocks.get_run(at, blocks),
}
match &section.light {
Light::Uniform(v) => light.fill(*v),
Light::Full(all) => light.copy_from_slice(&all[at..at + light.len()]),
}
}
Source::Outside(v) => {
blocks.fill(0);
light.fill(*v);
}
// A neighbour not loaded continues this chunk's edge column. As lit
// air, the outermost chunks' water drew a wall toward it (fluids show
// both sides), a light sheet in the water where far terrain carries
// on. Vanilla draws no chunk without its neighbours.
Source::Missing => match center {
Some(c) => {
let ez = z.clamp(0, 15);
for (k, (block, light)) in blocks.iter_mut().zip(light.iter_mut()).enumerate() {
let ex = (x0 + k as i32).clamp(0, 15);
*block = c.get(ex, wy, ez);
*light = c.light(ex, wy, ez);
}
}
None => {
blocks.fill(0);
light.fill(0xF0);
}
},
}
}
/// Per-worker temporary arrays. They never hold resource-pack references or
/// geometry still owned by the renderer, and total about 150 KiB per worker
/// (the leaf mask adds 8,000 cells, depths and a queue).
@@ -892,13 +1013,36 @@ impl Padded {
pub struct MeshScratch {
padded: Padded,
own: Option<Box<[u32; 4096]>>,
visibility: Vec<usize>,
leaf: leaf_culling::LeafMask,
/// Per padded row (`pi(-B, y, z) / P`), bit `x + B` set where the block
/// is `isSolidRender`.
opaque: Vec<u32>,
}
/// Fills `rows` with the padded section's opacity, one bit per block (`MeshScratch::opaque`).
fn opaque_rows(pad: &Padded, solid: &[bool], rows: &mut Vec<u32>) {
rows.clear();
rows.extend(pad.blocks.as_chunks::<P>().0.iter().map(|row| {
row.iter().enumerate().fold(0u32, |bits, (x, &id)| {
bits | u32::from(solid[id as usize]) << x
})
}));
}
/// Whether the block at `(x, y, z)` (section-local) and its six neighbours
/// are all `isSolidRender`: none of its faces can show.
#[inline]
fn hidden(rows: &[u32], x: i32, y: i32, z: i32) -> bool {
let row = |y: i32, z: i32| rows[((y + B) as usize) * P + (z + B) as usize];
let r = row(y, z);
let around =
r & r << 1 & r >> 1 & row(y - 1, z) & row(y + 1, z) & row(y, z - 1) & row(y, z + 1);
around >> (x + B) & 1 != 0
}
#[cfg(test)]
mod leaf_culling_tests;
#[cfg(test)]
mod reuse_tests;
#[cfg(test)]
mod tint_tests;
/// Set on a terrain vertex's sprite layer when its texture alpha must be ignored.
pub const OPAQUE_LAYER: u16 = 1 << 15;
/// The bits of `TerrainVertex::color`'s alpha byte that hold how brightly its face shines
@@ -990,6 +1134,12 @@ enum Resolver {
/// layer plus the blend radius around it, and the blended colours per column.
struct Layer {
zoomed: Vec<u16>,
/// Each resolver's colour of every cell of `zoomed`, filled on first use:
/// a cell is in the blend square of up to `(2r+1)^2` columns.
cells: [Vec<u32>; 4],
/// `BiomeZoom::corner_jitter` of the quart cells `zoomed` reaches, by
/// their offset from the first: each is shared by up to 16 cells of the layer.
jitter: Vec<Option<[[f64; 3]; 8]>>,
colors: [[u32; 256]; 4],
}
/// `ClientLevel.calculateBlockTint` behind a per-layer `BlockTintCache`: each
@@ -1005,6 +1155,8 @@ struct Tints<'a> {
/// Chunk x, chunk z and the section's lowest block y.
origin: [i32; 3],
layers: [Option<Box<Layer>>; 16],
/// `uniform_biome`, once computed.
uniform: Option<Option<u16>>,
}
impl<'a> Tints<'a> {
fn new(
@@ -1022,6 +1174,7 @@ impl<'a> Tints<'a> {
plains: biomes.id("minecraft:plains"),
origin,
layers: Default::default(),
uniform: None,
}
}
/// The biome of block `(x, y, z)` (section-local x/z, y in 0..16), as `BiomeManager.getBiome`
@@ -1041,6 +1194,16 @@ impl<'a> Tints<'a> {
[x, y, z]: [i32; 3],
) -> u16 {
let q = zoom.quart_at_block(BlockPos::new(cx * 16 + x, base_y + y, cz * 16 + z));
Self::quart_biome(region, plains, [cx, cz], q)
}
/// The biome of quart cell `q` in the loaded chunks around chunk `(cx, cz)`, plains where
/// there is none.
fn quart_biome(
region: &[[Option<&Chunk>; 3]; 3],
plains: u16,
[cx, cz]: [i32; 2],
q: [i32; 3],
) -> u16 {
let (rx, rz) = ((q[0] >> 2) - cx + 1, (q[2] >> 2) - cz + 1);
if (0..3).contains(&rx) && (0..3).contains(&rz) {
region[rz as usize][rx as usize].map_or(plains, |c| {
@@ -1050,13 +1213,59 @@ impl<'a> Tints<'a> {
plains
}
}
/// The biome every block of the section's blend squares zooms to, when they
/// all do: `quart_at_block` picks one of the eight quart cells at its base
/// cell plus 0 or 1 on each axis, so if every such cell the section reaches
/// holds one biome, every zoomed cell is that biome.
fn uniform_biome(&mut self) -> Option<u16> {
if let Some(uniform) = self.uniform {
return uniform;
}
let (r, [cx, cz, base_y]) = (self.radius, self.origin);
// The base cells of blocks lo..=hi, and the cell past the last.
let span = |lo: i32, hi: i32| (lo.wrapping_sub(2) >> 2)..=(hi.wrapping_sub(2) >> 2) + 1;
let (xs, ys, zs) = (
span(cx * 16 - r, cx * 16 + 15 + r),
span(base_y, base_y + 15),
span(cz * 16 - r, cz * 16 + 15 + r),
);
let first = Self::quart_biome(
self.region,
self.plains,
[cx, cz],
[*xs.start(), *ys.start(), *zs.start()],
);
let uniform = ys
.flat_map(|y| zs.clone().map(move |z| (y, z)))
.flat_map(|(y, z)| xs.clone().map(move |x| [x, y, z]))
.all(|q| Self::quart_biome(self.region, self.plains, [cx, cz], q) == first)
.then_some(first);
self.uniform = Some(uniform);
uniform
}
/// The blended tint of block `(x, y, z)` (section-local x/z, y in 0..16).
fn get(&mut self, resolver: Resolver, x: i32, y: i32, z: i32) -> u32 {
// One biome over every blend square: the mean of (2r+1)^2 equal
// colours is that colour, unless it varies by position (swamp grass).
if let Some(biome) = self.uniform_biome() {
let info = self.biomes.get(biome);
match resolver {
Resolver::Grass if !matches!(info.modifier, GrassModifier::Swamp) => {
return self.biomes.grass(biome, 0.0, 0.0) & 0xFF_FFFF;
}
Resolver::Grass => {}
Resolver::Foliage => return info.foliage & 0xFF_FFFF,
Resolver::Dry => return info.dry_foliage & 0xFF_FFFF,
Resolver::Water => return info.water & 0xFF_FFFF,
}
}
let r = self.radius;
let dim = (16 + 2 * r) as usize;
let layer = self.layers[y as usize].get_or_insert_with(|| {
Box::new(Layer {
zoomed: vec![NO_BIOME; dim * dim],
cells: Default::default(),
jitter: Vec::new(),
colors: [[UNSET; 256]; 4],
})
});
@@ -1065,21 +1274,45 @@ impl<'a> Tints<'a> {
if cached != UNSET {
return cached;
}
let [cx, cz, _] = self.origin;
let (bx, bz) = (cx * 16, cz * 16);
let [cx, cz, base_y] = self.origin;
let (bx, by, bz) = (cx * 16, base_y + y, cz * 16);
let (mut red, mut green, mut blue) = (0, 0, 0);
let cells = &mut layer.cells[resolver as usize];
if cells.is_empty() {
cells.resize(dim * dim, UNSET);
}
for dz in -r..=r {
for dx in -r..=r {
let cell = ((z + dz + r) as usize) * dim + (x + dx + r) as usize;
let known = cells[cell];
if known != UNSET {
red += known >> 16 & 255;
green += known >> 8 & 255;
blue += known & 255;
continue;
}
let mut biome = layer.zoomed[cell];
if biome == NO_BIOME {
biome = Self::biome_id(
&self.zoom,
self.region,
self.plains,
self.origin,
[x + dx, y, z + dz],
);
// BiomeManager.getBiome over the loaded chunks (plains
// where there is none): `quart_at_block`, its cell's
// corner jitter mixed once per layer.
let block = BlockPos::new(bx + x + dx, by, bz + z + dz);
let base = [block.x, block.y, block.z].map(|v| v.wrapping_sub(2) >> 2);
let first = [bx - r, bz - r].map(|v| v.wrapping_sub(2) >> 2);
let span = dim / 4 + 2;
let at = (base[2] - first[1]) as usize * span + (base[0] - first[0]) as usize;
if layer.jitter.is_empty() {
layer.jitter.resize(span * span, None);
}
let jitter =
layer.jitter[at].get_or_insert_with(|| self.zoom.corner_jitter(base));
let corner = BiomeZoom::select_corner(block, jitter);
let q = [
base[0].wrapping_add((corner >> 2 & 1) as i32),
base[1].wrapping_add((corner >> 1 & 1) as i32),
base[2].wrapping_add((corner & 1) as i32),
];
biome = Self::quart_biome(self.region, self.plains, [cx, cz], q);
layer.zoomed[cell] = biome;
}
let c = match resolver {
@@ -1091,6 +1324,7 @@ impl<'a> Tints<'a> {
Resolver::Dry => self.biomes.get(biome).dry_foliage,
Resolver::Water => self.biomes.get(biome).water,
};
cells[cell] = c;
red += c >> 16 & 255;
green += c >> 8 & 255;
blue += c & 255;
@@ -1162,7 +1396,9 @@ impl Mesher<'_> {
let own = scratch.own.get_or_insert_with(|| Box::new([0; 4096]));
scratch.padded.fill(region, sy, own);
let pad = &scratch.padded;
out.visibility = visibility_reusing(pad, &self.tables.info, &mut scratch.visibility);
opaque_rows(pad, &self.tables.solid, &mut scratch.opaque);
let opaque = &scratch.opaque;
out.visibility = visibility_rows(&open_rows(opaque));
let base_y = center.min_y() + sy * 16;
if self.leaf_culling.is_interior() {
scratch
@@ -1177,6 +1413,7 @@ impl Mesher<'_> {
[center.x, center.z, base_y],
);
let info = &self.tables.info;
let full_faces = &self.tables.full_faces;
let catalog = &self.render.model_catalog;
let mut models = Vec::new();
for y in 0..16 {
@@ -1195,12 +1432,7 @@ impl Mesher<'_> {
out.lights
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
}
if state.solid
&& Direction::ALL.iter().all(|d| {
let [dx, dy, dz] = d.normal();
info[pad.blocks[pi(x + dx, y + dy, z + dz)] as usize].solid
})
{
if hidden(opaque, x, y, z) {
continue;
}
if state.fluid.kind() != FluidKind::Empty {
@@ -1239,6 +1471,11 @@ impl Mesher<'_> {
for d in Direction::ALL {
let [dx, dy, dz] = d.normal();
let neighbor = pad.blocks[pi(x + dx, y + dy, z + dz)];
// `should_render_face`'s first test, without its lookups: a
// full occlusion face toward this block hides the face.
if full_faces[neighbor as usize] >> d.opposite() as u8 & 1 != 0 {
continue;
}
let other = &info[neighbor as usize];
// LeavesBlock.skipRendering with opaque leaves.
let opaque_leaves = !self.cutout_leaves && state.leaves && other.leaves;
@@ -83,9 +83,17 @@ fn weighted_blend(c: [u32; 4], w: [f32; 4]) -> u32 {
};
channel(0) | channel(16) << 16
}
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`.
/// `ARGB.gray` channel: `Mth.floor(brightness * 255)`. Without the `floorf`
/// call x86-64's baseline instruction set makes of `f32::floor`: truncation,
/// one lower where that rounded a negative value up (saturating as `as` does).
pub(super) fn gray(brightness: f32) -> i32 {
(brightness * 255.0).floor() as i32
let v = brightness * 255.0;
let whole = v as i32;
if whole as f32 > v {
whole.saturating_sub(1)
} else {
whole
}
}
/// One channel of `ARGB.scaleRGB(color, float)`.
pub(super) fn scale(channel: i32, by: f32) -> i32 {
@@ -406,6 +414,28 @@ mod tests {
);
}
#[test]
fn gray_floors_like_f32_floor() {
let mut values = vec![
f32::NAN,
f32::INFINITY,
f32::NEG_INFINITY,
-0.0,
1.0,
-1.0,
1e10,
-1e10,
];
for bits in (0..u32::MAX).step_by(7919) {
values.push(f32::from_bits(bits));
}
for i in -3000..3000 {
values.push(i as f32 / 1024.0);
}
for v in values {
assert_eq!(gray(v), (v * 255.0).floor() as i32, "{v:e}");
}
}
#[test]
fn colour_arithmetic_matches_argb_helpers() {
assert_eq!(gray(1.0), 255);
assert_eq!(gray(0.8), 204);
@@ -181,7 +181,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
assert!(scratch.own.is_none());
assert_eq!(scratch.padded.blocks.capacity(), 0);
assert_eq!(scratch.padded.light.capacity(), 0);
assert_eq!(scratch.visibility.capacity(), 0);
assert_eq!(scratch.opaque.capacity(), 0);
}
fresh.sort_solid();
recycled.sort_solid();
+122 -31
View File
@@ -210,6 +210,29 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
/// `sort`, with all the opaque parts first when `grouped`.
fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
SORT_SCRATCH.with_borrow_mut(|scratch| sort_with(solid, grouped, scratch))
}
/// `sort`'s buffers, kept by each meshing thread: a section's quads went
/// through fresh vectors, and the sorted copy replaced the mesh's own
/// (recycled) buffer.
#[derive(Default)]
struct SortScratch {
slots: Vec<u8>,
keys: Vec<u64>,
sorted: Vec<[TerrainVertex; 4]>,
}
thread_local! {
static SORT_SCRATCH: std::cell::RefCell<SortScratch> = Default::default();
}
/// `f32::total_cmp`'s order as an unsigned integer's.
fn total_order_key(v: f32) -> u32 {
let bits = v.to_bits() as i32;
(bits ^ (((bits >> 31) as u32) >> 1) as i32) as u32 ^ 0x8000_0000
}
fn sort_with(solid: &mut Vec<TerrainVertex>, grouped: bool, scratch: &mut SortScratch) -> Sides {
let (quads, rest) = solid.as_chunks::<4>();
debug_assert!(rest.is_empty(), "solid vertices come in quads");
let cutout = |q: &[TerrainVertex; 4]| q[0].layer & OPAQUE_LAYER == 0;
@@ -218,15 +241,14 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
true => b + 7 * usize::from(cutout),
false => b * 2 + usize::from(cutout),
};
let slots: Vec<u8> = quads
.iter()
.map(|q| position(bucket(q), cutout(q)) as u8)
.collect();
let slots = &mut scratch.slots;
slots.clear();
slots.extend(quads.iter().map(|q| position(bucket(q), cutout(q)) as u8));
let has_cutout = quads.iter().any(cutout);
let mut counts = [0u32; 7];
let mut slot_counts = [0u32; 14];
let mut planes = [0f32; 7];
for (quad, &part) in quads.iter().zip(&slots) {
for (quad, &part) in quads.iter().zip(slots.iter()) {
slot_counts[usize::from(part)] += 1;
let b = slot(grouped, usize::from(part)).0;
if let Bucket::Side(axis, positive) = BUCKETS[b] {
@@ -246,33 +268,34 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
*end = total;
}
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
let mut next: [u32; 14] = std::array::from_fn(|s| slot_ends[s] - slot_counts[s]);
let mut sorted = vec![quads[0]; quads.len()];
for (quad, &part) in quads.iter().zip(&slots) {
let at = &mut next[usize::from(part)];
sorted[*at as usize] = *quad;
*at += 1;
}
if has_cutout {
let mut start = 0;
for (s, &end) in slot_ends.iter().enumerate() {
let end = end as usize;
let slice = &mut sorted[start..end];
if let Bucket::Side(axis, positive) = BUCKETS[slot(grouped, s).0] {
slice.sort_by(|a, b| {
let a = a[0].pos[axis];
let b = b[0].pos[axis];
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
// One integer key per quad: its part, then (cutout meshes, side
// buckets) its plane from the outside in, then its index, which keeps
// the order of equal quads as a stable sort does.
let keys = &mut scratch.keys;
keys.clear();
keys.extend(
quads
.iter()
.zip(slots.iter())
.enumerate()
.map(|(i, (quad, &part))| {
let plane = match BUCKETS[slot(grouped, usize::from(part)).0] {
Bucket::Side(axis, positive) if has_cutout => {
let key = total_order_key(quad[0].pos[axis]);
if positive { !key } else { key }
}
});
}
start = end;
}
}
*solid = sorted.into_flattened();
_ => 0,
};
u64::from(part) << 56 | u64::from(plane) << 24 | i as u64
}),
);
debug_assert!(quads.len() < 1 << 24);
keys.sort_unstable();
let sorted = &mut scratch.sorted;
sorted.clear();
sorted.extend(keys.iter().map(|&k| quads[(k & 0xFF_FFFF) as usize]));
solid.clear();
solid.extend_from_slice(sorted.as_flattened());
}
Sides {
ends: slot_ends,
@@ -286,6 +309,74 @@ fn sort_parts(solid: &mut Vec<TerrainVertex>, grouped: bool) -> Sides {
mod tests {
use super::*;
/// The sort as it was before the integer keys: quads moved to their
/// parts in order, then a stable sort by plane in each side bucket.
fn reference(solid: &[TerrainVertex], grouped: bool) -> Vec<TerrainVertex> {
let quads = solid.as_chunks::<4>().0;
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
let mut sorted = Vec::new();
for n in 0..14 {
let (b, cutout) = slot(grouped, n);
let mut part: Vec<[TerrainVertex; 4]> = quads
.iter()
.filter(|q| bucket(q) == b && (q[0].layer & OPAQUE_LAYER == 0) == cutout)
.copied()
.collect();
if let (Bucket::Side(axis, positive), true) = (BUCKETS[b], has_cutout) {
part.sort_by(|a, b| {
let (a, b) = (a[0].pos[axis], b[0].pos[axis]);
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
}
});
}
sorted.extend(part);
}
sorted.into_flattened()
}
#[test]
fn integer_keys_sort_as_the_stable_sort_did() {
let mut state = 0x2545_F491_4F6C_DD1Du64;
let mut next = move || {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
};
for round in 0..100 {
let grouped = round % 2 == 1;
let mut solid = Vec::new();
for _ in 0..(next() % 300) {
let origin = [0, 1, 2].map(|_| (next() % 34) as f32 / 2.0 - 0.5);
// Some negative and signed-zero planes too.
let origin = if next() % 9 == 0 {
origin.map(|v| -v)
} else {
origin
};
let layer = if round % 3 == 0 {
OPAQUE_LAYER
} else {
(next() % 2) as u16 * OPAQUE_LAYER
};
let faces = cube(origin, layer);
let face = (next() % 6) as usize;
solid.extend_from_slice(&faces[face * 4..face * 4 + 4]);
}
let expected = reference(&solid, grouped);
let sides = sort_parts(&mut solid, grouped);
assert_eq!(
bytemuck::cast_slice::<_, u8>(&solid),
bytemuck::cast_slice::<_, u8>(&expected),
"round {round}"
);
assert_eq!(sides.quads() as usize * 4, solid.len());
}
}
fn quad(corners: [[f32; 3]; 4], layer: u16) -> [TerrainVertex; 4] {
corners.map(|p| TerrainVertex::new(p, [0.0; 2], layer, 0, 0))
}
@@ -0,0 +1,114 @@
//! The one-biome shortcut of `Tints::get` against the full blend. Needs the
//! prepared biome data, like the other mesher tests that load resources.
use super::*;
fn biomes() -> Biomes {
let root = std::env::var_os("HORIZON_MESH_TEST_RESOURCES")
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3")
});
Biomes::load(&root).unwrap()
}
/// A chunk whose every quart cell holds `biome`.
fn filled(cx: i32, cz: i32, biome: u16) -> Chunk {
let mut chunk = Chunk::empty(cx, cz);
for qy in 0..(384 / 4) {
for qz in 0..4 {
for qx in 0..4 {
chunk.set_biome_cell(qx, qy, qz, biome);
}
}
}
chunk
}
#[test]
#[ignore = "requires prepared 26.3 resources (HORIZON_MESH_TEST_RESOURCES or .local/horizon-client/26.3)"]
fn one_biome_shortcut_blends_like_the_full_square() {
let biomes = biomes();
let desert = biomes.id("minecraft:desert");
let (cx, cz) = (3, -2);
// Grass without and with the dark forest modifier, swamp noise, and
// biomes whose foliage, dry foliage and water differ from the defaults.
for name in [
"minecraft:plains",
"minecraft:dark_forest",
"minecraft:swamp",
"minecraft:mangrove_swamp",
"minecraft:cherry_grove",
"minecraft:warm_ocean",
"minecraft:badlands",
] {
let biome = biomes.id(name);
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| {
(0..3)
.map(|rx| filled(cx + rx - 1, cz + rz - 1, biome))
.collect()
})
.collect();
// One desert column in the east neighbour, next to the section's edge,
// where every blend radius reaches it.
let mut east = filled(cx + 1, cz, biome);
for qy in 0..(384 / 4) {
east.set_biome_cell(0, qy, 2, desert);
}
let all = |rz: usize, rx: usize| Some(&chunks[rz][rx]);
let uniform: [[Option<&Chunk>; 3]; 3] =
std::array::from_fn(|rz| std::array::from_fn(|rx| all(rz, rx)));
let mut mixed = uniform;
mixed[1][2] = Some(&east);
// A missing neighbour reads as plains.
let mut missing = uniform;
missing[0][0] = None;
for (region, one_biome) in [
(&uniform, true),
(&mixed, false),
(&missing, name == "minecraft:plains"),
] {
for base_y in [MIN_Y, 0, 304] {
for (radius, seed) in [(0, 0), (1, 42), (2, -7), (5, 1234), (7, i64::MAX)] {
let new = || {
Tints::new(
region,
&biomes,
BiomeZoom::new(seed),
radius,
[cx, cz, base_y],
)
};
let mut fast = new();
let mut full = new();
full.uniform = Some(None);
assert_eq!(
fast.uniform_biome().is_some(),
one_biome,
"{name} r{radius} y{base_y}"
);
for resolver in [
Resolver::Grass,
Resolver::Foliage,
Resolver::Dry,
Resolver::Water,
] {
for y in 0..16 {
for z in 0..16 {
for x in 0..16 {
assert_eq!(
fast.get(resolver, x, y, z),
full.get(resolver, x, y, z),
"{name} resolver {} r{radius} at {x} {y} {z} of y{base_y}",
resolver as usize
);
}
}
}
}
}
}
}
}
}
@@ -190,20 +190,74 @@ fn visibility_matches_reference() {
}
#[test]
fn visibility_scratch_reuse_keeps_shortcuts_and_subsequent_fills_exact() {
let info = [info(false), info(true), info(false), info(true)];
let mut stack = vec![usize::MAX];
for _ in 0..3 {
for (name, pad) in cases() {
assert_eq!(
visibility_reusing(&pad, &info, &mut stack),
reference(&pad, &info),
"{name}"
);
fn row_fill_matches_a_cell_by_cell_fill() {
let naive = |seeds: u16, open: u16| {
let mut fill = seeds;
loop {
let next = (fill | fill << 1 | fill >> 1) & open;
if next == fill {
return fill;
}
fill = next;
}
};
let mut state = 0x9E37_79B9u32;
for _ in 0..200_000 {
state ^= state << 13;
state ^= state >> 17;
state ^= state << 5;
let open = state as u16;
let seeds = (state >> 16) as u16 & open;
assert_eq!(
fill_row(seeds, open),
naive(seeds, open),
"{open:016b} {seeds:016b}"
);
}
// Several seeds in one run, the case a carry-based fill gets wrong.
assert_eq!(fill_row(0b0110, 0b1111), 0b1111);
assert_eq!(fill_row(0xC000, 0xF000), 0xF000);
}
#[test]
fn row_fill_follows_open_runs() {
for open in [0u16, 0xFFFF, 0b0111_0110, 0x8001, 0xF0F0, 0x5555] {
for bit in 0..16 {
let seed = 1u16 << bit;
if open & seed == 0 {
continue;
}
let mut expected = seed;
for x in (0..bit).rev().take_while(|&x| open & 1 << x != 0) {
expected |= 1 << x;
}
for x in (bit + 1..16).take_while(|&x| open & 1 << x != 0) {
expected |= 1 << x;
}
assert_eq!(fill_row(seed, open), expected, "{open:016b} from {bit}");
}
}
}
/// Mazes: one long winding cavity, and many small ones, where the row
/// sweeps are slowest.
#[test]
fn winding_and_scattered_cavities_match_reference() {
let info = [info(false), info(true), info(false), info(true)];
let serpentine = fixture(|x, y, z| {
let lane = z % 4 == 0 && y % 4 == 0;
let turn = (x == 0 && z % 8 == 4) || (x == 15 && z % 8 == 0);
let climb = x == 0 && z == 0 && y % 4 != 0;
!(lane || (turn && y % 4 == 0) || climb)
});
assert_eq!(
visibility(&serpentine, &info),
reference(&serpentine, &info)
);
let pockets = fixture(|x, y, z| !(x % 3 == 1 && y % 3 == 1 && z % 3 == 1));
assert_eq!(visibility(&pockets, &info), reference(&pockets, &info));
}
#[test]
fn visibility_preserves_every_face_pair() {
let info = [info(false), info(true), info(false), info(true)];
@@ -5345,6 +5345,61 @@ mod tests {
assert!(ui.resource_error.is_none());
}
/// The texts of an options page, as laid out in `code`'s language, and the keys it lacked.
fn page_texts(code: &str, pages: &[Page]) -> (Vec<String>, Vec<String>) {
let s = Settings::default();
lang::scoped(lang::Language::load(&[], code), || {
let mut texts = Vec::new();
for page in pages {
let mut ui = OptionsUi::default();
ui.show(*page);
for w in ui.layout(&s, 480.0, 4000.0).0 {
match w.kind {
WKind::Button { label: t, .. }
| WKind::Slider { label: t, .. }
| WKind::Text { text: t, .. } => texts.push(t.plain()),
_ => {}
}
}
}
texts
})
}
/// Horizon's own options pages said "Splitscreen...", "Hide block-break particles" and
/// "Hotbar: Previous Slot" in every language (#1927).
#[test]
fn horizon_option_pages_follow_the_language() {
let (texts, missed) = page_texts("de_de", &[Page::Horizon, Page::Extras, Page::PadBinds]);
for want in [
"Splitscreen …",
"Partikel",
"Blockpartikel weg: Aus",
"Weicher Chat: Aus",
"Nachrichtenmarkierung: An",
"Kanäle für UI-Töne: Aus",
"Schnellzugriffsleiste: Vorheriger Platz",
"Schnellzugriffsleiste: Nächster Platz",
] {
assert!(texts.iter().any(|t| t == want), "{want:?} not in {texts:?}");
}
// Every key these pages ask for is in the table; vanilla's own keys (`options.*`,
// `key.*`: dotted ids without spaces) come from the prepared resources, which this
// language does not have.
let missed: Vec<_> = missed
.into_iter()
.filter(|k| k.starts_with("horizon.") || k.contains(' ') || !k.contains('.'))
.collect();
assert_eq!(missed, Vec::<String>::new());
let (texts, _) = page_texts("pl_pl", &[Page::Extras, Page::PadBinds]);
for want in [
"Ukryj cząstki bloków: Wył.",
"Pasek szybkiego wyboru: poprzednie pole",
] {
assert!(texts.iter().any(|t| t == want), "{want:?} not in {texts:?}");
}
}
#[test]
fn classic_mods_page_toggles_mods_and_keeps_dev_and_invalid_entries_inactive() {
let atlas = atlas();
@@ -133,6 +133,21 @@ pub fn text(out: &mut Vec<Draw>, text: Text, x: i32, y: i32, color: i32, shadow:
});
}
}
/// `graphics.text` on text the caller already styled, drawn as it is: a `§`
/// in it stays a character, as `FormattedCharSequence.forward`
/// (`StringDecomposer.iterate`, not `iterateFormatted`) draws it. An edit box
/// builds its own sequence that way, so a `§` in its value shows.
pub fn text_as_typed(out: &mut Vec<Draw>, text: Text, x: i32, y: i32, color: i32, shadow: bool) {
if (color as u32) >> 24 != 0 {
out.push(Draw::Text {
text,
x,
y,
color,
shadow,
});
}
}
/// `StringDecomposer.iterateFormatted`: the drawn characters of `contents`,
/// with their index in it and their style; legacy `§` codes restyle what
@@ -186,6 +201,11 @@ impl Measure<'_> {
pub fn width(&self, t: &Text) -> i32 {
self.0.width_text(&t.resolve_legacy()) as i32
}
/// `Font.width(FormattedCharSequence)` of a sequence drawn as it is: a
/// `§` in the text counts as the glyph it is drawn as.
pub fn width_drawn(&self, t: &Text) -> i32 {
self.0.width_drawn(t) as i32
}
pub fn width_str(&self, s: &str) -> i32 {
self.0.width(s) as i32
}
@@ -864,8 +884,8 @@ impl TextField {
displayed.clone()
};
let t = styled(half, self.display);
let w = m.width(&t);
text(out, t, draw_x, ty, color, self.text_shadow);
let w = m.width_drawn(&t);
text_as_typed(out, t, draw_x, ty, color, self.text_shadow);
draw_x += w + 1;
}
let insert = self.cursor < len || len >= self.max_length;
@@ -878,7 +898,7 @@ impl TextField {
}
if !displayed.is_empty() && on_screen && (rel_cursor as usize) < shown_len {
let t = styled(sub(&displayed, rel_cursor as usize, shown_len), self.cursor);
text(out, t, draw_x, ty, color, self.text_shadow);
text_as_typed(out, t, draw_x, ty, color, self.text_shadow);
}
if let Some(hint) = &self.hint
&& displayed.is_empty()
+182
View File
@@ -413,6 +413,119 @@ impl Sprites {
};
self.register(root, resource, decoded)
}
/// The tiles of the texture `name` (`ns:path`) cut into a `columns` x `rows` grid, row by
/// row from the top left: a Fusion connecting texture's sheet. Each tile is its own layer
/// (named `fusion/<ns>/<path>#<tile>`); a fully transparent one is `None`, as Fusion skips
/// empty tiles. Tiles are square, as wide as a column. An animated sheet's frames are whole
/// grids one below the other, or with `per_tile` each cell is a column of its tile's frames.
/// The sheet's own layer, if models already show it, shows its first tile from then on.
pub fn insert_tiles(
&mut self,
root: &Path,
name: &str,
(columns, rows): (u32, u32),
per_tile: bool,
) -> Result<Vec<Option<u32>>> {
let path = crate::atlas_sources::texture_path(root, name, "textures");
let metadata = match fs::read_to_string(path.with_extension("png.mcmeta")) {
Ok(text) => Some(serde_json::from_str::<
BTreeMap<String, Box<serde_json::value::RawValue>>,
>(&text)?),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => return Err(error.into()),
};
let animation = metadata
.as_ref()
.and_then(|m| m.get("animation"))
.map(|v| v.get().to_owned());
// The sheet's `texture` section is each tile's.
let texture = texture_section(metadata.as_ref());
let strategy = texture
.as_ref()
.and_then(|t| t["mipmap_strategy"].as_str())
.and_then(crate::mipmap::Strategy::parse)
.unwrap_or_default();
let alpha_cutoff_bias = texture
.as_ref()
.and_then(|t| t["alpha_cutoff_bias"].as_f64())
.unwrap_or(0.0) as f32;
let (width, height, pixels) = read_rgba(&path)?;
let side = width / columns.max(1);
// How far down the next frame of a tile is, and the next row of tiles.
let (frame_step, row_step) = match per_tile {
true => (side, height / rows.max(1)),
false => (rows * side, side),
};
let frames = match per_tile {
true => row_step / side.max(1),
false => height / frame_step.max(1),
};
let whole = match per_tile {
true => height % rows.max(1) == 0 && row_step % side.max(1) == 0,
false => height % frame_step.max(1) == 0,
};
if side == 0 || frames == 0 || width % columns.max(1) != 0 || !whole {
return Err(format!("{name} isn't a {columns} x {rows} grid of square tiles").into());
}
let tiles = (0..columns * rows)
.map(|tile| {
let frames: Vec<Vec<u8>> = (0..frames)
.map(|f| {
let (x, y) = (
tile % columns * side,
tile / columns * row_step + f * frame_step,
);
(y..y + side)
.flat_map(|row| {
let start = ((row * width + x) * 4) as usize;
pixels[start..start + side as usize * 4].iter().copied()
})
.collect()
})
.collect();
let alphas = || frames.iter().flat_map(|f| f.iter().skip(3).step_by(4));
if alphas().all(|&a| a == 0) {
return Ok(None);
}
let decoded = crate::sprite_cache::Decoded {
side,
animation: animation.clone().filter(|_| frames.len() > 1),
strategy,
alpha_cutoff_bias,
has_transparent: alphas().any(|&a| a == 0),
opaque: alphas().all(|&a| a == 255),
translucent: alphas().any(|&a| a != 0 && a != 255),
frames,
};
let tile_name = format!("fusion/{}#{tile}", name.replace(':', "/"));
self.register(root, &tile_name, decoded).map(Some)
})
.collect::<Result<Vec<_>>>()?;
// Where Fusion doesn't draw the sheet (items, other models), the lone tile rather than
// the whole sheet squeezed square, whose size would set every layer's.
if let (Some(&own), Some(&first)) = (self.layers.get(name), tiles.iter().flatten().next()) {
self.show_as(own, first);
}
Ok(tiles)
}
/// Layer `layer` shows what `source` does, and is no longer the layer of its old pixels.
fn show_as(&mut self, layer: u32, source: u32) {
self.still.retain(|_, l| *l != layer);
let (l, s) = (layer as usize, source as usize);
self.native[l] = self.native[s].clone();
self.opaque[l] = self.opaque[s];
self.translucent[l] = self.translucent[s];
self.mips[l] = self.mips[s];
self.pbr[l] = self.pbr[s].clone();
self.animations.retain(|a| a.layer != layer);
if let Some(animation) = self.animations.iter().find(|a| a.layer == source) {
let animation = SpriteAnimation {
layer,
..animation.clone()
};
self.animations.push(animation);
}
}
/// Read and decode one texture and its `.mcmeta`.
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
if !name.contains(':') {
@@ -762,6 +875,75 @@ mod tests {
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn tile_sheets_cut_into_a_layer_per_tile() {
let root =
std::env::temp_dir().join(format!("horizon-sprite-tiles-{}", std::process::id()));
let file = root.join("assets/minecraft/textures/block/sheet.png");
fs::create_dir_all(file.parent().unwrap()).unwrap();
// 3 columns of 2 x 2 tiles over 4 rows of pixels: the pixel at (x, y) has red x, green
// y; the middle column is transparent, and the top-right tile half see-through.
let (w, h) = (6u32, 4u32);
let pixels: Vec<u8> = (0..w * h)
.flat_map(|i| {
let (x, y) = (i % w, i / w);
let alpha = match x / 2 {
1 => 0,
2 if y < 2 => 128,
_ => 255,
};
[x as u8, y as u8, 0, alpha]
})
.collect();
let mut encoder = png::Encoder::new(File::create(&file).unwrap(), w, h);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
encoder
.write_header()
.unwrap()
.write_image_data(&pixels)
.unwrap();
// Red and green of each pixel in a layer's frame.
let texels = |sprites: &Sprites, layer: u32, frame: usize| -> Vec<(u8, u8)> {
sprites.native[layer as usize].1[frame]
.chunks(4)
.map(|p| (p[0], p[1]))
.collect()
};
let mut sprites = Sprites::default();
let name = "minecraft:block/sheet";
let own = sprites.insert(&root, name).unwrap();
// Still, as a 3 x 2 grid: the empty middle column draws nothing.
let tiles = sprites.insert_tiles(&root, name, (3, 2), false).unwrap();
// The sheet's own layer now shows the top-left tile, at a tile's size.
assert_eq!(texels(&sprites, own, 0), [(0, 0), (1, 0), (0, 1), (1, 1)]);
assert_eq!(
tiles.iter().map(Option::is_some).collect::<Vec<_>>(),
[true, false, true, true, false, true]
);
let bottom_left = tiles[3].unwrap();
assert_eq!(
texels(&sprites, bottom_left, 0),
[(0, 2), (1, 2), (0, 3), (1, 3)]
);
assert!(sprites.translucent[tiles[2].unwrap() as usize]);
assert!(sprites.opaque[bottom_left as usize]);
// Animated, as one row of tiles: two frames, the grid's top and then its bottom half;
// per tile, each cell is the column of a tile's frames, which here is the same.
fs::write(file.with_extension("png.mcmeta"), r#"{"animation":{}}"#).unwrap();
for per_tile in [false, true] {
let mut sprites = Sprites::default();
let tiles = sprites.insert_tiles(&root, name, (3, 1), per_tile).unwrap();
let first = tiles[0].unwrap();
assert_eq!(texels(&sprites, first, 0), [(0, 0), (1, 0), (0, 1), (1, 1)]);
assert_eq!(texels(&sprites, first, 1), [(0, 2), (1, 2), (0, 3), (1, 3)]);
assert_eq!(sprites.animations.len(), 2, "two non-empty tiles");
}
// Not a grid of square tiles.
assert!(sprites.insert_tiles(&root, name, (4, 1), false).is_err());
assert!(sprites.insert_tiles(&root, name, (3, 3), false).is_err());
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn custom_item_texture_ids_load_with_their_animation() {
let root = std::env::temp_dir().join(format!("horizon-sprite-cit-{}", std::process::id()));
let file = root.join("assets/minecraft/optifine/cit/a.png");
+358 -221
View File
@@ -6,21 +6,19 @@ use crate::fast_hash::{FxHashMap, FxHashSet};
use crate::section_mask::SectionMask;
use crate::{
chunk::Chunk,
chunk_map::ChunkMembershipDelta,
graphics::LeafCullingMode,
light::{LightChanges, LightTables, apply_lighting, apply_relighting, light_chunk},
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, borrow},
world::World,
};
use horizon_core::biome::BiomeZoom;
use horizon_threads::jobs::{Class, PerWorker, Rank, Registration, Source};
use horizon_world::block_render::FaceVisibility;
use horizon_world::worldgen::decoration_order::DecorationOrder;
use std::{
collections::{BinaryHeap, HashSet, VecDeque},
sync::{
Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError,
atomic::{AtomicBool, AtomicUsize, Ordering},
},
thread,
sync::{Arc, Condvar, Mutex, MutexGuard, RwLock, TryLockError},
};
/// How long a worker waits with nothing to do before it gives back its
@@ -33,7 +31,8 @@ pub type Proto = Box<dyn std::any::Any + Send>;
/// (biomes, noise, surface, carvers), decoration of a 3x3 neighbourhood
/// (FEATURES, which may write into neighbours), then conversion of a chunk
/// whose eight neighbours are decorated into a final column. Implementations
/// must be thread-safe; each worker thread owns one `Worker`.
/// must be thread-safe; each worker of the shared pool
/// (`horizon_threads::jobs`) keeps one `Worker` per streamer.
pub trait ChunkGenerator: Send + Sync + 'static {
type Worker: Send + 'static;
fn worker(&self) -> Self::Worker;
@@ -276,8 +275,6 @@ impl Ord for Queued {
}
struct Shared {
queue: Mutex<BinaryHeap<Queued>>,
ready: Condvar,
stop: AtomicBool,
/// What meshing reads (block models and sprites), swapped when resource
/// packs reload; each mesh job takes the current one.
context: RwLock<Arc<WorkerContext>>,
@@ -285,11 +282,6 @@ struct Shared {
/// Only workers use this pool; frames never wait for its lock. Retaining
/// cleared buffers avoids racing the renderer for the process heap.
meshes: Mutex<MeshPool>,
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
/// job since: the last of them gives the freed memory back to the OS.
released: AtomicUsize,
/// The workers that started.
workers: AtomicUsize,
}
#[derive(Default)]
struct MeshPool {
@@ -364,7 +356,11 @@ pub struct Streaming {
/// next arrive, though read from the save (`World::loaded_chunks`).
dropped_new: FxHashSet<(i32, i32)>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
threads: Vec<thread::JoinHandle<()>>,
/// This streamer's place in the shared pool (`horizon_threads::jobs`);
/// dropping it waits for its running jobs.
jobs: Registration,
/// The workers its in-flight work is sized for.
workers: usize,
order: u64,
/// Ticks since the columns and protos that fell out of range were swept.
scan_age: u8,
@@ -476,6 +472,14 @@ pub struct Streaming {
/// Snapshot only after a scan found no missing candidates. Public range fields
/// and `World::chunks` can change without going through Streaming, so compare
/// their values and structural membership token rather than a frame timeout.
///
/// A scan also keeps every candidate it found, nearest first (`plan`): while
/// the ranges stay the same and no column leaves (`World::chunks` removals, or
/// `invalidate` for proto chunks dropped), no column becomes wanted, so the
/// next batches are the plan's entries still missing. A full scan looks at
/// every column of the areas (thousands at render distance 32), and one ran
/// whenever a worker finished: it took a quarter of the thread that runs
/// `Streaming::update` while a world loaded.
#[derive(Default)]
struct GenerationScan {
exhausted: bool,
@@ -485,17 +489,52 @@ struct GenerationScan {
forced: HashSet<(i32, i32)>,
band: Option<Band>,
chunk_revision: u64,
plan: Vec<(i64, i32, i32)>,
/// Entries before it are no longer missing.
next: usize,
/// What `plan` was made for: the ranges, the loading screen and
/// decoration; the chunk map's revision is `chunk_revision`.
plan_basis: Option<(bool, bool)>,
}
impl GenerationScan {
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
self.exhausted
&& self.center == ranges.center
fn same_ranges(&self, ranges: &Ranges<'_>) -> bool {
self.center == ranges.center
&& self.radius == ranges.radius
&& self.extra == ranges.extra
&& self.forced == *ranges.forced
&& self.band.as_ref() == ranges.band
}
fn unchanged(&self, ranges: &Ranges<'_>, world: &World) -> bool {
self.exhausted
&& self.same_ranges(ranges)
&& self.chunk_revision == world.chunks.membership_revision()
}
/// A column may be wanted again (a proto chunk was dropped): the next
/// call scans the areas again.
fn invalidate(&mut self) {
self.exhausted = false;
self.plan_basis = None;
}
/// Whether `plan` still holds every candidate: same ranges, and no
/// column left the chunk map since it was made.
fn plan_holds(&mut self, input: &GenerationInputs<'_>) -> bool {
if self.plan_basis != Some((input.loading, input.decorates))
|| !self.same_ranges(&input.ranges)
{
return false;
}
let revision = input.world.chunks.membership_revision();
if revision != self.chunk_revision {
let Some(mut changes) = input.world.chunks.deltas_since(self.chunk_revision) else {
return false;
};
if !changes.all(|(_, change)| matches!(change, ChunkMembershipDelta::Added(_))) {
return false;
}
self.chunk_revision = revision;
}
true
}
fn remember(&mut self, ranges: Ranges<'_>, world: &World) {
self.exhausted = true;
self.center = ranges.center;
@@ -533,6 +572,30 @@ impl GenerationScan {
{
return None;
}
let missing = |&(_, x, z): &(i64, i32, i32)| {
!input.world.chunks.contains_key(&(x, z))
&& !input.generating.contains(&(x, z))
&& !input.protos.contains_key(&(x, z))
&& !input.finishing.contains(&(x, z))
};
if self.plan_holds(&input) {
while self.next < self.plan.len() && !missing(&self.plan[self.next]) {
self.next += 1;
}
let room = capacity - input.generating.len();
let batch: Vec<_> = self.plan[self.next..]
.iter()
.filter(|c| missing(c))
.take(room)
.copied()
.collect();
// Past the plan's end, the full scan below decides whether
// anything is left (and remembers when nothing is).
if !batch.is_empty() {
self.exhausted = false;
return Some(batch);
}
}
{
let mut wanted = Vec::new();
// Each area's square of columns, as one run per row. Players'
@@ -592,28 +655,196 @@ impl GenerationScan {
}
}
}
let ranges = Ranges {
center: input.ranges.center,
radius: input.ranges.radius,
extra: input.ranges.extra,
forced: input.ranges.forced,
cover: input.ranges.cover,
band: input.ranges.band,
};
self.remember(ranges, input.world);
if wanted.is_empty() {
let ranges = Ranges {
center: input.ranges.center,
radius: input.ranges.radius,
extra: input.ranges.extra,
forced: input.ranges.forced,
cover: input.ranges.cover,
band: input.ranges.band,
};
self.remember(ranges, input.world);
self.plan.clear();
self.plan_basis = None;
} else {
// A bounded batch may leave candidates behind. Keep scanning
// as capacity opens, even if no range or membership changed.
// A bounded batch may leave candidates behind: the next calls
// take them from the plan as capacity opens.
self.exhausted = false;
self.plan_basis = Some((input.loading, input.decorates));
}
wanted.sort_unstable();
wanted.dedup();
self.plan.clear();
self.plan.extend_from_slice(&wanted);
self.next = 0;
wanted.truncate(capacity - input.generating.len());
Some(wanted)
}
}
}
/// What a pool worker keeps for one streamer between its jobs: the
/// generator's worker, and the face visibility table and its occlusion
/// cache (like vanilla's per-thread cache: cloning it per job copied every
/// state and face shape) and the mesher's scratch.
struct WorkerLocal<W> {
worker: W,
faces: Option<(Arc<MeshTables>, FaceVisibility)>,
mesh_scratch: MeshScratch,
}
/// A streamer's jobs, run by the process's shared pool (`horizon_threads::jobs`).
struct Jobs<G: ChunkGenerator> {
shared: Arc<Shared>,
generator: Arc<G>,
light: Arc<LightTables>,
locals: PerWorker<WorkerLocal<G::Worker>>,
}
impl<G: ChunkGenerator> Source for Jobs<G> {
fn next(&self) -> Option<Rank> {
let queue = self.shared.queue.lock().unwrap();
queue.peek().map(|q| {
// Jobs without a column (saves, meshes to free) go first.
let class = if q.at.is_none() {
Class::Urgent
} else {
Class::Near
};
Rank::new(class, q.priority)
})
}
fn run(&self, worker: usize) -> bool {
let Some(job) = self.shared.queue.lock().unwrap().pop() else {
return false;
};
let done = self.locals.with(
worker,
|| WorkerLocal {
worker: self.generator.worker(),
faces: None,
mesh_scratch: MeshScratch::default(),
},
|local| self.run_job(job.job, local),
);
if let Some(done) = done {
self.shared.done.send(done);
}
true
}
fn idle(&self, worker: usize) {
// A server nobody is on, a client standing still: free the
// generator's buffers (and the mesher's face cache and scratch)
// until the next job needs them.
self.locals.with_existing(worker, |local| {
self.generator.idle(&mut local.worker);
local.faces = None;
local.mesh_scratch = MeshScratch::default();
});
}
fn all_idle(&self) {
// No worker needs retained mesh capacity now. Drop it outside the
// pool lock; the pool then asks the allocator to return freed pages.
let meshes = std::mem::take(&mut *self.shared.meshes.lock().unwrap());
drop(meshes);
}
}
impl<G: ChunkGenerator> Jobs<G> {
fn run_job(&self, job: Job, local: &mut WorkerLocal<G::Worker>) -> Option<Done> {
let (generator, shared, light) = (&self.generator, &self.shared, &self.light);
let WorkerLocal {
worker,
faces,
mesh_scratch,
} = local;
let done = match job {
Job::Generate(x, z) => match generator.load(x, z) {
Some(chunk) => {
generator.prepare_loaded(&mut *worker, &chunk);
Done::Generated(chunk)
}
None => {
let proto = generator.proto(&mut *worker, x, z);
let decorated = generator.is_decorated(&proto);
Done::Proto(x, z, proto, decorated)
}
},
Job::Decorate(x, z, mut region, finals) => {
// The final chunks' proto chunks, made here: a
// generated proto chunk holding their blocks.
for (i, snapshot) in &finals {
if let Some(proto) = generator.proto_beside_final(&mut *worker, snapshot) {
region[*i] = proto;
}
}
generator.decorate(&mut *worker, &mut region);
let mut writes = FinalWrites::default();
for (i, snapshot) in &finals {
let blocks = generator.final_writes(snapshot, &region[*i]);
writes
.block_entities
.extend(generator.final_block_entities(&region[*i], &blocks));
writes.blocks.extend(blocks);
region[*i] = Box::new(());
}
let finals = finals.into_iter().map(|(i, _)| i).collect();
Done::Decorated(x, z, region, finals, writes)
}
Job::Finish(proto) => {
let mut chunk = generator.finish(proto);
chunk.generated = true;
Done::Generated(chunk)
}
Job::Save(protos) => {
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
generator.save_protos(&refs);
Done::Saved(protos)
}
Job::LightView(x, z, proto) => {
let view = generator.light_view(&proto);
Done::LightView(x, z, proto, view)
}
Job::Free(meshes) => {
for (_, mesh) in meshes {
let rejected = shared.meshes.lock().unwrap().put(mesh);
drop(rejected);
}
return None;
}
Job::Light(x, z, region, seq, sky) => {
Done::Lit(x, z, light_chunk(&borrow(&region), light, sky), seq)
}
Job::Mesh(x, sy, z, region, seq, settings) => {
let context = shared.context.read().unwrap().clone();
let visibility = match faces.take() {
Some((tables, visibility)) if Arc::ptr_eq(&tables, &context.tables) => {
visibility
}
_ => context.tables.visibility.clone(),
};
let mut mesher = Mesher {
nether_shading: settings.nether,
shader_pack_lighting: settings.shader_pack_lighting,
zoom: settings.zoom,
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
cutout_leaves: settings.cutout_leaves,
translucent_layers: &settings.translucent_layers,
leaf_culling: settings.leaf_culling,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
biomes: &context.biomes,
visibility,
};
let recycled = shared.meshes.lock().unwrap().take();
let mut mesh = mesher.mesh_reusing(&borrow(&region), sy, recycled, mesh_scratch);
mesh.sort_solid();
*faces = Some((context.tables.clone(), mesher.visibility));
Done::Meshed(x, sy, z, mesh, seq)
}
};
Some(done)
}
}
pub struct WorkerContext {
pub assets: Arc<crate::assets::Assets>,
pub render: Arc<crate::assets::RenderAssets>,
@@ -629,190 +860,23 @@ impl Streaming {
) -> Self {
let shared = Arc::new(Shared {
queue: Mutex::new(BinaryHeap::new()),
ready: Condvar::new(),
stop: AtomicBool::new(false),
context: RwLock::new(context.clone()),
done: Mailbox::default(),
meshes: Mutex::new(MeshPool::default()),
released: AtomicUsize::new(0),
workers: AtomicUsize::new(0),
});
let light_tables = context.light.clone();
let saves: Arc<dyn Saves> = generator.clone();
let threads = (0..threads.max(1))
.map(|i| {
let shared = shared.clone();
let generator = generator.clone();
let light = context.light.clone();
thread::Builder::new()
.name(format!("horizon-worker-{i}"))
.spawn(move || {
// Ticks and frames come first (horizon_threads).
horizon_threads::behind_server();
let mut worker = generator.worker();
// The face visibility table and its occlusion cache stay with
// the worker across mesh jobs, like vanilla's per-thread cache:
// cloning it per job copied every state and face shape.
let mut faces: Option<(Arc<MeshTables>, FaceVisibility)> = None;
let mut mesh_scratch = MeshScratch::default();
loop {
let job = {
let mut queue = shared.queue.lock().unwrap();
let mut released = false;
loop {
if shared.stop.load(Ordering::Relaxed) {
return;
}
if let Some(job) = queue.pop() {
if released {
shared.released.fetch_sub(1, Ordering::Relaxed);
}
break job.job;
}
if released {
queue = shared.ready.wait(queue).unwrap();
continue;
}
let (woken, wait) =
shared.ready.wait_timeout(queue, IDLE_RELEASE).unwrap();
queue = woken;
if wait.timed_out() && queue.is_empty() {
// A server nobody is on, a client standing still:
// free the generator's buffers (and the mesher's
// face cache and scratch) until the next job
// needs them.
drop(queue);
generator.idle(&mut worker);
faces = None;
mesh_scratch = MeshScratch::default();
released = true;
// The allocator keeps freed pages until memory
// runs short: once every worker has freed its own,
// hand them back.
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
== shared.workers.load(Ordering::Relaxed)
{
// No worker needs retained mesh capacity now.
// Drop it outside the pool lock before asking
// the allocator to return freed pages.
let meshes =
std::mem::take(&mut *shared.meshes.lock().unwrap());
drop(meshes);
horizon_threads::release_free_memory();
}
queue = shared.queue.lock().unwrap();
}
}
};
let done = match job {
Job::Generate(x, z) => match generator.load(x, z) {
Some(chunk) => {
generator.prepare_loaded(&mut worker, &chunk);
Done::Generated(chunk)
}
None => {
let proto = generator.proto(&mut worker, x, z);
let decorated = generator.is_decorated(&proto);
Done::Proto(x, z, proto, decorated)
}
},
Job::Decorate(x, z, mut region, finals) => {
// The final chunks' proto chunks, made here: a
// generated proto chunk holding their blocks.
for (i, snapshot) in &finals {
if let Some(proto) =
generator.proto_beside_final(&mut worker, snapshot)
{
region[*i] = proto;
}
}
generator.decorate(&mut worker, &mut region);
let mut writes = FinalWrites::default();
for (i, snapshot) in &finals {
let blocks = generator.final_writes(snapshot, &region[*i]);
writes.block_entities.extend(
generator.final_block_entities(&region[*i], &blocks),
);
writes.blocks.extend(blocks);
region[*i] = Box::new(());
}
let finals = finals.into_iter().map(|(i, _)| i).collect();
Done::Decorated(x, z, region, finals, writes)
}
Job::Finish(proto) => {
let mut chunk = generator.finish(proto);
chunk.generated = true;
Done::Generated(chunk)
}
Job::Save(protos) => {
let refs: Vec<&Proto> = protos.iter().map(|(_, p)| p).collect();
generator.save_protos(&refs);
Done::Saved(protos)
}
Job::LightView(x, z, proto) => {
let view = generator.light_view(&proto);
Done::LightView(x, z, proto, view)
}
Job::Free(meshes) => {
for (_, mesh) in meshes {
let rejected = shared.meshes.lock().unwrap().put(mesh);
drop(rejected);
}
continue;
}
Job::Light(x, z, region, seq, sky) => {
Done::Lit(x, z, light_chunk(&borrow(&region), &light, sky), seq)
}
Job::Mesh(x, sy, z, region, seq, settings) => {
let context = shared.context.read().unwrap().clone();
let visibility = match faces.take() {
Some((tables, visibility))
if Arc::ptr_eq(&tables, &context.tables) =>
{
visibility
}
_ => context.tables.visibility.clone(),
};
let mut mesher = Mesher {
nether_shading: settings.nether,
shader_pack_lighting: settings.shader_pack_lighting,
zoom: settings.zoom,
blend_radius: settings.blend_radius,
smooth_lighting: settings.smooth_lighting,
cutout_leaves: settings.cutout_leaves,
translucent_layers: &settings.translucent_layers,
leaf_culling: settings.leaf_culling,
assets: &context.assets,
render: &context.render,
tables: &context.tables,
biomes: &context.biomes,
visibility,
};
let recycled = shared.meshes.lock().unwrap().take();
let mut mesh = mesher.mesh_reusing(
&borrow(&region),
sy,
recycled,
&mut mesh_scratch,
);
mesh.sort_solid();
faces = Some((context.tables.clone(), mesher.visibility));
Done::Meshed(x, sy, z, mesh, seq)
}
};
shared.done.send(done);
}
})
})
// A thread the system refuses (a container's process limit reached) leaves the pool a
// worker short: a server that panicked here went down with its players' moves unsaved.
.filter_map(|spawned| {
spawned
.inspect_err(|e| log::error!("Horizon: chunk worker not started: {e}"))
.ok()
})
.collect::<Vec<_>>();
shared.workers.store(threads.len(), Ordering::Relaxed);
let pool = horizon_threads::jobs::pool();
let source = Arc::new(Jobs {
shared: shared.clone(),
generator: generator.clone(),
light: context.light.clone(),
locals: horizon_threads::jobs::PerWorker::new(pool.threads()),
});
let jobs = pool.register(source);
// The work kept in flight is sized for the workers this streamer may
// use: the pool's, or fewer when asked (tests, a placeholder).
let workers = threads.clamp(1, pool.threads());
let non_air_sections = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.non_air_sections(p))
@@ -820,7 +884,8 @@ impl Streaming {
Self {
shared,
non_air_sections,
threads,
jobs,
workers,
order: 0,
scan_age: 0,
scan_area: ((i32::MIN, i32::MIN), i32::MIN),
@@ -1019,7 +1084,7 @@ impl Streaming {
order: self.order,
job,
});
self.shared.ready.notify_one();
self.jobs.notify();
}
/// Rank the queued jobs again when what their priorities were computed
/// from changed: the centre, the extra areas, the band, or the loading
@@ -1489,7 +1554,7 @@ impl Streaming {
.map(|(k, _)| *k)
.collect();
if !far.is_empty() {
self.generation_scan.exhausted = false;
self.generation_scan.invalidate();
}
// Those decoration wrote to are saved first (see
// `ChunkGenerator::save_protos`), and dropped once back.
@@ -1538,7 +1603,7 @@ impl Streaming {
// Decorate chunks whose 3x3 proto neighbourhood is available, in
// vanilla's order: decoration is order dependent where write regions
// overlap (see `DecorationOrder`).
let workers = self.threads.len();
let workers = self.workers;
if self.decorates && self.order_area != Some((self.center, self.radius)) {
let (decorated, chunks) = (&self.decorated, &world.chunks);
// The render distance is the server view distance in singleplayer.
@@ -1600,14 +1665,24 @@ impl Streaming {
w.0 = self.decoration_order.rank_or_append(w.2, w.3);
}
wanted.sort_unstable();
// Most wanted chunks are blocked, and the same neighbours are
// asked about for each of them: what `waits_for` says is kept for
// the pass (nothing it reads changes in it but `decorated`, which
// updates the entry). Without it, scheduling at render distance 32
// took most of the thread that runs it while the world loaded.
let waiting: std::cell::RefCell<FxHashMap<(i32, i32), bool>> = Default::default();
for (_, _, x, z) in wanted {
if self.decorating >= workers * 2 {
break;
}
if self
.decoration_order
.blocked(x, z, |nx, nz| waits_for(self, nx, nz))
{
if self.decoration_order.blocked(x, z, |nx, nz| {
if let Some(&known) = waiting.borrow().get(&(nx, nz)) {
return known;
}
let known = waits_for(self, nx, nz);
waiting.borrow_mut().insert((nx, nz), known);
known
}) {
continue;
}
let keys: Vec<(i32, i32)> =
@@ -1627,6 +1702,7 @@ impl Streaming {
.collect();
if !finals.is_empty() && !self.saves_beside_final {
self.decorated.insert((x, z));
waiting.borrow_mut().insert((x, z), false);
continue;
}
if !keys.iter().enumerate().all(|(i, k)| {
@@ -1680,7 +1756,7 @@ impl Streaming {
for key in gone {
world.dirty_light.remove(&key);
}
let room = (self.threads.len() * 4).saturating_sub(self.lighting.len());
let room = (self.workers * 4).saturating_sub(self.lighting.len());
nearest_first(&mut light, room);
for (_, x, z) in light {
let Some(region) = self.light_region(world, x, z) else {
@@ -1766,7 +1842,7 @@ impl Streaming {
// jobs went stale long before a worker reached them (#3629). What
// stays dirty is asked for again, from the current centre, as
// capacity frees; `requeue` then has a small queue to keep close.
let room = (self.threads.len() * 32).saturating_sub(self.meshing.len());
let room = (self.workers * 32).saturating_sub(self.meshing.len());
nearest_first(&mut candidates, room);
for (_, x, z, sections) in candidates {
let Some(region) = Self::region(world, x, z) else {
@@ -2438,11 +2514,8 @@ impl Drop for Streaming {
None => break,
}
}
self.shared.stop.store(true, Ordering::Relaxed);
self.shared.ready.notify_all();
for t in self.threads.drain(..) {
let _ = t.join();
}
// `jobs` leaves the pool after this, once its running jobs are
// done; the jobs still queued go with the queue.
}
}
/// The queued jobs' priorities computed again from each one's column
@@ -3143,6 +3216,70 @@ mod tests {
}
}
/// Batches taken from the plan are the full scan's, as jobs finish,
/// columns and proto chunks leave and the centre moves.
#[test]
fn generation_plan_matches_full_scans_as_columns_arrive() {
for decorates in [false, true] {
let mut f = GenerationFixture::new(6, decorates);
let mut gate = GenerationScan::default();
let chunk = Arc::new(Chunk::empty(0, 0));
let mut seed = 0x2545_F491_4F6C_DD1Du64;
let mut rand = |n: u64| {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
seed % n
};
let mut scheduled = 0;
for step in 0..600 {
let capacity = 3 + rand(6) as usize;
let expected = baseline_candidates(f.input(), capacity);
let batch = gate.candidates(f.input(), capacity).unwrap_or_default();
assert_eq!(batch, expected, "step {step}");
for (_, x, z) in batch {
f.generating.insert((x, z));
scheduled += 1;
}
// Some jobs finish: as a proto chunk, or a final column.
let mut done: Vec<_> = f.generating.iter().copied().collect();
done.sort_unstable();
for key in done {
if rand(3) != 0 {
continue;
}
f.generating.remove(&key);
if rand(2) == 0 {
f.protos.insert(key, None);
} else {
f.world.chunks.insert(key, chunk.clone());
}
gate.exhausted = false;
}
// Now and then a column or a proto chunk leaves.
if rand(20) == 0 {
let mut keys: Vec<_> = f.world.chunks.keys().copied().collect();
keys.sort_unstable();
if let Some(key) = keys.get(rand(keys.len().max(1) as u64) as usize) {
f.world.chunks.remove(key);
}
}
if rand(20) == 0 {
let mut keys: Vec<_> = f.protos.keys().copied().collect();
keys.sort_unstable();
if let Some(key) = keys.get(rand(keys.len().max(1) as u64) as usize) {
f.protos.remove(key);
gate.invalidate();
}
}
if step == 300 {
f.center = (3, -2);
}
}
assert!(scheduled > 200);
}
}
#[test]
fn generation_scan_looks_at_overlapping_areas_once() {
// A server's players: many areas around one spot, some apart, one
+38 -4
View File
@@ -37,6 +37,9 @@ struct Globals {
// HDR output's tier windows (hdr.rs `Hdr::windows`): x..w low to very bright,
// [0] where the lift starts, [1] where it is full.
hdr_windows: array<vec4<f32>, 2>,
// HDR lighting: x how strongly the sky lights the world now (`Sky::sky_light_factor`),
// y what the sky and fog get: the same, but none under water.
hdr_light: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var sprites: texture_2d_array<f32>;
@@ -171,6 +174,8 @@ struct TerrainOut {
// How the face shines in HDR output (mesher.rs's `EMISSION_BITS`): 0 not
// at all, 1 to 4 the tier with its bright parts, 5 to 8 the tier all over.
@location(5) @interpolate(flat) emission: u32,
// HDR lighting: block and sky light, as levels 0 to 15.
@location(11) scene: vec2<f32>,
@location(6) @interpolate(flat) fade: u32,
// A distant tile's tile-relative position, for its fade (`faded`).
@location(7) local: vec3<f32>,
@@ -361,6 +366,22 @@ fn left_out() -> TerrainOut {
return terrain_vertex(position, terrain_uv(uv, origin.w), layer, color, light, origin.xyz,
origin.w);
}
// HDR lighting: how far a surface's light carries it above paper white, 0 to 1. Only the
// strongest light does, so dark stays as dark as in SDR; sky light by how the face meets the sun.
fn hdr_scene(block: f32, sky: f32, facing: f32) -> f32 {
let b = smoothstep(11.0, 15.0, block);
let s = smoothstep(11.0, 15.0, sky) * globals.hdr_light.x * facing;
return max(b, s);
}
// The frame's alpha for it, between 1 (none) and the light sources' marks below 0.75.
fn hdr_scene_alpha(scene: f32) -> f32 {
return 1.0 - 0.24 * scene;
}
// The face's normal, from the fragment's derivatives, for how it faces the sun.
var<private> hdr_normal: vec3<f32>;
fn hdr_facing() -> f32 {
return mix(0.45, 1.0, max(dot(hdr_normal, globals.sun.xyz), 0.0));
}
fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>, w: f32) -> TerrainOut {
let pos = position + origin;
@@ -384,6 +405,7 @@ fn terrain_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f3
}
let block = max(f32(light.y), dynamic_block_light(pos));
out.color = vec4(color.rgb, 1.0) * sample_lightmap(f32(light.x) / 16.0, block / 16.0);
out.scene = vec2(block / 16.0, f32(light.x) / 16.0);
if globals.colored_count.x > 0u {
out.color = vec4(min(out.color.rgb + color.rgb * native_colored_light(pos), vec3(1.0)), out.color.a);
}
@@ -426,6 +448,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
}
// `fs_solid` for the opaque pass (`opaque_quad`): no fade, no cutout, no discard.
@fragment fn fs_opaque(in: TerrainOut) -> @location(0) vec4<f32> {
hdr_normal = normalize(cross(dpdy(in.local), dpdx(in.local)));
let texel = sprite(in.uv, in.layer & 0x7fffu);
var color = texel * in.color;
color = mix(globals.fog_color * vec4(1.0, 1.0, 1.0, color.a), color, in.visibility);
@@ -440,6 +463,7 @@ fn hdr_mark(tier: u32, y: f32) -> f32 {
fn solid_color(in: TerrainOut) -> vec4<f32> {
let dx = dpdx(in.local);
let dy = dpdy(in.local);
hdr_normal = normalize(cross(dy, dx));
if faded(in.fade, in.local, dx, dy) { discard; }
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let texel = sprite(in.uv, in.layer & 0x7fffu);
@@ -456,15 +480,24 @@ fn solid_finish(in: TerrainOut, texel: vec4<f32>, color: vec4<f32>) -> vec4<f32>
// texel's own brightness, so its light level and face shading don't change
// it. Only pixels as bright as their sprite's threshold may: its bright
// parts, or all of it. Nothing else reads the frame's alpha.
var emissive = 1.0;
// Fog turns a far surface into the fog's colour: it takes the fog's light with it, or a
// fully fogged mountain would stand out from the fog behind it.
let fogged = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w)) * globals.fog_color.a;
var emissive = hdr_scene_alpha(mix(hdr_scene(in.scene.x, in.scene.y, hdr_facing()),
globals.hdr_light.y, fogged));
if in.emission > 0u {
// A light source's own face shines as one or stays as drawn, without the lighting.
emissive = 1.0;
let whole = in.emission > 4u;
let tier = select(in.emission, in.emission - 4u, whole);
let l = in.layer & 0x7fffu;
let t = textureLoad(emission_thresholds, vec2(i32(l % SPRITE_SPLIT), i32(l / SPRITE_SPLIT)), 0);
let y = brightness(texel.rgb);
if y >= select(t.r, t.g, whole) {
emissive = hdr_mark(tier, y);
// All over means all over: the sprite's range is one frame's, and an animated sprite's
// other frames dip below it. Fog hides a light source as it hides everything else.
if whole || y >= t.r {
emissive = hdr_mark(tier, y * (1.0 - fogged));
}
}
if in.emissive_layer != 0xffffffffu {
@@ -612,7 +645,8 @@ fn sky_along_ray(d: vec3<f32>) -> vec3<f32> {
return color;
}
@fragment fn fs_sky(in: SkyOut) -> @location(0) vec4<f32> {
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0);
// `hdr_scene_alpha`, spelled out: this part of the file also goes into shader packs' skies.
return vec4(sky_along_ray(view_ray(in.ndc)), 1.0 - 0.24 * globals.hdr_light.y);
}
// The first-person hand's depth clear inside the world pass: `vs_sky`'s triangle lies at the
// far plane, written over the whole depth target with no colour.
+107 -8
View File
@@ -384,19 +384,42 @@ pub fn scrolling_text_at(
y: f32,
now_ms: i64,
color: u32,
) {
// `acceptScrollingWithDefaultCenter`: `centerX = (left + right) / 2`.
let center_x = left + ((right - left) / 2.0).floor();
scrolling_text_around_at(
batch, atlas, text, center_x, left, right, top, bottom, y, now_ms, color,
);
}
/// [`scrolling_text_at`] around an explicit `center_x`
/// (`ActiveTextCollector.acceptScrolling`, which `SpriteIconButton.TextAndIcon`
/// calls with the button's middle): a label that fits is centred on `center_x`
/// moved as far as it must to stay between `left` and `right`
/// (`Mth.clamp(centerX, left + lineWidth / 2, right - lineWidth / 2)`), so
/// beside an icon it ends where the icon's room begins. A wider one is drawn
/// from the left and slid inside the box.
#[allow(clippy::too_many_arguments)]
pub fn scrolling_text_around_at(
batch: &mut HudBatch,
atlas: &HudAtlas,
text: &Text,
center_x: f32,
left: f32,
right: f32,
top: f32,
bottom: f32,
y: f32,
now_ms: i64,
color: u32,
) {
let text = text.resolved();
let width = atlas.font.width_text(&text);
let room = right - left;
if width <= room {
batch.text_styled(
atlas,
&text,
centered_left(left, room, width),
y,
color,
true,
);
let half = (width / 2.0).floor();
let text_x = center_x.max(left + half).min(right - half);
batch.text_styled(atlas, &text, text_x - half, y, color, true);
return;
}
// Scissored to the widget: what does not fit is read as the label
@@ -440,6 +463,37 @@ pub fn scrolling_text(
);
}
/// `MenuTabBar.MenuTabButton.renderLabel` for the tab at `x, y`: its label
/// inside the tab less 1 px on each side, from the tab's top when selected
/// and 3 px lower when not (`acceptScrollingWithDefaultCenter`, so one wider
/// than the tab slides instead of running into the next tab). White, gray
/// for an inactive tab (`WithInactiveMessage`).
pub fn menu_tab_label(
batch: &mut HudBatch,
atlas: &HudAtlas,
label: &Text,
(x, y, width, height): (f32, f32, f32, f32),
selected: bool,
active: bool,
now_ms: i64,
) {
let top = y + if selected { 0.0 } else { 3.0 };
let bottom = y + height;
scrolling_text_at(
batch,
atlas,
label,
x + 1.0,
x + width - 1.0,
top,
bottom,
// defaultScrollingHelper: `(top + bottom - lineHeight) / 2 + 1`.
((top + bottom - 9.0) / 2.0).floor() + 1.0,
now_ms,
if active { u32::MAX } else { 0xFFA0_A0A0 },
);
}
/// Button (AbstractButton): nine-slice sprite plus the centred label.
pub fn button(
batch: &mut HudBatch,
@@ -1642,6 +1696,51 @@ mod tests {
);
}
/// `MenuTabButton.renderLabel` on a 24 px tab: centred on the tab, its
/// text top at `(0 + 24 - 9) / 2 + 1 = 8` when selected and at
/// `(3 + 24 - 9) / 2 + 1 = 10` when not; white on an active tab, selected
/// or not, `0xA0A0A0` on an inactive one (`WithInactiveMessage`). The
/// Statistics tabs drew every label at 8 and an active unselected one gray.
#[test]
fn a_tab_label_sits_lower_on_an_unselected_tab_and_is_gray_only_when_inactive() {
let atlas = atlas();
let label = Text::literal("Items");
let half = (atlas.font.width_text(&label.resolved()) / 2.0).floor();
// A 124 px tab at x = 100: `(left + right) / 2 = (101 + 223) / 2 = 162`.
let left = 162.0 - half;
for (selected, active, y, color) in [
(true, true, 8.0, u32::MAX),
(false, true, 10.0, u32::MAX),
(false, false, 10.0, 0xFFA0_A0A0),
] {
let mut tab = HudBatch::new([640, 480]);
menu_tab_label(
&mut tab,
&atlas,
&label,
(100.0, 0.0, 124.0, 24.0),
selected,
active,
0,
);
let mut want = HudBatch::new([640, 480]);
want.text_styled(&atlas, &label.resolved(), left, y, color, true);
let glyphs = |b: &HudBatch| -> Vec<([u32; 2], u32)> {
b.vertices
.iter()
.filter(|v| v.text == 1)
.map(|v| ([v.pos[0].to_bits(), v.pos[1].to_bits()], v.color))
.collect()
};
assert!(!glyphs(&want).is_empty());
assert_eq!(
glyphs(&tab),
glyphs(&want),
"selected {selected}, active {active}"
);
}
}
#[test]
fn slider_track_is_outlined_only_while_dragged() {
let (track, handle) = slider_sprites(true, true, false);
@@ -95,6 +95,7 @@
"UI Brightness": "GUI-Helligkeit",
"Battery Mode": "Akkumodus",
"Graphics API": "Grafik-API",
"Translucent Batching": "Transparenz-Batching",
"Performance Overlay": "Leistungsanzeige",
"Full Detail Distance": "Detailweite",
"On": "An",
@@ -142,6 +143,7 @@
"This feature is not yet available in Horizon.": "Diese Funktion ist in Horizon noch nicht verfügbar.",
"Your display or system doesn't offer HDR output.": "Dein Bildschirm oder System unterstützt keine HDR-Ausgabe.",
"Turn HDR on to use it.": "Schalte HDR ein, um es zu nutzen.",
"Your GPU or graphics API doesn't support it.": "Deine GPU oder Grafik-API unterstützt das nicht.",
"%s chunks": "%s Chunks",
"%s per frame": "%s pro Bild",
"%s MiB/frame": "%s MiB/Bild",
@@ -164,7 +166,6 @@
"How far mobs, crops and machines keep working. CPU time.": "Wie weit Kreaturen, Pflanzen und Maschinen weiterarbeiten. Kostet CPU-Zeit.",
"How far away mobs and items are drawn, as a share of normal.": "Zeichenweite für Kreaturen und Gegenstände, anteilig zur Norm.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Überspringt Gelände hinter geschlossenen Abschnitten. Aus kostet viel.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Versteckt Modelle hinter Blöcken. Bei eigenen Paketen ausschalten.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Mesh-Abschnitte pro Bild. Weniger: flüssigeres Laden.",
"Soft shadows in corners. Costs chunk building time only.": "Weiche Schatten in Ecken. Kostet nur Zeit beim Chunk-Aufbau.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Kleinere Textur-Kopien für fernes Gelände: weniger Flimmern.",
@@ -198,6 +199,7 @@
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 im Menü/ohne Fokus), halb so viele Worker: spart Strom.",
"Threads that generate, light and mesh chunks. Next start.": "Threads für Erzeugung, Licht und Mesh der Chunks. Nach Neustart.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sonst DirectX 12 (Windows) oder OpenGL (Linux). Nach Neustart.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Wasser und Glas in einem Puffer: weniger Draw-Calls, etwas mehr VRAM. Nach Neustart.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Pro Bild an die GPU gesendete Mesh-Daten. Weniger ist flüssiger beim Laden.",
"Frame rate and frame times over the game, with a graph.": "Bildrate und Bildzeiten über dem Spiel, mit Diagramm.",
"horizon.multiplayer.finding_friend": "Die Welt deines Freundes wird gesucht …",
@@ -286,5 +288,53 @@
"horizon.multiplayer.not_signed_in": "Dieser Server läuft im Online-Modus. Melde dich im Horizon-Launcher mit deinem Microsoft-Konto an, um ihm beizutreten.",
"horizon.multiplayer.invalid_address": "Ungültige Serveradresse: %s",
"horizon.multiplayer.connected_friend_direct": "Direkt mit deinem Freund verbunden …",
"horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …"
"horizon.multiplayer.connected_friend_relay": "Über das Relay mit deinem Freund verbunden …",
"Culling": "Culling",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Versteckt Modelle hinter Blöcken. Prüfung im Hintergrund; Objekte laufen weiter.",
"Leaf Culling": "Laub-Culling",
"Every leaf face. Vanilla's fancy leaves.": "Jede Blattfläche, wie Minecraft sie bei schönen Blättern zeichnet.",
"Distant Plant Dither": "Pflanzen-Dithering",
"Keeps fine plants in high-res packs from fading far away.": "Verhindert, dass feine Pflanzen in hochauflösenden Paketen in der Ferne verblassen.",
"Screenshot Quality": "Screenshot-Qualität",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2-Screenshots in diesem Vielfachen der Spielauflösung. Höher dauert länger.",
"Frame Generation": "Bildgenerierung",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Zeigt zwischen zwei echten Bildern ein errechnetes: flüssiger, etwas mehr Verzögerung.",
"Shadow Cache": "Schatten-Cache",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Verwendet die Schattenkarte des Pakets erneut, wenn Sonne, Blick und Welt gleich bleiben. Standardmäßig aus.",
"Synchronous": "Synchron",
"Asynchronous": "Asynchron",
"Solid": "Massiv",
"Solid Aggressive": "Massiv, aggressiv",
"Hollow": "Hohl",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Blätter mitten im Blätterdach werden massiv gezeichnet: weniger durchsichtige Schichten.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Auch Blätter mit einer offenen Seite werden massiv gezeichnet: schneller, gröbere Kanten.",
"Drops faces between leaves: faster, but gaps look through.": "Lässt Flächen zwischen Blättern weg: schneller, aber Lücken sind durchsichtig.",
"Hotbar: Previous Slot": "Schnellzugriffsleiste: Vorheriger Platz",
"Hotbar: Next Slot": "Schnellzugriffsleiste: Nächster Platz",
"horizon.agent.connected.one": "Verbunden: 1 Agentenverbindung",
"horizon.agent.connected.many": "Verbunden: %s Agentenverbindungen",
"horizon.agent.connected.one_ago": "Verbunden: 1 Agentenverbindung, letzter Befehl vor %s s",
"horizon.agent.connected.many_ago": "Verbunden: %s Agentenverbindungen, letzter Befehl vor %s s",
"horizon.agent.waiting": "Wartet auf einen Agenten.",
"horizon.agent.off": "Aus: Agenten können dieses Spiel nicht erreichen.",
"horizon.agent.session_opened": "Für diese Sitzung von dem Programm geöffnet, das das Spiel gestartet hat.",
"horizon.agent.socket": "Socket: %s (Verbindungsdaten kopieren für den vollständigen Pfad)",
"horizon.agent.local_only": "Nur lokal: Agenten auf diesem Computer, nie über das Netzwerk.",
"horizon.extras.chat_animation": "Weicher Chat",
"horizon.extras.chat_animation.tooltip": "Eine neue Chatzeile gleitet von links herein und blendet dabei ein, statt sofort ganz zu erscheinen.",
"horizon.extras.chat_indicator": "Nachrichtenmarkierung",
"horizon.extras.chat_indicator.tooltip": "Der farbige Balken links neben einer Chatzeile.",
"horizon.extras.header.chat": "Chat",
"horizon.extras.header.particles": "Partikel",
"horizon.extras.header.sound": "Ton",
"horizon.extras.hide_block_particles": "Blockpartikel weg",
"horizon.extras.hide_block_particles.tooltip": "Blendet die Trümmerpartikel aus, die aus einem zerstörten Block sprühen und von dem abbröckeln, den du abbaust. Andere Partikel bleiben.",
"horizon.extras.splitscreen": "Splitscreen",
"horizon.extras.splitscreen.tooltip": "In einer Welt kann ein weiterer Controller als neuer Spieler beitreten: A drücken, wenn gefragt, dann Profil und Spielort wählen.",
"horizon.extras.ui_sound_reserve": "Kanäle für UI-Töne",
"horizon.extras.ui_sound_reserve.tooltip": "Weltgeräusche lassen 16 der 247 Tonkanäle frei, damit eine Flut davon (eine große Explosion) Menü-Klicks nicht verstummen lässt.",
"horizon.pages.open.split_setup": "Splitscreen …",
"horizon.pages.split_settings": "Splitscreen-Einstellungen",
"horizon.pages.split_setup": "Splitscreen",
"horizon.pages.split_setup.tooltip": "Spieler an diesem Computer: Controller können deiner Welt beitreten."
}
@@ -170,7 +170,6 @@
"How far mobs, crops and machines keep working. CPU time.": "How far mobs, crops and machines keep working. CPU time.",
"How far away mobs and items are drawn, as a share of normal.": "How far away mobs and items are drawn, as a share of normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Skips terrain hidden behind closed sections. Off costs a lot.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Hides models behind opaque blocks. Entities update. Off with custom packs.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Meshed sections uploaded per frame. Lower is smoother while loading.",
"Soft shadows in corners. Costs chunk building time only.": "Soft shadows in corners. Costs chunk building time only.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Smaller copies of block textures for distant terrain: less shimmer.",
@@ -306,5 +305,36 @@
"horizon.osk.close": "Close",
"horizon.extras.chat_animation.tooltip": "A new chat line slides in from the left while it fades in, instead of appearing whole.",
"horizon.extras.chat_indicator.tooltip": "The coloured bar at the left of a chat line.",
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
"horizon.extras.splitscreen.tooltip": "In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play.",
"Culling": "Culling",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Hides models behind opaque blocks. Async checks off-thread; entities tick.",
"Leaf Culling": "Leaf Culling",
"Every leaf face. Vanilla's fancy leaves.": "Every leaf face. Vanilla's fancy leaves.",
"Distant Plant Dither": "Distant Plant Dither",
"Keeps fine plants in high-res packs from fading far away.": "Keeps fine plants in high-res packs from fading far away.",
"Screenshot Quality": "Screenshot Quality",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 screenshots at this many times the game's resolution. Higher takes longer.",
"Frame Generation": "Frame Generation",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Shows a made-up frame between each two real ones: smoother, a little more lag.",
"Shadow Cache": "Shadow Cache",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Reuses the pack shadow map when sun, view and world match. Off by default.",
"Synchronous": "Synchronous",
"Asynchronous": "Asynchronous",
"Solid": "Solid",
"Solid Aggressive": "Solid Aggressive",
"Hollow": "Hollow",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Leaves fully inside a canopy draw solid: fewer see-through layers.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Leaves with one open side draw solid too: faster, chunkier edges.",
"Drops faces between leaves: faster, but gaps look through.": "Drops faces between leaves: faster, but gaps look through.",
"Hotbar: Previous Slot": "Hotbar: Previous Slot",
"Hotbar: Next Slot": "Hotbar: Next Slot",
"horizon.agent.connected.one": "Connected: 1 agent connection",
"horizon.agent.connected.many": "Connected: %s agent connections",
"horizon.agent.connected.one_ago": "Connected: 1 agent connection, last command %s s ago",
"horizon.agent.connected.many_ago": "Connected: %s agent connections, last command %s s ago",
"horizon.agent.waiting": "Waiting for an agent.",
"horizon.agent.off": "Off: agents can't reach this game.",
"horizon.agent.session_opened": "Opened for this session by the program that started the game.",
"horizon.agent.socket": "Socket: %s (copy the details for its full path)",
"horizon.agent.local_only": "Local only: agents on this computer, never the network."
}
@@ -158,7 +158,6 @@
"How far mobs, crops and machines keep working. CPU time.": "Hasta dónde siguen funcionando mobs, cultivos y máquinas. Tiempo de CPU.",
"How far away mobs and items are drawn, as a share of normal.": "A qué distancia se dibujan mobs y objetos, como proporción de lo normal.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Omite el terreno oculto tras secciones cerradas. Desactivarlo cuesta mucho.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Oculta modelos tras bloques opacos. Las entidades se actualizan. Desactivado con paquetes personalizados.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Secciones malladas subidas por fotograma. Menos es más fluido durante la carga.",
"Soft shadows in corners. Costs chunk building time only.": "Sombras suaves en las esquinas. Solo cuesta tiempo de construcción de chunks.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Copias más pequeñas de las texturas de bloques para el terreno lejano: menos parpadeo.",
@@ -95,6 +95,7 @@
"UI Brightness": "Luminosité UI",
"Battery Mode": "Mode batterie",
"Graphics API": "API graphique",
"Translucent Batching": "Regroupement translucide",
"Performance Overlay": "Infos de perfs",
"Full Detail Distance": "Dist. détail",
"On": "Oui",
@@ -142,6 +143,7 @@
"This feature is not yet available in Horizon.": "Cette fonction n'est pas encore disponible dans Horizon.",
"Your display or system doesn't offer HDR output.": "Votre écran ou votre système ne propose pas de sortie HDR.",
"Turn HDR on to use it.": "Activez le HDR pour l'utiliser.",
"Your GPU or graphics API doesn't support it.": "Votre GPU ou votre API graphique ne le prend pas en charge.",
"%s chunks": "%s chunks",
"%s per frame": "%s par image",
"%s MiB/frame": "%s MiB/image",
@@ -164,7 +166,6 @@
"How far mobs, crops and machines keep working. CPU time.": "Portée d'activité des créatures, cultures, machines. Coût : CPU.",
"How far away mobs and items are drawn, as a share of normal.": "Distance des créatures et objets, en part de la normale.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Ignore le terrain caché. Désactiver coûte cher.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Masque les modèles derrière les blocs. Éteindre avec packs.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Sections maillées par image. Moins : chargement plus fluide.",
"Soft shadows in corners. Costs chunk building time only.": "Ombres douces dans les coins. Coût : construction des chunks.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Textures réduites au loin : moins de scintillement.",
@@ -198,6 +199,7 @@
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS max (30 en menu/sans focus), moitié de threads : économie.",
"Threads that generate, light and mesh chunks. Next start.": "Threads de génération, lumière et maillage. Au redémarrage.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, sinon DirectX 12 (Windows) ou OpenGL (Linux). Au redémarrage.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Eau et verre dans un seul tampon : moins d'appels de dessin, un peu plus de VRAM. Au redémarrage.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Données de maillage vers le GPU par image. Moins : plus fluide.",
"Frame rate and frame times over the game, with a graph.": "Fréquence et temps d'image par-dessus le jeu, avec un graphique.",
"horizon.multiplayer.finding_friend": "Recherche du monde de votre ami...",
@@ -286,5 +288,53 @@
"horizon.multiplayer.not_signed_in": "Ce serveur est en mode en ligne. Connectez-vous avec votre compte Microsoft dans le lanceur Horizon pour le rejoindre.",
"horizon.multiplayer.invalid_address": "Adresse de serveur invalide : %s",
"horizon.multiplayer.connected_friend_direct": "Connecté directement à votre ami...",
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais..."
"horizon.multiplayer.connected_friend_relay": "Connecté à votre ami via le relais...",
"Culling": "Occlusion",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Masque les modèles derrière les blocs opaques. Vérification en arrière-plan ; les entités continuent.",
"Leaf Culling": "Occlusion feuillage",
"Every leaf face. Vanilla's fancy leaves.": "Toutes les faces de feuillage, comme les feuilles détaillées de Minecraft.",
"Distant Plant Dither": "Tramage des plantes",
"Keeps fine plants in high-res packs from fading far away.": "Empêche les plantes fines des packs haute résolution de s'estomper au loin.",
"Screenshot Quality": "Qualité des captures",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Captures F2 à ce multiple de la résolution du jeu. Plus c'est haut, plus c'est long.",
"Frame Generation": "Génération d'images",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Insère une image calculée entre deux vraies : plus fluide, un peu plus de latence.",
"Shadow Cache": "Cache des ombres",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Réutilise la carte d'ombres du pack si soleil, vue et monde sont identiques. Désactivé par défaut.",
"Synchronous": "Synchrone",
"Asynchronous": "Asynchrone",
"Solid": "Opaque",
"Solid Aggressive": "Opaque agressif",
"Hollow": "Creux",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Les feuilles au cœur d'un feuillage sont opaques : moins de couches transparentes.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Les feuilles avec un côté ouvert sont aussi opaques : plus rapide, bords plus grossiers.",
"Drops faces between leaves: faster, but gaps look through.": "Supprime les faces entre feuilles : plus rapide, mais on voit à travers les trous.",
"Hotbar: Previous Slot": "Barre d'action : case précédente",
"Hotbar: Next Slot": "Barre d'action : case suivante",
"horizon.agent.connected.one": "Connecté : 1 connexion d'agent",
"horizon.agent.connected.many": "Connecté : %s connexions d'agent",
"horizon.agent.connected.one_ago": "Connecté : 1 connexion d'agent, dernière commande il y a %s s",
"horizon.agent.connected.many_ago": "Connecté : %s connexions d'agent, dernière commande il y a %s s",
"horizon.agent.waiting": "En attente d'un agent.",
"horizon.agent.off": "Désactivé : les agents ne peuvent pas joindre ce jeu.",
"horizon.agent.session_opened": "Ouvert pour cette session par le programme qui a lancé le jeu.",
"horizon.agent.socket": "Socket : %s (copiez les détails pour le chemin complet)",
"horizon.agent.local_only": "Local uniquement : agents sur cet ordinateur, jamais via le réseau.",
"horizon.extras.chat_animation": "Chat fluide",
"horizon.extras.chat_animation.tooltip": "Une nouvelle ligne de chat glisse depuis la gauche en apparaissant progressivement, au lieu d'apparaître d'un coup.",
"horizon.extras.chat_indicator": "Marqueur de message",
"horizon.extras.chat_indicator.tooltip": "La barre colorée à gauche d'une ligne de chat.",
"horizon.extras.header.chat": "Chat",
"horizon.extras.header.particles": "Particules",
"horizon.extras.header.sound": "Son",
"horizon.extras.hide_block_particles": "Masquer particules",
"horizon.extras.hide_block_particles.tooltip": "Masque les débris qui jaillissent d'un bloc cassé et s'effritent de celui que vous minez. Les autres particules restent.",
"horizon.extras.splitscreen": "Écran partagé",
"horizon.extras.splitscreen.tooltip": "Dans un monde, une autre manette peut rejoindre en tant que nouveau joueur : appuyez sur A quand c'est demandé, puis choisissez un profil et où jouer.",
"horizon.extras.ui_sound_reserve": "Canaux pour sons UI",
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu.",
"horizon.pages.open.split_setup": "Écran partagé...",
"horizon.pages.split_settings": "Options de l'écran partagé",
"horizon.pages.split_setup": "Écran partagé",
"horizon.pages.split_setup.tooltip": "Joueurs sur cet ordinateur : des manettes peuvent rejoindre votre monde."
}
@@ -95,6 +95,7 @@
"UI Brightness": "Jasność interfejsu",
"Battery Mode": "Tryb baterii",
"Graphics API": "API grafiki",
"Translucent Batching": "Grupowanie półprzezroczystych",
"Performance Overlay": "Nakładka wydajności",
"Full Detail Distance": "Zasięg detali",
"On": "Wł.",
@@ -142,6 +143,7 @@
"This feature is not yet available in Horizon.": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
"Your display or system doesn't offer HDR output.": "Twój wyświetlacz lub system nie obsługuje wyjścia HDR.",
"Turn HDR on to use it.": "Włącz HDR, aby tego użyć.",
"Your GPU or graphics API doesn't support it.": "Twoje GPU lub API graficzne tego nie obsługuje.",
"%s chunks": "%s chunków",
"%s per frame": "%s na klatkę",
"%s MiB/frame": "%s MiB/klatka",
@@ -164,7 +166,6 @@
"How far mobs, crops and machines keep working. CPU time.": "Jak daleko stworzenia, uprawy i maszyny nadal działają. Czas procesora.",
"How far away mobs and items are drawn, as a share of normal.": "Odległość rysowania stworzeń i przedmiotów, jako ułamek normy.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Pomija teren ukryty za zamkniętymi sekcjami. Wyłączone dużo kosztuje.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ukrywa modele za pełnymi blokami. Wył. przy własnych paczkach.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Liczba sekcji siatki wysyłanych na klatkę. Mniej to płynniej podczas ładowania.",
"Soft shadows in corners. Costs chunk building time only.": "Miękkie cienie w narożnikach. Kosztuje tylko czas budowania chunków.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Mniejsze kopie tekstur bloków dla odległego terenu: mniej migotania.",
@@ -198,6 +199,7 @@
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "Limit 60 FPS (30 w menu/bez fokusu), połowa wątków: mniej energii.",
"Threads that generate, light and mesh chunks. Next start.": "Wątki generujące, oświetlające i meshujące chunki. Po restarcie.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, awaryjnie DirectX 12 (Windows) / OpenGL (Linux). Po restarcie.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Woda i szkło w jednym buforze: mniej wywołań rysowania, trochę więcej VRAM. Po restarcie.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Dane siatki wysyłane do GPU na klatkę. Mniej to płynniej podczas ładowania.",
"Frame rate and frame times over the game, with a graph.": "Liczba klatek i czasy klatek nad grą, z wykresem.",
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
@@ -286,5 +288,53 @@
"horizon.osk.delete": "Usuń",
"horizon.osk.type": "Wpisz",
"horizon.osk.symbols": "Symbole",
"horizon.osk.close": "Zamknij"
"horizon.osk.close": "Zamknij",
"Culling": "Pomijanie",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ukrywa modele za pełnymi blokami. Sprawdzanie w tle; byty nadal się aktualizują.",
"Leaf Culling": "Pomijanie liści",
"Every leaf face. Vanilla's fancy leaves.": "Każda ściana liści, tak jak rysuje je Minecraft.",
"Distant Plant Dither": "Dithering roślin",
"Keeps fine plants in high-res packs from fading far away.": "Zapobiega zanikaniu drobnych roślin w paczkach wysokiej rozdzielczości w oddali.",
"Screenshot Quality": "Jakość zrzutów ekranu",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Zrzuty F2 w tylokrotności rozdzielczości gry. Więcej trwa dłużej.",
"Frame Generation": "Generowanie klatek",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Pokazuje wygenerowaną klatkę między każdymi dwiema prawdziwymi: płynniej, trochę większe opóźnienie.",
"Shadow Cache": "Pamięć cieni",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Używa ponownie mapy cieni paczki, gdy słońce, widok i świat się zgadzają. Domyślnie wyłączone.",
"Synchronous": "Synchroniczne",
"Asynchronous": "Asynchroniczne",
"Solid": "Pełne",
"Solid Aggressive": "Pełne, agresywnie",
"Hollow": "Puste",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Liście w środku korony rysują się jako pełne: mniej przezroczystych warstw.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Liście z jedną otwartą stroną też rysują się jako pełne: szybciej, grubsze krawędzie.",
"Drops faces between leaves: faster, but gaps look through.": "Pomija ściany między liśćmi: szybciej, ale luki są przezroczyste.",
"Hotbar: Previous Slot": "Pasek szybkiego wyboru: poprzednie pole",
"Hotbar: Next Slot": "Pasek szybkiego wyboru: następne pole",
"horizon.agent.connected.one": "Połączono: 1 połączenie agenta",
"horizon.agent.connected.many": "Połączenia agenta: %s",
"horizon.agent.connected.one_ago": "Połączono: 1 połączenie agenta, ostatnie polecenie %s s temu",
"horizon.agent.connected.many_ago": "Połączenia agenta: %s, ostatnie polecenie %s s temu",
"horizon.agent.waiting": "Czeka na agenta.",
"horizon.agent.off": "Wyłączone: agenci nie mają dostępu do tej gry.",
"horizon.agent.session_opened": "Otwarte na tę sesję przez program, który uruchomił grę.",
"horizon.agent.socket": "Gniazdo: %s (skopiuj dane, aby poznać pełną ścieżkę)",
"horizon.agent.local_only": "Tylko lokalnie: agenci na tym komputerze, nigdy przez sieć.",
"horizon.extras.chat_animation": "Płynny czat",
"horizon.extras.chat_animation.tooltip": "Nowa linia czatu wsuwa się z lewej i pojawia stopniowo, zamiast wyświetlać się od razu w całości.",
"horizon.extras.chat_indicator": "Znacznik wiadomości",
"horizon.extras.chat_indicator.tooltip": "Kolorowy pasek po lewej stronie linii czatu.",
"horizon.extras.header.chat": "Czat",
"horizon.extras.header.particles": "Cząsteczki",
"horizon.extras.header.sound": "Dźwięk",
"horizon.extras.hide_block_particles": "Ukryj cząstki bloków",
"horizon.extras.hide_block_particles.tooltip": "Ukrywa cząsteczki odłamków, które tryskają ze zniszczonego bloku i kruszą się z tego, który kopiesz. Inne cząsteczki zostają.",
"horizon.extras.splitscreen": "Podzielony ekran",
"horizon.extras.splitscreen.tooltip": "W świecie może dołączyć kolejny kontroler jako nowy gracz: naciśnij A, gdy zapyta, potem wybierz profil i miejsce gry.",
"horizon.extras.ui_sound_reserve": "Kanały dla dźwięków UI",
"horizon.extras.ui_sound_reserve.tooltip": "Dźwięki świata zostawiają wolnych 16 z 247 kanałów, by ich nawał (duży wybuch) nie wyciszył kliknięć w menu.",
"horizon.pages.open.split_setup": "Podzielony ekran…",
"horizon.pages.split_settings": "Ustawienia podzielonego ekranu",
"horizon.pages.split_setup": "Podzielony ekran",
"horizon.pages.split_setup.tooltip": "Gracze na tym komputerze: kontrolery mogą dołączyć do twojego świata."
}
@@ -157,7 +157,6 @@
"How far mobs, crops and machines keep working. CPU time.": "Как далеко мобы, посевы и механизмы продолжают работать. Нагрузка на процессор.",
"How far away mobs and items are drawn, as a share of normal.": "С какой дистанции рисуются мобы и предметы, в долях от обычной.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускает рельеф, скрытый за закрытыми секциями. Выключено — дорого.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Прячет модели за непрозрачными блоками. Сущности тикают. Выключать со своими наборами.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секций меша за кадр. Меньше — плавнее во время загрузки.",
"Soft shadows in corners. Costs chunk building time only.": "Мягкие тени по углам. Замедляет только построение чанков.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Уменьшенные копии текстур блоков для дали: меньше мерцания.",
@@ -158,7 +158,6 @@
"How far mobs, crops and machines keep working. CPU time.": "Yaratıkların, ekinlerin ve makinelerin ne kadar uzaktan çalışmaya devam edeceğini belirler. İşlemci gücü kullanır.",
"How far away mobs and items are drawn, as a share of normal.": "Yaratıkların ve eşyaların ne kadar uzaktan görüntüleneceğini, normal mesafeye oranla belirler.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Kapalı alanların arkasında kalan arazilerin çizilmesini önler. Kapalı olması ciddi performans kaybına neden olur.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Opak blokların arkasında kalan modelleri gizler. Varlıkların güncellemeleri devam eder. Özel paketler kullanırken kapatın.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Her karede GPU'ya yüklenen oluşturulmuş bölüm sayısı. Daha düşük değerler yükleme sırasında daha akıcı bir deneyim sağlar.",
"Soft shadows in corners. Costs chunk building time only.": "Köşelerde yumuşak gölgeler oluşturur. Yalnızca parça oluşturma süresini etkiler.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Uzak arazilerde blok dokularının daha küçük kopyalarını kullanır. Titreşimli görüntüyü azaltır.",
@@ -95,6 +95,7 @@
"UI Brightness": "Яскравість UI",
"Battery Mode": "Режим батареї",
"Graphics API": "API графіки",
"Translucent Batching": "Групування напівпрозорого",
"Performance Overlay": "Накладка швидкодії",
"Full Detail Distance": "Дальність деталей",
"On": "Увімк.",
@@ -142,6 +143,7 @@
"This feature is not yet available in Horizon.": "Ця функція ще недоступна в Horizon.",
"Your display or system doesn't offer HDR output.": "Ваш дисплей або система не підтримує вивід HDR.",
"Turn HDR on to use it.": "Увімкніть HDR, щоб користуватися цим.",
"Your GPU or graphics API doesn't support it.": "Ваш GPU або графічний API цього не підтримує.",
"%s chunks": "%s чанків",
"%s per frame": "%s на кадр",
"%s MiB/frame": "%s MiB/кадр",
@@ -164,7 +166,6 @@
"How far mobs, crops and machines keep working. CPU time.": "Як далеко працюють моби, посіви й механізми. Витрачає CPU.",
"How far away mobs and items are drawn, as a share of normal.": "Відстань малювання мобів і предметів, у частці від норми.",
"Skips terrain hidden behind closed sections. Off costs a lot.": "Пропускає приховану місцевість. Вимкнення коштує дорого.",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "Ховає моделі за блоками. Вимкніть із власними пакетами.",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "Секцій сітки за кадр. Менше — плавніше під час завантаження.",
"Soft shadows in corners. Costs chunk building time only.": "М’які тіні в кутах. Витрачає лише час на побудову чанків.",
"Smaller copies of block textures for distant terrain: less shimmer.": "Менші копії текстур блоків для віддаленої місцевості: менше мерехтіння.",
@@ -198,6 +199,7 @@
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "60 FPS (30 у меню/без фокуса), половина потоків: економія.",
"Threads that generate, light and mesh chunks. Next start.": "Потоки генерації, світла й сітки чанків. Після перезапуску.",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "Vulkan, інакше DirectX 12 (Windows)/OpenGL (Linux). Після рестарту.",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "Вода і скло в одному буфері: менше викликів малювання, трохи більше VRAM. Після перезапуску.",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "Дані сітки до GPU за кадр. Менше — плавніше завантаження.",
"Frame rate and frame times over the game, with a graph.": "Частота та час кадрів поверх гри, з графіком.",
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
@@ -286,5 +288,53 @@
"horizon.osk.delete": "Видалити",
"horizon.osk.type": "Ввести",
"horizon.osk.symbols": "Символи",
"horizon.osk.close": "Закрити"
"horizon.osk.close": "Закрити",
"Culling": "Відсікання",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "Ховає моделі за непрозорими блоками. Перевірка у фоні; сутності оновлюються.",
"Leaf Culling": "Відсікання листя",
"Every leaf face. Vanilla's fancy leaves.": "Кожна грань листя, як це робить Minecraft у гарній графіці.",
"Distant Plant Dither": "Дизеринг рослин",
"Keeps fine plants in high-res packs from fading far away.": "Не дає дрібним рослинам у пакетах високої роздільності зникати здалеку.",
"Screenshot Quality": "Якість знімків екрана",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "Знімки F2 у стільки разів більшій роздільності, ніж гра. Більше значить довше.",
"Frame Generation": "Генерація кадрів",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "Показує згенерований кадр між кожними двома справжніми: плавніше, трохи більша затримка.",
"Shadow Cache": "Кеш тіней",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "Повторно використовує карту тіней пакета, коли сонце, вигляд і світ збігаються. Типово вимкнено.",
"Synchronous": "Синхронно",
"Asynchronous": "Асинхронно",
"Solid": "Суцільне",
"Solid Aggressive": "Суцільне, агресивно",
"Hollow": "Порожнє",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "Листя всередині крони малюється суцільним: менше прозорих шарів.",
"Leaves with one open side draw solid too: faster, chunkier edges.": "Листя з одним відкритим боком теж малюється суцільним: швидше, грубіші краї.",
"Drops faces between leaves: faster, but gaps look through.": "Прибирає грані між листям: швидше, але прогалини прозорі.",
"Hotbar: Previous Slot": "Панель швидкого доступу: попередній слот",
"Hotbar: Next Slot": "Панель швидкого доступу: наступний слот",
"horizon.agent.connected.one": "Підключено: 1 з’єднання агента",
"horizon.agent.connected.many": "Підключено з’єднань агента: %s",
"horizon.agent.connected.one_ago": "Підключено: 1 з’єднання агента, остання команда %s с тому",
"horizon.agent.connected.many_ago": "Підключено з’єднань агента: %s, остання команда %s с тому",
"horizon.agent.waiting": "Очікування агента.",
"horizon.agent.off": "Вимкнено: агенти не можуть дістатися цієї гри.",
"horizon.agent.session_opened": "Відкрито на цей сеанс програмою, що запустила гру.",
"horizon.agent.socket": "Сокет: %s (скопіюйте дані, щоб побачити повний шлях)",
"horizon.agent.local_only": "Лише локально: агенти на цьому комп’ютері, ніколи через мережу.",
"horizon.extras.chat_animation": "Плавний чат",
"horizon.extras.chat_animation.tooltip": "Новий рядок чату виїжджає зліва, поступово з’являючись, замість того щоб з’явитися одразу.",
"horizon.extras.chat_indicator": "Позначка повідомлення",
"horizon.extras.chat_indicator.tooltip": "Кольорова смужка ліворуч від рядка чату.",
"horizon.extras.header.chat": "Чат",
"horizon.extras.header.particles": "Частинки",
"horizon.extras.header.sound": "Звук",
"horizon.extras.hide_block_particles": "Без частинок блоків",
"horizon.extras.hide_block_particles.tooltip": "Ховає частинки уламків, що розлітаються зі зламаного блока та осипаються з того, який ви ламаєте. Інші частинки залишаються.",
"horizon.extras.splitscreen": "Розділений екран",
"horizon.extras.splitscreen.tooltip": "У світі може долучитися ще один геймпад як новий гравець: натисніть A, коли запитає, потім виберіть профіль і де грати.",
"horizon.extras.ui_sound_reserve": "Канали для звуків UI",
"horizon.extras.ui_sound_reserve.tooltip": "Звуки у світі залишають вільними 16 із 247 звукових каналів, щоб їхній потік (великий вибух) не заглушив кліки в меню.",
"horizon.pages.open.split_setup": "Розділений екран…",
"horizon.pages.split_settings": "Налаштування розділеного екрана",
"horizon.pages.split_setup": "Розділений екран",
"horizon.pages.split_setup.tooltip": "Гравці на цьому комп’ютері: геймпади можуть долучитися до вашого світу."
}
@@ -89,6 +89,7 @@
"UI Brightness": "界面亮度",
"Battery Mode": "电池模式",
"Graphics API": "图形 API",
"Translucent Batching": "半透明批处理",
"Performance Overlay": "性能叠加层",
"Full Detail Distance": "全细节距离",
"On": "开",
@@ -136,6 +137,7 @@
"This feature is not yet available in Horizon.": "此功能在 Horizon 中尚不可用。",
"Your display or system doesn't offer HDR output.": "你的显示器或系统不支持 HDR 输出。",
"Turn HDR on to use it.": "开启 HDR 后才能使用。",
"Your GPU or graphics API doesn't support it.": "你的 GPU 或图形 API 不支持此功能。",
"%s chunks": "%s 个区块",
"%s per frame": "每帧 %s",
"%s MiB/frame": "每帧 %s MiB",
@@ -158,7 +160,6 @@
"How far mobs, crops and machines keep working. CPU time.": "生物、农作物和机器持续工作的距离。占用 CPU 时间。",
"How far away mobs and items are drawn, as a share of normal.": "生物和物品的绘制距离,按正常值的比例。",
"Skips terrain hidden behind closed sections. Off costs a lot.": "跳过被封闭区块遮挡的地形。关闭的开销很大。",
"Hides models behind opaque blocks. Entities update. Off with custom packs.": "隐藏不透明方块后面的模型。实体仍会更新。自定义资源包时应关闭。",
"Meshed sections uploaded per frame. Lower is smoother while loading.": "每帧上传的网格化区块数。越低加载时越流畅。",
"Soft shadows in corners. Costs chunk building time only.": "角落里的柔和阴影。只占用区块构建时间。",
"Smaller copies of block textures for distant terrain: less shimmer.": "为远处地形生成更小的方块纹理副本:减少闪烁。",
@@ -192,6 +193,7 @@
"60 FPS cap (30 in menus or unfocused), half the workers: less power.": "限制 60 FPS(菜单或失焦时 30),工作线程减半:更省电。",
"Threads that generate, light and mesh chunks. Next start.": "负责生成、光照与网格化区块的线程数。下次启动生效。",
"Vulkan, then DirectX 12 (Windows) or OpenGL (Linux) where it fails. Next start.": "优先 Vulkan,失败时用 DirectX 12(Windows)或 OpenGL(Linux)。下次启动生效。",
"Water and glass in one buffer: fewer draw calls, a bit more VRAM. Next start.": "水和玻璃放在同一个缓冲区:绘制调用更少,显存略多。下次启动生效。",
"Mesh data sent to the GPU per frame. Lower is smoother while loading.": "每帧发送给 GPU 的网格数据。越低加载时越流畅。",
"Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。",
"horizon.multiplayer.finding_friend": "正在查找好友的世界…",
@@ -303,5 +305,36 @@
"horizon.osk.close": "关闭",
"horizon.extras.chat_animation.tooltip": "新聊天行从左侧滑入并淡入,而不是整行直接出现。",
"horizon.extras.chat_indicator.tooltip": "聊天行左侧的彩色竖条。",
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。"
"horizon.extras.splitscreen.tooltip": "在世界中,另一个手柄可以以新玩家身份加入:提示时按 A,然后选择档案和游玩位置。",
"Culling": "剔除",
"Hides models behind opaque blocks. Async checks off-thread; entities tick.": "隐藏不透明方块后面的模型。检测在后台线程进行,实体仍会更新。",
"Leaf Culling": "树叶剔除",
"Every leaf face. Vanilla's fancy leaves.": "绘制每个树叶面,与原版的华丽树叶一致。",
"Distant Plant Dither": "远处植物抖动",
"Keeps fine plants in high-res packs from fading far away.": "避免高分辨率资源包中的细小植物在远处逐渐消失。",
"Screenshot Quality": "截图质量",
"F2 screenshots at this many times the game's resolution. Higher takes longer.": "F2 截图的分辨率为游戏分辨率的这个倍数。越高耗时越长。",
"Frame Generation": "帧生成",
"Shows a made-up frame between each two real ones: smoother, a little more lag.": "在每两个真实帧之间插入一个生成帧:更流畅,延迟略增。",
"Shadow Cache": "阴影缓存",
"Reuses the pack shadow map when sun, view and world match. Off by default.": "太阳、视角和世界不变时重复使用资源包的阴影贴图。默认关闭。",
"Synchronous": "同步",
"Asynchronous": "异步",
"Solid": "实心",
"Solid Aggressive": "激进实心",
"Hollow": "镂空",
"Leaves fully inside a canopy draw solid: fewer see-through layers.": "树冠内部的树叶绘制为实心:透明层更少。",
"Leaves with one open side draw solid too: faster, chunkier edges.": "只有一面外露的树叶也绘制为实心:更快,边缘更粗糙。",
"Drops faces between leaves: faster, but gaps look through.": "省略树叶之间的面:更快,但缝隙会透视。",
"Hotbar: Previous Slot": "快捷栏:上一个栏位",
"Hotbar: Next Slot": "快捷栏:下一个栏位",
"horizon.agent.connected.one": "已连接:1 个代理连接",
"horizon.agent.connected.many": "已连接:%s 个代理连接",
"horizon.agent.connected.one_ago": "已连接:1 个代理连接,上次命令在 %s 秒前",
"horizon.agent.connected.many_ago": "已连接:%s 个代理连接,上次命令在 %s 秒前",
"horizon.agent.waiting": "正在等待代理。",
"horizon.agent.off": "关闭:代理无法访问此游戏。",
"horizon.agent.session_opened": "由启动游戏的程序为本次会话开启。",
"horizon.agent.socket": "套接字:%s(复制详细信息以获取完整路径)",
"horizon.agent.local_only": "仅限本机:此电脑上的代理可用,网络不可用。"
}
@@ -89,8 +89,9 @@ fn main() -> Result<(), Error> {
});
let terrain = VanillaTerrain::new(&root.join("worldgen"), assets.registry, &biomes, seed)?;
// Generation and light, on the game's worker pool. Meshing is left out
// here and measured on its own below.
// Generation and light, on the game's worker pool (`threads` workers).
// Meshing is left out here and measured on its own below.
horizon_threads::jobs::configure(threads);
let mut streaming = Streaming::new(Arc::new(terrain), context.clone(), threads);
streaming.mesh = false;
streaming.center = center;
@@ -536,6 +536,9 @@ pub fn main() -> Result<()> {
let flying = op["value"].as_bool().unwrap();
world.player.may_fly = op["may_fly"].as_bool().unwrap_or(flying);
world.player.flying = flying;
if let Some(speed) = op.get("speed") {
world.player.flying_speed = single(speed);
}
}
"trigger" => world.player.jump_trigger_time = op["value"].as_i64().unwrap() as i32,
"delay" => world.player.no_jump_delay = op["value"].as_i64().unwrap() as i32,
+8 -8
View File
@@ -13,14 +13,14 @@ pub use horizon_client_render::{
chunk_map, cit, cit_armor, cit_assets, cit_glint, clouds, collisions, command_block,
containers, crash, datapack, dynamic_light, effects, environment, extras, fast_hash, fog, font,
gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays,
inventory_effects, java_text, jigsaw_block, level_loading, light, lod, logging, menu_blur,
mesher, minimap, mipmap, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_screen, packs, par, particles,
poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw, screen_effects,
sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins, sound_engine,
sound_events, sound_preview, spans, split_layout, split_profiles, sprites, streaming,
structure_block, tab_list, telemetry, test_block, test_instance_block, testgen, title_edition,
trims, ui, vault, weather, widgets, world, worldborder, zip,
inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging,
menu_blur, mesher, minimap, mipmap, mod_list, model_library, models, multiline_edit,
native_emissive, options_file, options_fixes, options_ui, overlay, pack_screen, packs, par,
particles, poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw,
screen_effects, sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins,
sound_engine, sound_events, sound_preview, spans, split_layout, split_profiles, sprites,
streaming, structure_block, tab_list, telemetry, test_block, test_instance_block, testgen,
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
pub mod attribute_updates;
+6 -48
View File
@@ -14,8 +14,7 @@ use horizon_world::nbt::Tag;
use serde_json::Value;
use std::{
collections::{HashMap, HashSet},
fs,
path::{Path, PathBuf},
path::Path,
sync::Arc,
};
@@ -1208,48 +1207,6 @@ pub fn recipe_result(v: &Value) -> Option<ItemStack> {
let template = Template::parse(v)?;
template.item.is_some().then(|| template.create())
}
/// The `.json` files under a `recipe` folder, its subfolders' too, in id
/// order: each one's path inside the folder without the extension (`a/b`, as a
/// recipe id has it) and its file. An error when `dir` itself can't be read; a
/// subfolder or an entry that can't be listed is warned and skipped, as
/// vanilla's `PathPackResources` keeps what it found.
pub(crate) fn recipe_files(dir: &Path) -> Result<Vec<(String, PathBuf)>> {
let mut files = Vec::new();
let mut stack = vec![fs::read_dir(dir)?];
while let Some(entries) = stack.pop() {
for entry in entries {
let entry = match entry {
Ok(entry) => entry,
Err(e) => {
log::warn!("Horizon: failed to list an entry of {}: {e}", dir.display());
continue;
}
};
let path = entry.path();
if path.is_dir() {
match fs::read_dir(&path) {
Ok(list) => stack.push(list),
Err(e) => log::warn!("Horizon: failed to list {}: {e}", path.display()),
}
} else if path.extension().is_some_and(|e| e == "json") {
files.push(path);
}
}
}
let mut named: Vec<(String, PathBuf)> = files
.into_iter()
.map(|path| {
let inside = path.strip_prefix(dir).unwrap_or(&path).with_extension("");
let name: Vec<_> = inside.iter().map(|c| c.to_string_lossy()).collect();
(name.join("/"), path)
})
.collect();
// Vanilla lists resources by id, in a map keyed by it
// (`FallbackResourceManager`): `crushing` before `crushing/iron`, where
// a path's components put the subfolder's file first.
named.sort_by(|a, b| a.0.cmp(&b.0));
Ok(named)
}
/// The type of a recipe the game doesn't craft itself: one outside `minecraft`.
/// A recipe's whole id from its name in the tables: vanilla's are kept bare
/// (`oak_planks`), everyone else's whole (`mymod:crusher/iron`).
@@ -1372,14 +1329,14 @@ impl Recipes {
}
mods
};
for (name, p) in recipe_files(&root.join("data/minecraft/recipe"))? {
sources.push((name, fs::read(&p)?));
for file in &crate::json_folder::read(&root.join("data/minecraft/recipe"))?.files {
sources.push((file.name.clone(), file.bytes()?.to_vec()));
}
for namespace in crate::mod_content::namespaces(root) {
let dir = root.join(format!("data/{namespace}/recipe"));
if dir.is_dir() {
for (name, p) in recipe_files(&dir)? {
sources.push((format!("{namespace}:{name}"), fs::read(&p)?));
for file in &crate::json_folder::read(&dir)?.files {
sources.push((format!("{namespace}:{}", file.name), file.bytes()?.to_vec()));
}
}
}
@@ -1887,6 +1844,7 @@ impl Recipes {
mod tests {
use super::*;
use crate::banner::BannerLayer;
use std::fs;
#[test]
fn result_count_is_bounded_like_vanilla() {
+70 -7
View File
@@ -183,6 +183,7 @@ mod maps;
mod minecarts;
mod mob_blocks;
mod mob_gear;
mod mob_index;
mod mob_looting;
mod mob_sounds;
mod mod_api_host;
@@ -2343,6 +2344,10 @@ struct Game<'a> {
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
/// Which entity this frame's entity vertices belong to (`Frame::entity_motion`).
entity_motion: std::cell::RefCell<Vec<(u32, u64)>>,
/// Mob models posed on the GPU (`Frame::skin`, `mobs::skin`): the models stored there,
/// and this frame's draws.
skin_models: std::cell::RefCell<horizon_client::mobs::skin::SkinModels>,
skin_frame: std::cell::RefCell<horizon_client::gfx::entity_skin::SkinFrame>,
/// The glowing entities' silhouettes this frame (game/glowing.rs).
glowing_vertices: std::cell::RefCell<Vec<Vertex>>,
/// The shader pack's entity and block entity ids, while one draws entities.
@@ -2439,6 +2444,8 @@ struct Game<'a> {
/// player's own (taken by `update_world`).
spawn_wait: Vec<(i32, i32)>,
mobs: Vec<Mob>,
/// `mobs` by network id, for the packets that name one (`game/mob_index.rs`).
mob_index: mob_index::MobIndex,
creatures: creatures::Creatures,
sculk: sculk::SculkState,
raiding: raids::Raiding,
@@ -2676,6 +2683,10 @@ struct Game<'a> {
cart_models: minecarts::CartModels,
/// The boat the player rides.
riding: Option<u32>,
/// The mount the last `position_rider` put the player on, to tell the tick
/// a seat is taken (`Entity.moveTo`, no camera sweep) from the ones that
/// follow it (`positionRider`, interpolated as usual).
posed_rider: Option<u32>,
/// The vehicle the player's file names, waiting for it to be in the world
/// (`ServerPlayer.loadAndSpawnParentVehicle`).
pending_vehicle: Option<[i32; 4]>,
@@ -2848,6 +2859,13 @@ struct Game<'a> {
/// `remote_handle_input`): picked once, so a creative break does not
/// move the use that follows it past the broken block.
tick_hit: Option<Option<Hit>>,
/// The mob of that `hitResult`, picked with the item held at the pick
/// (`LocalPlayer.raycastHitResult`: a spear's `attack_range`), before the
/// tick's number keys change it.
tick_mob: Option<Option<(usize, f64)>>,
/// Number keys since the last tick (`keyHotbarSlots[i].consumeClick`):
/// `Minecraft.handleKeybinds` selects them after the tick's pick.
hotbar_presses: Vec<usize>,
/// `Player.entityInteractionRange` and `blockInteractionRange`, refreshed
/// each tick from the attribute (a server's reach modifier, creative's
/// +2): the pick helpers' `&self` reach.
@@ -7149,6 +7167,11 @@ impl Game<'_> {
self.tick_riding(keys)?;
}
let riding = self.riding.is_some();
// A seat that is given up is taken again as a new one: the next mount
// snaps the camera even when it is the same vehicle.
if !riding {
self.posed_rider = None;
}
let entity_collisions = self.player_entity_collisions();
let spin_targets = self.spin_targets();
let sprint_window = self.settings.sprint_window;
@@ -8553,7 +8576,7 @@ impl Game<'_> {
batch.blur_at = batch.vertices.len() as u32;
// PackConfirmScreen takes the open screen's place (it comes back once
// the prompt is answered): the screen under the prompt isn't drawn.
let screen = if self.server_packs.prompt.is_some() {
let screen = if self.server_packs.prompt.is_some() || self.book.is_some() {
Screen::Playing
} else {
self.screen
@@ -8626,7 +8649,7 @@ impl Game<'_> {
/// read-only: what a mod draws is its own.
fn fire_screen_render_post(&self, batch: &mut HudBatch) {
let screen = self.screen;
if screen == Screen::Playing || self.server_packs.prompt.is_some() {
if screen == Screen::Playing || self.server_packs.prompt.is_some() || self.book.is_some() {
return;
}
// slot_count mirrors slots() without allocating; keep them in step.
@@ -11184,6 +11207,9 @@ impl Game<'_> {
darken
}
fn mob_hit(&self) -> Option<(usize, f64)> {
if let Some(hit) = self.tick_mob {
return hit;
}
let (eye, dir) = self.aim()?;
let reach: f64 = self.entity_reach;
let mut best: Option<(usize, f64)> = None;
@@ -11407,7 +11433,7 @@ impl Game<'_> {
let Some((index, distance)) = self.mob_hit() else {
return false;
};
if !self.before_block(distance) {
if !self.before_block(distance) || !self.held_range_allows(distance) {
return false;
}
self.attack_mob(index);
@@ -15529,6 +15555,7 @@ impl Game<'_> {
let entity_motion = std::mem::take(self.entity_motion.get_mut());
let glowing = std::mem::take(self.glowing_vertices.get_mut());
let shadow_only = std::mem::take(self.shadow_only_vertices.get_mut());
let skin = std::mem::take(self.skin_frame.get_mut());
let frame = Frame {
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
@@ -15612,6 +15639,7 @@ impl Game<'_> {
entities: &entities,
entity_motion: &entity_motion,
entity_spans: &entity_spans,
skin: skin.pending().then_some(&skin),
glowing: &glowing,
dynamic_lights: &self.dynamic_lights,
colored_lights: &colored_lights,
@@ -15692,6 +15720,18 @@ impl Game<'_> {
*self.entity_motion.get_mut() = entity_motion;
*self.glowing_vertices.get_mut() = glowing;
*self.shadow_only_vertices.get_mut() = shadow_only;
let mut skin = skin;
// The renderer took the new models when its store grew by them; otherwise they stay
// pending, or the stores restart (`SkinModels::begin`).
if self
.renderer
.as_ref()
.and_then(|r| r.skinned_models())
.is_some_and(|stored| stored == skin.base + skin.new_model.len() as u32)
{
skin.handed();
}
*self.skin_frame.get_mut() = skin;
self.see_through_vertices = see_through;
result
}
@@ -15966,6 +16006,8 @@ impl Game<'_> {
KeyCode::KeyE => match self.screen {
Screen::Playing if self.spectator() => {}
Screen::Playing => {
// handleKeybinds: the number keys select before key.inventory.
self.select_queued_hotbar();
// key.inventory while riding a tamed mount opens its inventory.
if !self.open_ridden_mount_menu() {
if self.tutorial.open_inventory() {
@@ -16006,6 +16048,8 @@ impl Game<'_> {
}
}
KeyCode::KeyF if self.screen == Screen::Playing => {
// handleKeybinds: the number keys select before key.swapOffhand.
self.select_queued_hotbar();
// handlePlayerAction SWAP_ITEM_WITH_OFFHAND: setItemInHand for
// the off hand, then the main hand.
std::mem::swap(&mut self.offhand, &mut self.inventory[self.selected]);
@@ -16017,6 +16061,8 @@ impl Game<'_> {
self.using = None;
}
KeyCode::KeyQ if self.screen == Screen::Playing => {
// handleKeybinds: the number keys select before key.drop.
self.select_queued_hotbar();
let all = drop_whole_stack(self.modifiers);
self.drop_held(all);
}
@@ -16054,7 +16100,7 @@ impl Game<'_> {
{
self.hotbar_load_or_save(slot, load);
} else {
self.selected = slot;
self.hotbar_presses.push(slot);
}
}
_ if self.screen == Screen::Paused && self.pause_menu => {
@@ -16105,6 +16151,8 @@ impl Game<'_> {
}
}
MouseButton::Middle if pressed => {
// handleKeybinds: the number keys select before key.pickItem.
self.select_queued_hotbar();
// Minecraft.pickBlockOrEntity -> the server's handlePickItemFromBlock:
// the block's clone item, with its block entity's data on Ctrl in
// creative, picked from the inventory (`pick_item`).
@@ -18234,6 +18282,8 @@ impl<'a> Game<'a> {
shadow_only_vertices: Default::default(),
entity_spans: Default::default(),
entity_motion: Default::default(),
skin_models: Default::default(),
skin_frame: Default::default(),
glowing_vertices: Default::default(),
pack_ids: None,
health_hud: hud_layout::HealthHud::default(),
@@ -18295,6 +18345,7 @@ impl<'a> Game<'a> {
player_spawn: None,
spawn_wait: Vec::new(),
mobs: Vec::new(),
mob_index: Default::default(),
creatures: Default::default(),
sculk: Default::default(),
raiding: Default::default(),
@@ -18436,6 +18487,7 @@ impl<'a> Game<'a> {
trim_slots: Vec::new(),
cart_models: prepared.cart_models,
riding: None,
posed_rider: None,
pending_vehicle: None,
ender_items: vec![None; 27],
ender_open: None,
@@ -18531,6 +18583,8 @@ impl<'a> Game<'a> {
world_icon: None,
forced_hit: None,
tick_hit: None,
tick_mob: None,
hotbar_presses: Vec::new(),
entity_reach: 3.0,
block_reach: 4.5,
guest_use: None,
@@ -18961,6 +19015,9 @@ impl Game<'_> {
return None;
}
};
// The recipe tables, the stations and the recipe book read one folder:
// hold its files so it is read once.
let _recipe_files = horizon_client::json_folder::read(&root.join("data/minecraft/recipe"));
let recipes = match Recipes::load(root, &items, &overlay) {
Ok(recipes) => recipes,
Err(e) => {
@@ -19338,6 +19395,9 @@ fn load_resources(
publish: &mut dyn FnMut(UiResources, &std::path::Path),
) -> Result<Loaded> {
let threads = settings.horizon.worker_threads();
// One pool of workers for every level's streaming, the client's and the
// server thread's (horizon_threads::jobs), sized before any world opens.
horizon_threads::jobs::configure(threads);
horizon_client::sprites::set_texture_budget(settings.horizon.texture_budget_mib);
horizon_client::sprites::set_labpbr_maps(shader_packs::picks_native_pack(settings));
// Stage A: the menu bundle (atlas and panorama). With a loading screen it is
@@ -19592,6 +19652,9 @@ fn load_world_rest(
let items = startup::time("rest.items", || Items::load(root, registry, facts, overlay))?;
log::info!("load items: {:.2}s", started.elapsed().as_secs_f32());
progress.set(7);
// The recipe tables, the stations and the recipe book read one folder:
// hold its files so it is read once.
let _recipe_files = horizon_client::json_folder::read(&root.join("data/minecraft/recipe"));
let recipes = startup::time("rest.recipes", || Recipes::load(root, &items, overlay))?;
let stations = startup::time("rest.stations", || {
horizon_client::stations::StationData::load(root, &items, overlay)
@@ -19917,6 +19980,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
entities: &[],
entity_motion: &[],
entity_spans: &[],
skin: None,
glowing: &[],
dynamic_lights: &[],
colored_lights: &[],
@@ -20653,9 +20717,8 @@ pub fn run_with() -> Result<()> {
horizon_threads::put_helpers_behind();
if options.execution_role.is_client() {
// The render thread, like the server thread, ahead of the workers on
// macOS and Windows. While terrain loads, the worker pools (two with
// single player's server thread) and other programs outnumber the
// cores, and a frame that gave its core up (in a GPU submit) waited
// macOS and Windows. While terrain loads, the workers
// (horizon_threads::jobs) and other programs outnumber the cores, and a frame that gave its core up (in a GPU submit) waited
// 10 to 200 ms to get one back.
horizon_threads::favour_current();
}
+139 -28
View File
@@ -86,34 +86,21 @@ impl Game<'_> {
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
.filter(|c| !c.path().as_os_str().is_empty())
.map(|s| Socket {
clients: s.clients(),
name: s
.path()
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned(),
});
status_lines(
server,
self.agent_last.map(|t| t.elapsed().as_secs()),
self.options.control.is_some() && !self.agent_control_on,
)
}
/// What Copy Connection Details puts on the clipboard: the socket, a horizon-ctl line to try,
@@ -138,3 +125,127 @@ impl Game<'_> {
)
}
}
/// The control socket as the AI Agent page tells of it.
struct Socket {
/// Agents connected now.
clients: usize,
/// The socket's file name; the full path is too wide for the page (Copy Connection Details
/// has it).
name: String,
}
/// The AI Agent page's lines in the selected language: the state, then where agents connect.
/// `last_command` is the seconds since an agent's last command, `session_only` whether the
/// program that started the game opened the socket and the player's switch did not.
fn status_lines(
server: Option<Socket>,
last_command: Option<u64>,
session_only: bool,
) -> Vec<String> {
use horizon_client::lang::{get, tr};
let mut lines = vec![match &server {
Some(s) if s.clients > 0 => {
let (one, many) = match last_command {
Some(_) => ("one_ago", "many_ago"),
None => ("one", "many"),
};
let key = format!(
"horizon.agent.connected.{}",
if s.clients == 1 { one } else { many }
);
let mut args: Vec<&dyn std::fmt::Display> = Vec::new();
if s.clients != 1 {
args.push(&s.clients);
}
if let Some(ago) = &last_command {
args.push(ago);
}
tr(&key, &args)
}
Some(_) => get("horizon.agent.waiting"),
None => get("horizon.agent.off"),
}];
if let Some(s) = &server {
if session_only {
lines.push(get("horizon.agent.session_opened"));
}
lines.push(tr("horizon.agent.socket", &[&s.name]));
}
lines.push(get("horizon.agent.local_only"));
lines
}
#[cfg(test)]
mod tests {
use super::{Socket, status_lines};
use horizon_client::lang::{Language, scoped};
fn lines(code: &str, clients: usize, ago: Option<u64>, session_only: bool) -> Vec<String> {
let socket = Socket {
clients,
name: "ctl.sock".into(),
};
let (lines, missed) = scoped(Language::load(&[], code), || {
status_lines(Some(socket), ago, session_only)
});
assert!(missed.is_empty(), "{code}: {missed:?}");
lines
}
/// The AI Agent page told its state in English in every language (#1927).
#[test]
fn agent_page_lines_follow_the_language() {
assert_eq!(
lines("de_de", 2, Some(5), false),
[
"Verbunden: 2 Agentenverbindungen, letzter Befehl vor 5 s",
"Socket: ctl.sock (Verbindungsdaten kopieren für den vollständigen Pfad)",
"Nur lokal: Agenten auf diesem Computer, nie über das Netzwerk.",
]
);
assert_eq!(
lines("pl_pl", 1, None, true),
[
"Połączono: 1 połączenie agenta",
"Otwarte na tę sesję przez program, który uruchomił grę.",
"Gniazdo: ctl.sock (skopiuj dane, aby poznać pełną ścieżkę)",
"Tylko lokalnie: agenci na tym komputerze, nigdy przez sieć.",
]
);
let (off, missed) = scoped(Language::load(&[], "fr_fr"), || {
status_lines(None, None, false)
});
assert!(missed.is_empty(), "{missed:?}");
assert_eq!(
off,
[
"Désactivé : les agents ne peuvent pas joindre ce jeu.",
"Local uniquement : agents sur cet ordinateur, jamais via le réseau.",
]
);
}
/// English keeps the sentences it always had: the count, its plural, the time since the last
/// command, and the wait for a first agent.
#[test]
fn agent_page_lines_in_english() {
assert_eq!(
lines("en_us", 2, Some(5), false)[0],
"Connected: 2 agent connections, last command 5 s ago"
);
assert_eq!(
lines("en_us", 1, None, false)[0],
"Connected: 1 agent connection"
);
assert_eq!(
lines("en_us", 1, Some(0), false)[0],
"Connected: 1 agent connection, last command 0 s ago"
);
assert_eq!(
lines("en_us", 3, None, false)[0],
"Connected: 3 agent connections"
);
assert_eq!(lines("en_us", 0, None, true)[0], "Waiting for an agent.");
}
}
+1 -1
View File
@@ -258,7 +258,7 @@ impl Game<'_> {
self.remote_close_container();
}
self.book = None;
self.capture(true);
self.capture(self.screen == super::Screen::Playing && !self.screen_over_playing());
}
/// `BookEditScreen.saveChanges` and `BookSignScreen`'s Sign and Close:
/// trailing empty pages dropped, the book in hand updated here and, on a
+2
View File
@@ -574,6 +574,8 @@ impl Game<'_> {
/// last time, when this method can restore it (a command draft only for
/// /), else with its prefix.
pub(super) fn open_chat_method(&mut self, prefix: &str) {
// handleKeybinds: the number keys select before key.chat and key.command.
self.select_queued_hotbar();
let command = prefix == "/";
let draft = self
.ui
+119 -77
View File
@@ -488,14 +488,15 @@ impl Game<'_> {
}
}
/// `LivingEntity.hurtArmor` -> `LivingEntity.doHurtEquipment`, from the hit
/// `MobEvent::ArmorHurt` reported: every worn piece that wears when its
/// wearer is hurt loses `max(1, damage / 4)` durability, Unbreaking
/// applied, and at its maximum the slot empties and the equipment-break
/// event goes out (`LivingEntity.entityEventForEquipmentBreak`: 49 to 52
/// for the armour, 65 for the body). A fire hit leaves a fire-resistant
/// piece alone (`ItemStack.canBeHurtBy`).
/// `MobEvent::ArmorHurt` reported. Only `Horse` overrides `hurtArmor` for a
/// mob, and its override asks for the BODY slot alone, so the body piece is
/// the only one this wears: it loses `max(1, damage / 4)` durability,
/// Unbreaking applied, and at its maximum the slot empties and the break
/// event goes out (`LivingEntity.entityEventForEquipmentBreak`: 65 for the
/// body — 49 to 52 are the four armour slots, which only a player's own
/// pieces reach). A fire hit leaves a fire-resistant piece alone
/// (`ItemStack.canBeHurtBy`).
fn hurt_mob_armor(&mut self, id: u32, damage: f32, kind: horizon_client::mobs::DamageKind) {
use horizon_client::mobs::drops;
use horizon_world::player_inventory::EquipmentSlot;
if damage <= 0.0 {
return;
@@ -504,7 +505,6 @@ impl Game<'_> {
return;
};
let at = [m.position.x, m.position.y, m.position.z];
let body_armour = drops::head_is_body(m.species());
let amount = ((damage / 4.0) as u32).max(1);
let name = damage_type(kind, false);
let fire = self
@@ -514,9 +514,15 @@ impl Game<'_> {
.id(name)
.is_some_and(|d| self.motion.health.damage.in_tag(d, "minecraft:is_fire"));
// A piece's maximum, or none when it may not lose durability at all.
// `doHurtEquipment` starts from the item's `equippable` component
// (`Equippable equippable = item.get(DataComponents.EQUIPPABLE)`), so a
// damageable item with no such component — a sword put in a horse's body
// slot by `/item replace` — never wears; `equip_damage_on_hurt` alone
// cannot say that, since it defaults to true when the component is absent.
fn wearable(me: &Game<'_>, stack: &ItemStack, fire: bool, name: &str) -> Option<u32> {
let info = me.res.items().get(&stack.item)?;
if !info.equip_damage_on_hurt
if info.equip_slot.is_none()
|| !info.equip_damage_on_hurt
|| info.max_damage == 0
|| !horizon_client::stations::is_damageable(me.res.items(), stack)
|| (fire && me.res.items().resists_fire(stack, name))
@@ -525,66 +531,39 @@ impl Game<'_> {
}
Some(info.max_damage)
}
let mut broke: Vec<i8> = Vec::new();
// The four armour slots, then the body slot (`extra.head`).
let event_of = horizon_client::mobs::item_break::equipment_break_event;
for (index, slot) in [
(0usize, EquipmentSlot::Head),
(1, EquipmentSlot::Chest),
(2, EquipmentSlot::Legs),
(3, EquipmentSlot::Feet),
] {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return;
};
let Some(stack) = m.extra.armor[index].clone() else {
continue;
};
let Some(max) = wearable(self, &stack, fire, name) else {
continue;
};
let loss = self.durability_loss(&stack, amount);
if loss == 0 {
continue;
}
let lost = stack.damage + loss;
let Some(body) = self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| m.item_by_slot(EquipmentSlot::Body))
else {
return;
};
let Some(max) = wearable(self, &body, fire, name) else {
return;
};
let loss = self.durability_loss(&body, amount);
if loss == 0 {
return;
}
let lost = body.damage + loss;
let broke = {
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
if lost >= max {
m.extra.armor[index] = None;
broke.push(event_of(slot));
} else if let Some(worn) = m.extra.armor[index].as_mut() {
worn.damage = lost;
m.extra.head = None;
m.extra.body_damage = 0;
true
} else {
m.extra.body_damage = lost as i32;
false
}
}
if body_armour {
let body = self
.mobs
.iter()
.find(|m| m.id == id)
.and_then(|m| m.item_by_slot(EquipmentSlot::Body));
if let Some(body) = body
&& let Some(max) = wearable(self, &body, fire, name)
{
let loss = self.durability_loss(&body, amount);
if loss > 0 {
let lost = body.damage + loss;
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
if lost >= max {
m.extra.head = None;
m.extra.body_damage = 0;
broke.push(event_of(EquipmentSlot::Body));
} else {
m.extra.body_damage = lost as i32;
}
}
}
}
for event in broke {
};
if broke {
// LivingEntity.onEquippedItemBroken through the level's broadcast.
let event =
horizon_client::mobs::item_break::equipment_break_event(EquipmentSlot::Body);
self.lan_entity_event(id as i32, event, at);
}
}
@@ -6119,6 +6098,19 @@ impl Game<'_> {
let lookup = |name: &str| atlas.sprites.get(name).map(|r| [r.u0, r.v0, r.u1, r.v1]);
let scale = self.view_scale();
self.rendered_mobs.set(0);
// Mob models posed on the GPU (mobs::skin), when the renderer takes them this frame.
let skinning = self
.renderer
.as_ref()
.filter(|r| r.skins_entities())
.and_then(|r| r.skinned_models());
match skinning {
Some(stored) => self
.skin_models
.borrow_mut()
.begin(&mut self.skin_frame.borrow_mut(), stored),
None => self.skin_frame.borrow_mut().clear_draws(),
}
// What custom entity model animations read of the frame.
horizon_client::mobs::cem_anim::set_frame(horizon_client::mobs::cem_anim::Frame {
time: self.world.game_time as f64 + alpha,
@@ -6261,21 +6253,55 @@ impl Game<'_> {
Vec::new()
};
let body = out.len();
let render = if layers_only {
horizon_client::mobs::render::render_layers
// The GPU path: an ordinary mob, no outline, no layers the CPU path changes after
// drawing them (a player's, the HDR tiers below, the sulfur cube's shell).
let posable = skinning.is_some()
&& !is_player
&& !layers_only
&& outline.is_none()
&& armor.is_empty()
&& !matches!(
mob.species(),
Species::Blaze | Species::MagmaCube | Species::GlowSquid | Species::SulfurCube
);
let mut skinned = None;
if posable {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mark = frame.mark();
let mut skin = horizon_client::mobs::skin::Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
horizon_client::mobs::render::render_skinned(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
out,
&mut skin,
);
skinned = Some((mark, skin.bounds));
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
let render = if layers_only {
horizon_client::mobs::render::render_layers
} else {
horizon_client::mobs::render::render_with
};
render(
mob,
alpha as f32,
camera,
&lookup,
packed,
self.res.mob_data(),
&armor,
out,
);
}
// HDR output: mobs that are light shine by their kind, as blocks do (entity.wgsl
// fs_hdr_mark); a tier's window lifts only their bright texels.
if let Some(tier) = match mob.species() {
@@ -6323,7 +6349,23 @@ impl Game<'_> {
if outline.is_none()
&& horizon_client::mobs::guardian::beam(mob, alpha as f32).is_none()
{
// What the GPU draws of the mob counts by its bounds' corners.
let corners = skinned.and_then(|(_, bounds)| bounds);
if let Some((lo, hi)) = corners {
out.extend([lo, hi].map(|pos| Vertex {
pos: pos.to_array(),
..bytemuck::Zeroable::zeroed()
}));
}
let len = out.len();
self.cull_entity_vertices(out, entity_start);
if out.len() < len {
if let Some((mark, _)) = skinned {
self.skin_frame.borrow_mut().truncate(mark);
}
} else if corners.is_some() {
out.truncate(len - 2);
}
}
// GuardianRenderer: the laser to the locked target.
if let Some((time, scale)) = horizon_client::mobs::guardian::beam(mob, alpha as f32)
@@ -712,6 +712,17 @@ mod tests {
assert_eq!(range, 48.0);
}
/// `Entity.shouldRenderAtSqrDistance` for a dropped item (`ItemEntity`
/// keeps the default and its box is 0.25 on each side): 24 blocks at
/// render distance 12, 32 at 16, 40 from 20 on; never 64.
#[test]
fn a_dropped_item_is_drawn_sixteen_blocks_times_the_view_scale() {
let item = glam::DVec3::splat(0.25);
assert_eq!(default_range(item, entity_view_scale(12, 1.0)), 24.0);
assert_eq!(default_range(item, entity_view_scale(16, 1.0)), 32.0);
assert_eq!(default_range(item, entity_view_scale(32, 1.0)), 40.0);
}
/// `Entity.shouldRenderAtSqrDistance` on a broken box: the size falls
/// back to one block.
#[test]
+97 -8
View File
@@ -205,6 +205,94 @@ impl Game<'_> {
self.attack_ticks = 0;
self.hand_strength.reset();
}
/// `Minecraft.tick`'s `pick`, then `handleKeybinds`' number keys: the
/// tick's clicks act on what the item held before the keys picked (a
/// spear's 4.5 blocks), with the item the keys select (attribute
/// swapping a spear's reach onto a mace).
pub(super) fn pick_tick_targets(&mut self) {
self.tick_hit = Some(self.pick());
self.tick_mob = None;
let mob = match self.active_attack_range() {
Some(range) => self.attack_range_pick(&range),
None => None,
};
self.tick_mob = Some(mob.unwrap_or_else(|| self.mob_hit()));
self.select_queued_hotbar();
}
/// `handleKeybinds`' number keys (`keyHotbarSlots[i].consumeClick`).
pub(super) fn select_queued_hotbar(&mut self) {
for slot in std::mem::take(&mut self.hotbar_presses) {
self.selected = slot;
}
}
/// The tick's clicks are done: later picks follow the crosshair again.
pub(super) fn release_tick_targets(&mut self) {
self.tick_hit = None;
self.tick_mob = None;
}
/// `Minecraft.startAttack` on an entity: a main-hand item with an
/// `attack_range` attacks only a hit inside it.
pub(super) fn held_range_allows(&self, distance: f64) -> bool {
let range = self.held().and_then(|stack| {
self.pack
.item_renderer
.models
.components(&horizon_client::item_render::stack_view(stack))
.get("minecraft:attack_range")
.cloned()
});
range.is_none_or(|r| attack_range_contains(&r, distance, self.creative))
}
/// `LocalPlayer.raycastHitResult`'s item (`getActiveItem`: the one in
/// use, else the main hand's) `attack_range` component.
fn active_attack_range(&self) -> Option<serde_json::Value> {
let active = self
.using
.and_then(|(slot, _)| self.inventory.get(slot).and_then(Option::as_ref))
.or_else(|| self.held())?;
self.pack
.item_renderer
.models
.components(&horizon_client::item_render::stack_view(active))
.get("minecraft:attack_range")
.cloned()
}
/// `AttackRange.getClosesetHit` against the mobs `mob_hit` can pick
/// (`EntitySelector.CAN_BE_PICKED`), as `raycastHitResult` reads it: the
/// nearest mob hit within the item's reach; no mob when the clip met a
/// block within the block reach (`getHitEntitiesAlong`'s block result,
/// kept by `filterHitResult`); `None` on a miss, where the plain pick
/// decides.
fn attack_range_pick(&self, range: &serde_json::Value) -> Option<Option<(usize, f64)>> {
let eye = v3(self.eye_position()?);
let look = v3(super::view_vector(self.yaw, self.pitch));
let reach = attack_reach(range, self.creative);
let camera = self.remote_camera_id();
let boxes: Vec<(usize, Aabb)> = (self.mobs.iter().enumerate())
.filter(|(_, m)| !m.removed && Some(m.id) != self.riding && Some(m.id) != camera)
.map(|(i, m)| (i, m.bounds()))
.collect();
let clip = |from: Vec3, to: Vec3| {
let length = from.distance_squared(to).sqrt();
let d = to.subtract(from).scale(1.0 / length);
(length > 0.0)
.then(|| self.clip_ray([from.x, from.y, from.z], [d.x, d.y, d.z], length))
.flatten()
.map(|hit| Vec3::new(hit.point[0], hit.point[1], hit.point[2]))
};
let movement = self.known_player_movement();
let nearest = super::spear::hits_in_reach(&boxes, eye, look, movement, reach, clip)
.into_iter()
.map(|(i, at)| (i, eye.distance_squared(at).sqrt()))
.reduce(|a, b| if b.1 < a.1 { b } else { a });
if nearest.is_some() {
return Some(nearest);
}
let to = eye.add_vector(look.scale(f64::from(reach.1) + movement.dot(look).max(0.0)));
clip(eye, to)
.is_some_and(|block| eye.distance_squared(block) <= self.block_reach.powi(2))
.then_some(None)
}
/// The attack and use part of `Minecraft.tick` for the local player:
/// attack clicks since the last tick and the held attack button.
pub(super) fn tick_local_hand(&mut self) -> Result<()> {
@@ -216,11 +304,10 @@ impl Game<'_> {
// (scripts under --control hold buttons without grabbing it).
let down = self.left;
let ticks = self.clicks.destroying.ticks;
// Minecraft.hitResult is picked once per frame: a creative break
// does not move the use that follows it past the broken block.
if use_clicks > 0 && attack_clicks > 0 {
self.tick_hit = Some(self.pick());
}
// Minecraft.hitResult is picked once per tick, before the number
// keys: a creative break does not move the use that follows it
// past the broken block.
self.pick_tick_targets();
let mut world = GameHands {
game: self,
error: None,
@@ -229,7 +316,7 @@ impl Game<'_> {
let error = world.error.take();
self.clicks = clicks;
if let Some(e) = error {
self.tick_hit = None;
self.release_tick_targets();
return Err(e);
}
// Use clicks follow the attack clicks within the tick, against
@@ -245,7 +332,7 @@ impl Game<'_> {
break;
}
}
self.tick_hit = None;
self.release_tick_targets();
used?;
// A tick of breaking: the hit sound every fourth tick and the
// cracking particles.
@@ -267,8 +354,10 @@ impl Game<'_> {
}
}
} else {
// Minecraft.tick: a screen keeps the attack from starting.
// Minecraft.tick: a screen keeps the attack from starting, and
// opening it released the keys (`KeyMapping.releaseAll`).
self.clicks.miss_time = 10000;
self.hotbar_presses.clear();
}
let d = &self.clicks.destroying;
self.mining = d.active.then_some(Mining {
+11 -1
View File
@@ -483,12 +483,22 @@ impl Game<'_> {
}
_ => None,
});
// Entity.shouldRenderAtSqrDistance for the item's 0.25 box: 16 blocks
// times the view scale (32 at render distance 16), from its current
// position. A pickup particle is not an entity and keeps 64 blocks.
let range =
super::entity_culling::default_range(glam::DVec3::splat(0.25), self.view_scale());
for (item, at, partial, entity) in live.chain(taken) {
let entity_start = out.len();
self.mark_motion(entity_start, (2 << 32) | u64::from(item.id));
let q = item.position;
let pos = glam::DVec3::from_array(at) - camera;
if pos.length_squared() > 64.0 * 64.0 {
let far = if entity {
(glam::DVec3::new(q.x, q.y, q.z) - camera).length_squared() >= range * range
} else {
pos.length_squared() > 64.0 * 64.0
};
if far {
continue;
}
// EntityRenderer.shouldRender: the item's 0.25 box, grown by 0.5,
+23 -4
View File
@@ -383,6 +383,15 @@ impl Game<'_> {
let _ =
m.apply_saved_attributes(g.saved.as_ref().and_then(|t| t.get("attributes")));
}
// ServerPlayer.restoreFrom: a body that follows another takes its
// attributes (a scale of 0.1 stays 0.1 after a respawn or a trip
// to another level).
let g = &mut host.hub.guests[i];
if let Some(carried) = g.carried_attributes.take()
&& let Some(m) = g.motion.as_mut()
{
let _ = m.apply_saved_attributes(Some(&carried));
}
// 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();
@@ -2516,8 +2525,9 @@ impl Game<'_> {
}
let g = &mut host.hub.guests[gi];
(g.yaw, g.pitch) = (yaw, pitch);
// A new body there: no fall distance or motion carried over.
g.motion = None;
// A new body there: no fall distance or motion carried over, but the
// player's attributes (ServerPlayer.teleport keeps the entity).
g.new_body(true);
host.hub.teleport(gi, Some(at));
}
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
@@ -3248,14 +3258,16 @@ impl Game<'_> {
g.load_timeout = horizon_client::lan::guest::CLIENT_LOADED_TIMEOUT;
g.loading = true;
g.death_time = 0;
g.health = 20.0;
g.food = 20;
g.saturation = 5.0;
g.sent_health = None;
if let Some(m) = g.motion.take() {
// ServerPlayer.restoreFrom(old, false): the base values come back,
// and the new player has the max health they give.
if let Some(m) = g.new_body(false) {
let m = *m;
g.logs_kept = Some((m.hurt_log, m.attack_log));
}
g.health = g.respawn_health(self.motion);
let mut xp = Experience {
level: g.xp_level,
progress: g.xp_progress,
@@ -3847,6 +3859,12 @@ impl Game<'_> {
w.player.fall_distance = 0.0;
}
let travelled: Vec<[f64; 3]> = moves.iter().map(|m| m.0).collect();
// The tick's whole movement, for the delta movement a glide's wall
// hit reads (`handleMovePlayer` gives the server copy the client's
// own movement).
let tick_movement = travelled.iter().fold(Vec3::ZERO, |sum, d| {
sum.add_vector(Vec3::new(d[0], d[1], d[2]))
});
// Entity.move's movementThisTick: each move, from where the
// tick's first one started.
let end = w.player.position;
@@ -3957,6 +3975,7 @@ impl Game<'_> {
let _ = player.peaceful_regeneration(rules);
let _ = player.food_tick(health, rules);
events.append(&mut player.events);
player.carry_client_movement(tick_movement);
let _ = MotionWorld {
entities: &[],
player,
@@ -0,0 +1,90 @@
//! Finding a mob by its network id without walking the whole list: a
//! multiplayer client looks one up for every movement, data, motion and
//! event packet. A `Mob` is ~3 KB, so a linear search touches a cache line
//! per mob: 1000 movement packets in a 3000-mob farm cost ~5 ms a tick that
//! way, against ~0.01 ms through the index.
//!
//! The index is a cache: the mob list changes in many places (spawns,
//! removals, dimension swaps), and none of them has to tell it. A hit is
//! checked against the list, and a miss falls back to the linear search,
//! which rebuilds the index when it finds the mob. Ids are unique in the list
//! (`spawn_entity` drops a mob with the same id before adding one), so the
//! answer is always the linear search's.
use horizon_client::fast_hash::FxHashMap;
use horizon_client::mobs::Mob;
#[derive(Default)]
pub(super) struct MobIndex {
slots: FxHashMap<u32, u32>,
}
impl MobIndex {
/// The position of the mob with this id in `mobs`, as
/// `mobs.iter().position(|m| m.id == id)`.
pub(super) fn find(&mut self, mobs: &[Mob], id: u32) -> Option<usize> {
self.position(mobs, |m| m.id, id)
}
fn position<T>(&mut self, items: &[T], id_of: impl Fn(&T) -> u32, id: u32) -> Option<usize> {
if let Some(&i) = self.slots.get(&id)
&& items.get(i as usize).is_some_and(|m| id_of(m) == id)
{
return Some(i as usize);
}
let i = items.iter().position(|m| id_of(m) == id)?;
self.slots.clear();
for (i, m) in items.iter().enumerate() {
self.slots.entry(id_of(m)).or_insert(i as u32);
}
Some(i)
}
/// The mob with this id, as `mobs.iter_mut().find(|m| m.id == id)`.
pub(super) fn get_mut<'a>(&mut self, mobs: &'a mut [Mob], id: u32) -> Option<&'a mut Mob> {
let i = self.find(mobs, id)?;
Some(&mut mobs[i])
}
/// The mob with this id, as `mobs.iter().find(|m| m.id == id)`.
pub(super) fn get<'a>(&mut self, mobs: &'a [Mob], id: u32) -> Option<&'a Mob> {
let i = self.find(mobs, id)?;
Some(&mobs[i])
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Whatever the list went through since the last lookup, the index
/// answers as the linear search does.
#[test]
fn finds_what_a_linear_search_finds_after_any_change() {
let mut index = MobIndex::default();
let check = |index: &mut MobIndex, ids: &[u32]| {
for id in 0..20 {
let found = index.position(ids, |&m| m, id);
assert_eq!(
found,
ids.iter().position(|&m| m == id),
"id {id} in {ids:?}"
);
}
};
let mut ids = vec![7, 3, 9, 12];
check(&mut index, &ids);
ids.remove(1);
check(&mut index, &ids);
ids.push(3);
ids.swap(0, 2);
check(&mut index, &ids);
ids.retain(|&m| m != 9);
ids.insert(0, 15);
check(&mut index, &ids);
// Another list altogether: a dimension swapped in.
check(&mut index, &[12, 15, 1]);
ids.clear();
check(&mut index, &ids);
}
}
+7 -1
View File
@@ -536,8 +536,14 @@ impl Game<'_> {
let rear = Vec3::new(rear[0], rear[1], rear[2])
.rotate_y(-m.yaw * (std::f64::consts::PI / 180.0) as f32);
at = Vec3::new(at.x + rear.x, at.y + rear.y, at.z + rear.z);
// The tick a rider takes its seat is a teleport (`Entity.moveTo`), which
// sets the old position too: the camera must not sweep from where the
// player stood to the seat. The ticks after it move with the mount and
// are interpolated as usual.
let first = self.posed_rider != self.riding;
self.posed_rider = self.riding;
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
self.previous_position = if first { at } else { p.position };
p.position = at;
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
@@ -1288,6 +1288,21 @@ impl Game<'_> {
pub(super) fn native_frame(&mut self, elapsed: Duration) {
mod_api::dispatch_frame(self, mod_api::mods(), elapsed);
}
/// The frame rate this window is about to be held to: Max Framerate after the
/// inactivity throttle and Battery Mode (260 is unlimited). `focused` is whether the
/// window has focus, `minimized` whether it is minimised and `in_world` whether a world
/// is open. A mod may lower `limit`, as Dynamic FPS does for a window in the background:
/// Horizon keeps the lower of its own limit and the mod's, and ignores values below 1.
/// API @API:framerate_limit@.
pub(super) fn native_framerate_limit(
&self,
focused: bool,
minimized: bool,
in_world: bool,
limit: &mut i32,
) {
let _ = (focused, minimized, in_world, limit);
}
/// Render-thread notification after a resource-pack reload has applied successfully.
/// Atlas, models, renderer and client caches already refer to the new resources.
/// Selection/F3+T and server-pack reloads share this point; initial startup does not.
@@ -734,6 +734,16 @@ impl Game<'_> {
ids.push((o.id, true));
}
}
for t in &self.tridents {
if tracked.contains_key(&t.id) {
ids.push((t.id, true));
}
}
for r in &self.rockets {
if tracked.contains_key(&r.id) && r.attached_to.is_none() && !r.attached {
ids.push((r.id, true));
}
}
}
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
self.portals.entities.retain(|id, _| present.contains(id));
@@ -921,6 +931,30 @@ impl Game<'_> {
inside.push((p.id, pos));
}
}
// A trident (0.5 across) or rocket (0.25) in flight, or one that moved
// this tick; a rocket that carries its owner stays.
let mut flyers: Vec<(u32, Vec3, Vec3, f32)> = Vec::new();
for t in self
.tridents
.iter_mut()
.filter(|t| !t.in_ground || t.previous != t.position)
{
let id = super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
flyers.push((id, t.previous, t.position, 0.5));
}
for r in self
.rockets
.iter_mut()
.filter(|r| r.attached_to.is_none() && !r.attached)
{
let id = super::lan_entities::assign_entity_id(&mut self.next_entity, &mut r.id);
flyers.push((id, r.previous, r.position, 0.25));
}
for (id, from, to, size) in flyers {
if let Some(pos) = self.swept_portal(from, to, size, size, &mut portal_sections) {
inside.push((id, pos));
}
}
self.portals.sections = portal_sections;
for (id, pos) in inside {
self.portals
@@ -1058,6 +1092,28 @@ impl Game<'_> {
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.tridents.iter().position(|t| t.id == id) {
let plan = self.entity_plan(entry, self.tridents[i].position, 0.5, 0.5, false);
if !self.may_enter(plan.dim) {
return;
}
let uuid = self.uuid_for(id);
let tag = self.tridents[i].to_nbt(uuid, self.persist_raw(id));
self.tridents.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.rockets.iter().position(|r| r.id == id) {
let plan = self.entity_plan(entry, self.rockets[i].position, 0.25, 0.25, false);
if !self.may_enter(plan.dim) {
return;
}
let uuid = self.uuid_for(id);
let tag = self.rockets[i].to_nbt(uuid, self.level.player_uuid, self.persist_raw(id));
self.rockets.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.orbs.iter().position(|o| o.id == id) {
let plan = self.entity_plan(entry, self.orbs[i].position, 0.5, 0.5, false);
if !self.may_enter(plan.dim) {
+16 -2
View File
@@ -574,9 +574,23 @@ impl Game<'_> {
has_overlay: false,
};
let limit = framerate_limit(self.settings.max_fps as i32, context);
self.settings
let limit = self
.settings
.horizon
.battery_limit(limit, self.title.is_none(), self.focused)
.battery_limit(limit, self.title.is_none(), self.focused);
// A native mod may only lower it (`native_framerate_limit`).
let mut hooked = limit;
self.native_framerate_limit(
self.focused,
context.iconified,
self.title.is_none(),
&mut hooked,
);
if hooked >= 1 {
hooked.min(limit)
} else {
limit
}
}
/// `Minecraft.runTick`'s frame limiter: `FramerateLimiter.limitDisplayFPS`
/// below 260 frames per second, sleeping then spin-waiting until the
+8
View File
@@ -299,6 +299,7 @@ impl Game<'_> {
pack,
textures,
cem,
block_entity_shapes,
} = staged;
// Keep the working GPU resources, and hand its window surface over.
if let Some(renderer) = &mut self.renderer {
@@ -372,6 +373,9 @@ impl Game<'_> {
.cem
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = cem;
self.res
.block_entity_render()
.set_shapes(block_entity_shapes);
// The random entity textures and emissive overlays the new atlas packs. A new scan is
// a new generation:
// every mob (every splitscreen player's too) chooses again on its next tick.
@@ -479,6 +483,7 @@ pub(super) fn stage_pack(
&root,
res.mob_types(),
));
let block_entity_shapes = res.block_entity_render().shapes_for(&root);
progress.set(3);
Ok(StagedPack {
root,
@@ -486,6 +491,7 @@ pub(super) fn stage_pack(
pack,
textures,
cem,
block_entity_shapes,
})
}
@@ -498,6 +504,8 @@ pub(super) struct StagedPack {
pub textures: PreparedTextures,
/// The packs' custom entity models (OptiFine CEM).
pub cem: Arc<horizon_client::mobs::cem::CemModels>,
/// The block entities' models, the packs' custom entity models over vanilla's.
pub block_entity_shapes: Arc<horizon_client::block_entity_render::BlockEntityShapes>,
}
/// `LoadingOverlay`'s stage lines for a pack reload, indexed by `progress`.
+7 -2
View File
@@ -493,8 +493,11 @@ impl Game<'_> {
}
"minecraft:trident" => {
if let Some(n) = entity_nbt::trident_from_nbt(&tag) {
self.tridents
.push(super::trident::ThrownTrident::from_nbt(n));
let mut t = super::trident::ThrownTrident::from_nbt(n);
// One that came through a portal keeps its cooldown.
super::lan_entities::assign_entity_id(&mut self.next_entity, &mut t.id);
self.restore_portal_cooldown(t.id, &tag);
self.tridents.push(t);
}
}
"minecraft:firework_rocket" => {
@@ -503,6 +506,8 @@ impl Game<'_> {
let id = self.next_entity;
let mut rocket = super::fireworks::Rocket::from_nbt(r);
rocket.id = id;
// One that came through a portal keeps its cooldown.
self.restore_portal_cooldown(id, &tag);
self.rockets.push(rocket);
self.remember(id, uuid, tag);
}
@@ -427,6 +427,7 @@ impl Game<'_> {
let visually_swimming =
p.pose == Pose::Swimming || (!p.fall_flying && p.pose == Pose::FallFlying);
let in_water = p.fluids.was_touching_water;
let in_lava = p.is_in_lava();
let (yaw, pitch) = (self.yaw, self.pitch);
let main = self.inventory[self.selected].clone();
let off = self.offhand.as_ref().map(|s| s.item.clone());
@@ -497,6 +498,9 @@ impl Game<'_> {
m.extra.crouching = crouching;
// Guests and server players take their pose from DATA_POSE, not here.
m.extra.avatar_pose = pose;
// What custom entity model animations read of the fluids (`is_in_water`, crawling).
m.fluids.in_water = in_water;
m.fluids.in_lava = in_lava;
m.extra.passenger = riding;
m.extra.previous_velocity = m.velocity;
m.velocity = velocity;
+64 -38
View File
@@ -2230,7 +2230,7 @@ impl Game<'_> {
)
})
} else {
self.mobs.iter().find(|m| m.id == id as u32).map(|m| {
self.mob_index.get(&self.mobs, id as u32).map(|m| {
(
[m.position.x, m.position.y, m.position.z],
m.kind.width,
@@ -2448,9 +2448,8 @@ impl Game<'_> {
let dragon = self.dragon.as_ref().is_some_and(|d| d.id as i32 == id);
let driven = self.driven_mount().is_some_and(|m| m.id as i32 == id);
let mob = self
.mobs
.iter_mut()
.find(|m| m.id == id as u32)
.mob_index
.get_mut(&mut self.mobs, id as u32)
.filter(|_| !dragon);
let tracked = self
.remote
@@ -2852,7 +2851,7 @@ impl Game<'_> {
value("minecraft:movement_speed"),
value("minecraft:jump_strength"),
);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) else {
let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) else {
return;
};
// A horse its rider's client moves goes at its own speed and jumps
@@ -3284,7 +3283,7 @@ impl Game<'_> {
None => Some(Holder::Mob(d as u32)),
},
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.source as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.source as u32) {
m.extra.leash = holder;
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id == p.source as u32) {
// A leashed boat or raft (AbstractBoat is Leashable).
@@ -3304,7 +3303,7 @@ impl Game<'_> {
// Entity.DATA_SHARED_FLAGS_ID, DATA_CUSTOM_NAME and
// DATA_CUSTOM_NAME_VISIBLE: server NPCs and holograms rely on
// these instead of saved NBT.
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
(0, Value::Byte(flags)) => {
@@ -3337,7 +3336,7 @@ impl Game<'_> {
}
}
self.remote_mob_data(p.id, &p.values);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
&& m.species() == horizon_client::mobs::Species::Creeper
{
for (index, _, value) in &p.values {
@@ -3350,7 +3349,7 @@ impl Game<'_> {
}
}
}
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
for (index, _, value) in &p.values {
match (index, value) {
// Entity.DATA_TICKS_FROZEN: isFullyFrozen shakes it.
@@ -3382,7 +3381,7 @@ impl Game<'_> {
for (index, _, value) in &p.values {
if let (14, Value::OptPos(bed)) = (index, value) {
let code = bed.map_or(0, |b| self.bed_facing(b));
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
m.extra.ints[1] = code;
if let Some(head) = *bed {
// LivingEntity.onSyncedDataUpdated(SLEEPING_POS_ID): setPosToBed.
@@ -3401,14 +3400,14 @@ impl Game<'_> {
// Avatar.DATA_PLAYER_MAIN_HAND (HumanoidArm: 0 left, 1
// right): the arm a swing of the main hand moves.
if let (15, Value::Id(arm)) = (index, value)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
m.extra.main_hand_left = *arm == 0;
}
// Avatar.DATA_PLAYER_MODE_CUSTOMISATION: the skin layers
// the player shows (PlayerModelPart bits).
if let (16, Value::Byte(bits)) = (index, value)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
m.extra.hidden_model_parts = !(*bits as u8) & 0x7F;
}
@@ -3518,7 +3517,7 @@ impl Game<'_> {
.remote
.as_ref()
.is_some_and(|r| r.level_players.contains_key(&p.id)))
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
for (index, _, value) in &p.values {
if let (0, Value::Byte(flags)) = (index, value) {
@@ -3571,7 +3570,7 @@ impl Game<'_> {
)
})
});
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
if let Some(charged) = charged
&& &*m.kind.id == "minecraft:player"
{
@@ -3753,7 +3752,7 @@ impl Game<'_> {
pitch,
);
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
m.limb_speed = 1.5;
m.hurt_time = 10;
} else {
@@ -3766,7 +3765,7 @@ impl Game<'_> {
if let Some(pl) = self.player.as_mut() {
pl.health.animate_hurt_dir(p.yaw);
}
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
m.hurt_time = 10;
} else {
self.remote_dragon_hurt(p.id);
@@ -3810,20 +3809,20 @@ impl Game<'_> {
self.fox_eat_particles(p.id as u32);
}
if p.event == 1
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
&& m.species() == horizon_client::mobs::Species::Rabbit
{
horizon_client::mobs::rabbit::hop_event(m);
}
if p.event == 19
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
horizon_client::mobs::aquatic::squid_cycle_event(m);
}
// LivingEntity.handleEntityEvent 55: another player swapped
// its main and off hand (`swapHandItems`).
if p.event == 55
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
std::mem::swap(&mut m.extra.main_hand, &mut m.extra.off_hand);
}
@@ -3835,7 +3834,7 @@ impl Game<'_> {
}
// EntityEvent.DEATH: start the death animation.
if p.event == 3
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32)
{
m.health = 0.0;
}
@@ -3859,12 +3858,12 @@ impl Game<'_> {
);
}
// The events that start a mob's animation (the warden's).
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
horizon_client::mobs::sync::entity_event(m, p.event as u8);
}
// LivingEntity.handleEntityEvent 60: the poof as the dead body goes.
if p.event == 60
&& let Some(m) = self.mobs.iter().find(|m| m.id == p.id as u32)
&& let Some(m) = self.mob_index.get(&self.mobs, p.id as u32)
{
let (at, w, h) = (m.position, f64::from(m.width()), f64::from(m.height()));
self.death_poof([at.x, at.y, at.z], w, h);
@@ -3941,7 +3940,7 @@ impl Game<'_> {
};
if self.remote.as_ref().is_some_and(|r| r.entity_id == p.id) {
self.swing_hand(hand, animation);
} else if let Some(m) = self.mobs.iter_mut().find(|m| m.id == p.id as u32) {
} else if let Some(m) = self.mob_index.get_mut(&mut self.mobs, p.id as u32) {
m.swing_hand_modified(hand == Hand::Off, animation);
}
}
@@ -5219,7 +5218,7 @@ impl Game<'_> {
fn remote_motion(&mut self, id: i32, velocity: [f64; 3]) {
self.remote_dragon_motion(id, velocity);
let v = Vec3::new(velocity[0], velocity[1], velocity[2]);
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
// Squid.aiStep derives its body pitch and roll from network motion.
m.velocity = v;
} else if let Some(d) = self.dropped.iter_mut().find(|d| d.id == id as u32) {
@@ -8181,9 +8180,26 @@ impl Game<'_> {
self.stop_remote_mining();
}
if !ready {
// A screen's opening released the keys (`KeyMapping.releaseAll`).
self.hotbar_presses.clear();
return;
}
// MultiPlayerGameMode.tick syncs the carried slot before keybinds.
self.remote_send_carried();
let (attack, use_item) = (self.left, self.right);
// A tick that clicks, attacks or uses acts with the slot its number
// keys select (`ensureHasSentCarriedItem` before the packets).
let acts = input.has_clicks() || attack || use_item;
input.tick(&mut RemoteInputWorld(self, acts), attack, use_item);
self.remote_queued_keys();
if let Some(r) = self.remote.as_mut() {
r.input = input;
}
}
/// `MultiPlayerGameMode.ensureHasSentCarriedItem`: the server hears of a
/// new selected slot before anything the slot acts with.
fn remote_send_carried(&mut self) {
let carried_changed = self.remote.as_mut().is_some_and(|r| {
if r.carried == Some(self.selected) {
false
@@ -8197,8 +8213,9 @@ impl Game<'_> {
slot: self.selected as i16,
}));
}
let (attack, use_item) = (self.left, self.right);
input.tick(&mut RemoteInputWorld(self), attack, use_item);
}
fn remote_queued_keys(&mut self) {
// handleKeybinds' keybind polls: the queued presses act now, in tick
// order, so no packet goes out between CLIENT_TICK_END and a pong.
let pending = self
@@ -8229,9 +8246,6 @@ impl Game<'_> {
if self.clicks.miss_time > 0 {
self.clicks.miss_time -= 1;
}
if let Some(r) = self.remote.as_mut() {
r.input = input;
}
}
fn remote_start_attack(&mut self) -> bool {
@@ -8300,11 +8314,14 @@ impl Game<'_> {
} else if let Some((i, distance)) = self.mob_hit()
&& self.before_block(distance)
{
self.send(S::Attack(play::Attack {
id: self.mobs[i].id as i32,
}));
self.remote_player_attack(i);
self.reset_attack_strength();
// Minecraft.startAttack: an item's attack_range must hold the hit.
if self.held_range_allows(distance) {
self.send(S::Attack(play::Attack {
id: self.mobs[i].id as i32,
}));
self.remote_player_attack(i);
self.reset_attack_strength();
}
} else if !self.entity_targeted()
&& let Some(hit) = self.pick()
{
@@ -8519,7 +8536,9 @@ impl Game<'_> {
// `KeyMapping.releaseAll` clears key.use; without this the button still
// reads held and a use would start again on the first tick back.
self.right = false;
horizon_client::interaction_input::release_on_screen_open(&mut RemoteInputWorld(self));
horizon_client::interaction_input::release_on_screen_open(&mut RemoteInputWorld(
self, false,
));
}
/// `MultiPlayerGameMode.releaseUsingItem`: sends `RELEASE_USE_ITEM` and
/// runs the client half of `Item.releaseUsing` (a Riptide launch moves the
@@ -9818,7 +9837,8 @@ fn synced_local_use(
}
}
struct RemoteInputWorld<'g, 'a>(&'g mut Game<'a>);
/// The game as the tick's input sees it, and whether the tick acts.
struct RemoteInputWorld<'g, 'a>(&'g mut Game<'a>, bool);
struct RemoteUseWorld<'g, 'a>(&'g mut Game<'a>);
@@ -9836,10 +9856,16 @@ impl horizon_client::interaction_input::TargetUseWorld for RemoteUseWorld<'_, '_
impl horizon_client::interaction_input::InteractionWorld for RemoteInputWorld<'_, '_> {
fn pick_hit_result(&mut self) {
self.0.tick_hit = Some(self.0.pick());
self.0.pick_tick_targets();
// MultiPlayerGameMode.ensureHasSentCarriedItem: an action this tick
// sends the slot the number keys chose first; otherwise the next
// tick's MultiPlayerGameMode.tick does.
if self.1 {
self.0.remote_send_carried();
}
}
fn release_hit_result(&mut self) {
self.0.tick_hit = None;
self.0.release_tick_targets();
}
fn using_item(&self) -> bool {
self.0.using.is_some()
@@ -487,7 +487,7 @@ impl Game<'_> {
(14, _, Value::OptPos(bed)) => Some(bed.map_or(0, |b| self.bed_facing(b))),
_ => None,
});
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id as u32) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, id as u32) {
apply(m, values, &r.builtin, r.entity_id);
apply_registry_data(m, values, &r.registries, self.world.game_time);
if let Some(code) = bed
@@ -795,7 +795,10 @@ impl Game<'_> {
out.push(json!({"id": c.id, "kind": "minecraft:end_crystal", "position": at(c.position), "show_bottom": c.show_bottom}));
}
for t in &self.tridents {
out.push(json!({"id": t.id, "kind": "minecraft:trident", "position": at(t.position)}));
out.push(json!({"id": t.id, "kind": "minecraft:trident", "position": at(t.position), "velocity": at(t.velocity)}));
}
for r in &self.rockets {
out.push(json!({"id": r.id, "kind": "minecraft:firework_rocket", "position": at(r.position), "velocity": at(r.velocity)}));
}
for p in &self.creatures.projectiles {
out.push(json!({"id": p.id, "kind": format!("{:?}", p.kind).split([' ', '{']).next().unwrap_or(""), "position": at(p.position), "velocity": at(p.velocity),
@@ -170,7 +170,7 @@ impl Game<'_> {
} else if self.riding == Some(vehicle_id) {
// The server takes the player out, then teleports it.
self.riding = None;
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == vehicle_id) {
if let Some(m) = self.mob_index.get_mut(&mut self.mobs, vehicle_id) {
m.rider = None;
}
// HappyGhast.removePassenger: a still timeout of 10 (the server's
@@ -237,9 +237,17 @@ impl Game<'_> {
cart
};
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
// The tick a rider takes its seat is a teleport (`Entity.moveTo`,
// which sets the old position too): the camera must not sweep from
// where the player stood to the cushion, boat or cart. The ticks
// after it move with the vehicle and interpolate as usual.
let first = self.posed_rider != self.riding;
self.posed_rider = self.riding;
if let Some(v) = seat {
self.previous_position = if first { v } else { p.position };
p.position = v;
} else {
self.previous_position = p.position;
}
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
@@ -278,7 +286,7 @@ impl Game<'_> {
pub(super) fn remote_move_vehicle(&mut self, position: [f64; 3], yaw: f32) {
let at = Vec3::new(position[0], position[1], position[2]);
if let Some(id) = self.driven_mount().map(|m| m.id)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
&& let Some(m) = self.mob_index.get_mut(&mut self.mobs, id)
{
m.previous = at;
m.position = at;
@@ -670,8 +670,9 @@ impl Game<'_> {
host.play.entry(trip.conn).or_default().portal.cooldown = 10;
let g = &mut host.hub.guests[i];
g.yaw = yaw;
// A new body at the exit: no fall distance or motion carried over.
g.motion = None;
// A new body at the exit: no fall distance or motion carried over,
// but the player's attributes (ServerPlayer.teleport keeps the entity).
g.new_body(true);
let at = [position.x, position.y, position.z];
if portal {
host.hub.land(i, at);
@@ -727,8 +728,9 @@ impl Game<'_> {
let g = &mut host.hub.guests[i];
let [yaw, pitch] = t.rotation.unwrap_or([g.yaw, g.pitch]);
let origin = g.position;
// A new body there: no fall distance or motion carried over.
g.motion = None;
// A new body there: no fall distance or motion carried over, but
// the player's attributes (ServerPlayer.teleport keeps the entity).
g.new_body(true);
let cooldown = host.play.get(&t.conn).map_or(0, |p| p.portal.cooldown);
let ctx = self.server_ctx();
host.hub
+31 -7
View File
@@ -38,6 +38,10 @@ fn sign_box() -> EditBox {
edit
}
/// The Done button's text, `CommonComponents.GUI_DONE`, in the selected language.
fn done_label() -> String {
super::screen_info::tr("gui.done", &[])
}
/// AbstractSignEditScreen.init's Done button: `width / 2 - 100`,
/// `height / 4 + 144`, int GUI pixels.
fn done_button(w: f32, h: f32) -> (f32, f32) {
@@ -841,11 +845,7 @@ impl Game<'_> {
}
let (bx, by) = done_button(w, h);
horizon_client::widgets::narratable(|| {
horizon_client::widgets::Narratable::button(
super::screen_info::tr("gui.done", &[]),
[bx, by, 200.0, 20.0],
true,
)
horizon_client::widgets::Narratable::button(done_label(), [bx, by, 200.0, 20.0], true)
});
batch.sprite_scaled(
atlas,
@@ -856,10 +856,11 @@ impl Game<'_> {
20.0,
u32::MAX,
);
let tw = HudBatch::text_width(atlas, "Done");
let done = done_label();
let tw = HudBatch::text_width(atlas, &done);
batch.text(
atlas,
"Done",
&done,
bx + 100.0 - (tw / 2.0).floor(),
by + 6.0,
u32::MAX,
@@ -876,4 +877,27 @@ mod tests {
assert_eq!(super::done_button(427.0, 334.0), (113.0, 227.0));
assert_eq!(super::done_button(320.0, 240.0), (60.0, 204.0));
}
/// The Done button said "Done" in every language (#1927).
#[test]
fn done_button_reads_the_selected_language() {
use horizon_client::lang::{Language, ResourceSource, scoped};
struct Vanilla;
impl ResourceSource for Vanilla {
fn read(&self, path: &str) -> Option<Vec<u8>> {
match path {
"assets/minecraft/lang/en_us.json" => Some(br#"{"gui.done": "Done"}"#.to_vec()),
"assets/minecraft/lang/de_de.json" => {
Some(br#"{"gui.done": "Fertig"}"#.to_vec())
}
_ => None,
}
}
}
for (code, want) in [("de_de", "Fertig"), ("en_us", "Done")] {
let (label, missed) = scoped(Language::load(&[&Vanilla], code), super::done_label);
assert_eq!(label, want, "{code}");
assert!(missed.is_empty(), "{code}: {missed:?}");
}
}
}
+7 -13
View File
@@ -326,20 +326,14 @@ impl Game<'_> {
(false, false) => "gui/sprites/widget/tab",
};
ui::nine_slice(batch, atlas, sprite, x, 0.0, tw, TAB_H, u32::MAX);
let label = Text::translate(key, &[]);
let lw = atlas.font.width_text(&label);
let color = if selected && active {
u32::MAX
} else {
0xFFA0_A0A0
};
batch.text_styled(
ui::menu_tab_label(
batch,
atlas,
&label,
(x + (tw - lw) / 2.0).floor(),
8.0,
color,
true,
&Text::translate(key, &[]),
(x, 0.0, tw, TAB_H),
selected,
active,
horizon_client::hud_overlays::now_ms(),
);
if selected {
batch.fill(atlas, x + 2.0, TAB_H - 3.0, tw - 4.0, 1.0, u32::MAX);
+78 -5
View File
@@ -45,7 +45,7 @@ impl PrimedTnt {
collision: &horizon_world::block_collision::BlockCollisionRegistry<'_>,
assets: &horizon_client::assets::Assets,
facts: &horizon_client::blocks::BlockFacts,
) -> Result<()> {
) -> Result<Vec<BlockPos>> {
self.previous = self.position;
if !self.no_gravity {
self.velocity.y -= 0.04;
@@ -74,14 +74,17 @@ impl PrimedTnt {
if moved.y != wanted.y {
self.velocity.y = 0.0;
}
// Entity.applyEffectsFromBlocks: a bubble column lifts or drags it.
horizon_client::motion::bubble_column_push(
// Entity.applyEffectsFromBlocks: a bubble column lifts or drags it, and the
// columns it is at the top of send their surface particles.
let mut tops = Vec::new();
horizon_client::motion::bubble_column_push_over(
world,
assets.registry,
facts,
collision,
self.bounds(),
&mut self.velocity,
|pos| tops.push(pos),
);
self.velocity = Vec3::new(
self.velocity.x * 0.98,
@@ -120,7 +123,7 @@ impl PrimedTnt {
},
)?;
}
Ok(())
Ok(tops)
}
}
@@ -199,12 +202,16 @@ impl Game<'_> {
i += 1;
continue;
}
t.tick(
let tops = t.tick(
&self.world,
self.collision,
&self.pack.assets,
self.res.facts(),
)?;
for pos in tops {
self.bubble_column_particles(pos);
}
let t = &mut self.tnts[i];
if t.fuse <= 0 {
let center = [
t.position.x,
@@ -766,4 +773,70 @@ mod tests {
tnt.position.y
);
}
/// `Entity.onAboveBubbleColumn` sends the surface particles only from the top of
/// a column: `tick` names the column block it is at the top of, and none while
/// the column has water above it.
#[test]
fn primed_tnt_reports_the_surface_of_a_bubble_column() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = horizon_client::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = horizon_client::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let collision = horizon_client::collisions::load(&root, assets.registry).unwrap();
let mut world = horizon_client::world::World::test_scene(&assets, &render, 3).unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
let soul_sand = assets
.registry
.default_state("minecraft:soul_sand")
.unwrap();
let default = assets
.registry
.default_state("minecraft:bubble_column")
.unwrap();
let mut up = assets.registry.state(default).unwrap().clone();
up.properties.insert("drag".into(), "false".into());
let column = assets.registry.resolve(&up).unwrap();
for y in 3..=8 {
world.set(20, y, 20, column).unwrap();
}
world.set(20, 2, 20, soul_sand).unwrap();
let at = Vec3::new(20.5, 6.0, 20.5);
let primed = || PrimedTnt {
no_gravity: false,
id: 0,
position: at,
previous: at,
velocity: Vec3::ZERO,
fuse: 80,
on_ground: false,
owner: None,
guest: None,
};
let mut tops = Vec::new();
let mut tnt = primed();
for _ in 0..30 {
tops.extend(tnt.tick(&world, &collision, &assets, &facts).unwrap());
}
assert!(!tops.is_empty(), "it reaches the top of the column");
assert!(
tops.iter().all(|p| *p == BlockPos::new(20, 8, 20)),
"only the surface block, got {tops:?}"
);
// Water above the top block: it is no longer the surface.
world.set(20, 9, 20, water).unwrap();
let mut tnt = primed();
for _ in 0..10 {
assert!(
tnt.tick(&world, &collision, &assets, &facts)
.unwrap()
.is_empty()
);
}
}
}
@@ -59,6 +59,7 @@ impl Game<'_> {
let mut nodes = widgets::tree(&widgets::last_labelled(), &widgets::last_targets(), size);
if matches!(self.screen, Screen::Inventory | Screen::Creative)
&& self.title.is_none()
&& self.book.is_none()
&& let Ok((ox, oy)) = self.gui_origin()
{
nodes.extend(self.slots().into_iter().map(|i| {
+29
View File
@@ -426,6 +426,35 @@ impl Game<'_> {
);
}
self.falling = falling;
// Firework rockets (0.25 wide, eyes at 0.85 of it), tridents in flight
// (0.5 wide, eyes at 0.13; like any arrow not pushed in the ground)
// take `Entity.pushFromExplosion` alike.
let mut rockets = std::mem::take(&mut self.rockets);
for r in &mut rockets {
let p = r.position;
push_box(
self,
p,
[p.x - 0.125, p.y, p.z - 0.125],
[p.x + 0.125, p.y + 0.25, p.z + 0.125],
0.2125,
&mut r.velocity,
);
}
self.rockets = rockets;
let mut tridents = std::mem::take(&mut self.tridents);
for t in tridents.iter_mut().filter(|t| !t.in_ground) {
let p = t.position;
push_box(
self,
p,
[p.x - 0.25, p.y, p.z - 0.25],
[p.x + 0.25, p.y + 0.5, p.z + 0.25],
f64::from(0.13_f32),
&mut t.velocity,
);
}
self.tridents = tridents;
// VehicleEntity.ignoreExplosion: a mob's burst (a breeze's) leaves
// boats and minecarts alone with mob griefing off.
if !(breeze && !self.ui.rules.bool("mob_griefing")) {
@@ -826,6 +826,57 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
/// A recipe filter's `replace_input` (#5910) reaches the book swapped:
/// the book writes from the network copy, so the swap must have run
/// there, once, before the ingredient normalization. A world's pack
/// carries the filter (the way the game reads them).
#[test]
fn a_recipe_filters_swapped_input_shows_swapped_in_the_book() {
let root = resources(
"filter_swap",
&[(
"copper_ingot_from_smelting_raw_copper",
r#"{"type":"minecraft:smelting","cookingtime":200,"experience":0.7,
"ingredient":"minecraft:raw_copper",
"result":{"id":"minecraft:copper_ingot"}}"#,
)],
);
let packs = vec![(
"recipe_filter/minecraft:swap".to_owned(),
vec![
br#"{"filters":[{"id":"minecraft:copper_ingot_from_smelting_raw_copper",
"replace_input":{"from":"minecraft:raw_copper","to":"minecraft:redstone"}}]}"#
.to_vec(),
],
)];
let overlay = horizon_world::loot::PackOverlay { files: &packs };
let sync = SyncData::load(&root, &overlay).unwrap();
let entries = sync.advancements.book_entries(
"minecraft:copper_ingot_from_smelting_raw_copper",
&sync,
NOTIFY_AND_HIGHLIGHT,
);
assert_eq!(entries.len(), 1, "the recipe has one display");
let mut r = Reader::new(&entries[0]);
let e = RecipeBookEntry::read(&mut r).unwrap();
assert_eq!(r.remaining(), 0, "the entry ends with its flags");
let item = |n: &str| sync.id("minecraft:item", n).unwrap();
assert_eq!(
e.requirements,
Some(vec![HolderSet::Items(vec![item("minecraft:redstone")])]),
"the filter's swapped input, not the file's"
);
let RecipeDisplay::Furnace { ingredient, .. } = e.display else {
panic!("a smelting recipe shows a furnace display");
};
assert_eq!(
ingredient,
SlotDisplay::Tag(HolderSet::Items(vec![item("minecraft:redstone")])),
"the display shows the swapped input too"
);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn blasting_reaches_the_blast_furnace_book() {
let root = resources(
+1 -2
View File
@@ -271,8 +271,7 @@ impl SyncData {
super::advancements::ServerAdvancements::load(resources, overlay, recipes, groups)
},
};
sync.recipes =
super::recipes::update_recipes(&sync.advancements.recipes, resources, &sync, overlay);
sync.recipes = super::recipes::update_recipes(&sync.advancements.recipes, &sync);
Ok(sync)
}
+131 -2
View File
@@ -214,6 +214,11 @@ pub struct Guest {
/// 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 attribute list the player's last body hands to its next one
/// (`ServerPlayer.restoreFrom`, `PlayerMotion::carried_attributes`),
/// until that body exists: the scale a player has set stays through a
/// respawn and a change of level (#3684).
pub carried_attributes: Option<horizon_world::nbt::Tag>,
/// The vehicle the player's file names (`ServerPlayer.saveParentVehicle`'s
/// "RootVehicle"), for the ride to come back on the next login.
pub saved_vehicle: Option<[i32; 4]>,
@@ -410,6 +415,7 @@ impl Guest {
tracker: Tracker::default(),
motion: None,
loaded_body: None,
carried_attributes: None,
saved_vehicle: None,
logs_kept: None,
inventory: vec![None; 36],
@@ -600,14 +606,39 @@ impl Guest {
Pose::Standing
}
}
/// `Avatar.POSES` for the guest's pose, with its scale attribute.
/// `Avatar.POSES` for the guest's pose, with its scale attribute: also for
/// a pose the client has announced (gliding) before the body's own tick
/// has taken it.
pub fn dimensions(&self) -> Dimensions {
let pose = self.pose();
match &self.motion {
Some(m) if m.pose == pose => m.dimensions,
_ => pose.dimensions(1.0),
Some(m) => pose.dimensions(m.scale),
None => pose.dimensions(1.0),
}
}
/// The player's body is replaced (`ServerPlayer.restoreFrom`): the next
/// one starts from the old one's attributes, all of them when it is the
/// same player in another level (`restore_all`), only their base values
/// after a death. The old body, for what else it hands over.
pub fn new_body(&mut self, restore_all: bool) -> Option<Box<PlayerMotion>> {
let m = self.motion.take()?;
self.carried_attributes = Some(m.carried_attributes(restore_all));
Some(m)
}
/// `ServerPlayer.restoreFrom(old, false)`'s `setHealth(getMaxHealth())`:
/// the health a player comes back from a death with, the max health of
/// the base values their next body takes over, before its equipment
/// modifies it (40 after `/attribute @s minecraft:max_health base set
/// 40`, not 20).
pub fn respawn_health(&self, resources: &crate::motion::MotionResources) -> f32 {
let Ok(mut body) = PlayerMotion::new(resources, Vec3::ZERO) else {
return 20.0;
};
let _ = body.apply_saved_attributes(self.carried_attributes.as_ref());
body.attribute("minecraft:max_health")
.map_or(20.0, |max| max as f32)
}
/// `Entity.getBoundingBox`.
pub fn bounds(&self) -> Aabb {
let [x, y, z] = self.position;
@@ -1384,4 +1415,102 @@ mod tests {
Some(Component::Text(_))
));
}
/// The prepared game data a body needs, or `None` without it.
fn resources() -> Option<(crate::assets::Assets, crate::motion::MotionResources)> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
eprintln!("skipped: prepared resources unavailable");
return None;
}
let assets = crate::assets::Assets::load(&root).ok()?;
let resources = crate::motion::MotionResources::load(&root, &assets).ok()?;
Some((assets, resources))
}
/// `/attribute @s <attribute> base set`, as `AttributeCommand` runs it,
/// and the next tick's `refreshDirtyAttributes` (the box of a new scale).
fn set_base(m: &mut PlayerMotion, attribute: &str, value: f64) {
let key = m.attributes.key(attribute).unwrap();
let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap();
m.attributes
.edit(&handle, |attribute, observer| {
attribute.set_base_value(value, observer)
})
.unwrap();
m.refresh_attributes().unwrap();
}
fn assert_dims(got: Dimensions, want: (f32, f32, f32), what: &str) {
let close = |a: f32, b: f32| (a - b).abs() <= 1e-5 * b.abs().max(1.0);
assert!(
close(got.width, want.0) && close(got.height, want.1) && close(got.eye_height, want.2),
"{what}: {got:?}, want {want:?}"
);
}
/// `ServerPlayer.restoreFrom(old, false)` takes the base values
/// (`assignBaseValues`), then `setHealth(getMaxHealth())`: a player whose
/// max health base was set comes back with that much, not 20, and a
/// permanent modifier (`/attribute ... modifier add`) does not count, as
/// it stays with the dead body. The attribute's range is 1..1024.
#[test]
fn a_respawned_player_has_the_max_health_of_their_base_values() {
use horizon_world::attributes::{Modifier, Operation};
let Some((_assets, resources)) = resources() else {
return;
};
for (base, bonus, want) in [
(40.0, 0.0, 40.0),
(10.0, 0.0, 10.0),
(20.0, 20.0, 20.0),
(0.0, 0.0, 1.0),
(2048.0, 0.0, 1024.0),
] {
let mut g = guest_at([0.5, 64.0, 0.5]);
let mut m = PlayerMotion::new(&resources, Vec3::new(0.5, 64.0, 0.5)).unwrap();
set_base(&mut m, "minecraft:max_health", base);
let key = m.attributes.key("minecraft:max_health").unwrap();
let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap();
let bonus = Modifier::new("test:bonus", bonus, Operation::AddValue).unwrap();
m.attributes
.edit(&handle, |a, o| a.add_permanent(bonus, o))
.unwrap();
g.motion = Some(Box::new(m));
assert!(g.new_body(false).is_some());
assert_eq!(g.respawn_health(&resources), want, "base {base}");
}
// No body to take anything from: the default.
let g = guest_at([0.5, 64.0, 0.5]);
assert_eq!(g.respawn_health(&resources), 20.0);
}
/// #3684: a glide the client announced (`START_FALL_FLYING`) is the
/// player's pose before its body's own tick has taken it: the box in
/// between is the gliding one at the player's scale, not at scale 1 (a
/// 0.6 box on a player 0.1 or 2 times as big sent them back from a wall).
#[test]
fn a_glide_announced_before_the_body_ticks_has_the_scaled_box() {
let Some((_assets, resources)) = resources() else {
return;
};
for (scale, side) in [(0.1_f64, 0.06_f32), (2.0, 1.2)] {
let mut g = guest_at([0.5, 64.0, 0.5]);
let mut m = PlayerMotion::new(&resources, Vec3::new(0.5, 64.0, 0.5)).unwrap();
set_base(&mut m, "minecraft:scale", scale);
m.set_pose(Pose::Standing).unwrap();
g.motion = Some(Box::new(m));
g.fall_flying = true;
assert_eq!(g.pose(), Pose::FallFlying);
assert_ne!(g.motion.as_ref().unwrap().pose, Pose::FallFlying);
let d = g.dimensions();
assert_dims(
d,
(side, side, side * (0.4 / 0.6)),
&format!("scale {scale}"),
);
}
}
}
+92 -2
View File
@@ -275,12 +275,19 @@ pub fn store(guest: &Guest, level: &mut Level, vehicle: Option<Tag>) {
}
// LivingEntity.addAdditionalSaveData: the live body's AttributeMap.pack,
// or the list the file had while the player has no body yet.
if let Some(m) = guest.motion.as_deref() {
// A player between two bodies (a respawn, a change of level) saves what
// the next one takes over.
let attributes = guest
.motion
.as_deref()
.map(crate::motion::PlayerMotion::saved_attributes)
.or_else(|| guest.carried_attributes.clone());
if let Some(attributes) = attributes {
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 {
map.insert("attributes".into(), m.saved_attributes());
map.insert("attributes".into(), attributes);
}
}
level.recipe_book = guest.recipe_book.clone();
@@ -543,6 +550,89 @@ mod tests {
Some((assets, resources))
}
/// A body at scale 0.1 with a permanent +0.4 on its step height, such as
/// a command or a datapack gives.
fn small_body(resources: &crate::motion::MotionResources) -> crate::motion::PlayerMotion {
use horizon_world::attributes::{Modifier, Operation};
let mut m = crate::motion::PlayerMotion::new(resources, horizon_core::geometry::Vec3::ZERO)
.expect("a body");
let key = m.attributes.key("minecraft:scale").unwrap();
let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap();
m.attributes
.edit(&handle, |a, o| a.set_base_value(0.1, o))
.unwrap();
let key = m.attributes.key("minecraft:step_height").unwrap();
let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap();
let step = Modifier::new("test:step", 0.4, Operation::AddValue).unwrap();
m.attributes
.edit(&handle, |a, o| a.add_permanent(step, o))
.unwrap();
m
}
#[test]
fn a_new_body_takes_the_scale_of_the_old_one() {
// #3684: ServerPlayer.restoreFrom copies the base values to the body
// that follows a death (assignBaseValues) and, for the same player in
// another level, the permanent modifiers too (assignPermanentModifiers).
// The body Horizon made on a respawn or a level change started from
// the defaults: a player at /attribute scale 0.1 was 1.0 again.
let Some((_assets, resources)) = resources() else {
eprintln!("skipped: prepared resources unavailable");
return;
};
let next = |g: &Guest| {
let mut m =
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
.expect("a body");
m.apply_saved_attributes(g.carried_attributes.as_ref())
.unwrap();
m
};
for (restore_all, step) in [(false, 0.6), (true, 1.0)] {
let mut g = guest();
g.motion = Some(Box::new(small_body(&resources)));
g.new_body(restore_all);
assert!(g.motion.is_none());
let mut m = next(&g);
assert_eq!(m.attribute("minecraft:scale").unwrap(), 0.1);
assert!((m.attribute("minecraft:step_height").unwrap() - step).abs() < 1e-9);
// The tick that follows rebuilds the box at that scale.
m.refresh_attributes().unwrap();
assert_eq!(m.dimensions.width, 0.6 * 0.1);
assert_eq!(m.dimensions.height, 1.8 * 0.1);
}
// No body before: nothing to hand over.
let mut g = guest();
g.new_body(true);
assert!(g.carried_attributes.is_none());
}
#[test]
fn a_player_between_two_bodies_saves_the_attributes_the_next_one_takes() {
// #3684: logging out after a respawn, before the new body has ticked,
// must not write the file's older list over the scale.
let Some((_assets, resources)) = resources() else {
eprintln!("skipped: prepared resources unavailable");
return;
};
let mut g = guest();
g.motion = Some(Box::new(small_body(&resources)));
g.new_body(false);
let mut level = Level::default();
store(&g, &mut level, None);
let tag = level.player_tag();
let mut read = Level::default();
read.read_player(&tag);
read.player_extra = Some(tag);
let mut back =
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
.expect("a body");
back.apply_saved_attributes(read.player_extra.as_ref().and_then(|t| t.get("attributes")))
.unwrap();
assert_eq!(back.attribute("minecraft:scale").unwrap(), 0.1);
}
#[test]
fn a_saved_air_comes_back() {
// A player saved underwater (#1321): the file keeps the remaining air
+14 -20
View File
@@ -96,6 +96,12 @@ pub(super) fn network_recipes(
let mut all = BTreeMap::new();
recipes(&resources.join("data/minecraft/recipe"), "", &mut all);
overlay_recipes(overlay, &mut all);
// The data's recipe filters, as the server's own tables apply them
// (#5910), once, before the ingredient normalization, so a swapped
// name resolves and every copy — the book's displays, the property
// sets — agrees on the swapped recipe.
let filters = horizon_items::recipe_filters::RecipeFilters::load(resources, overlay);
all.retain(|id, r| filters.apply(id, r));
match horizon_client::items::Items::catalog(resources, overlay) {
Ok(items) => all.retain(|_, r| normalize_ingredients(r, &items)),
Err(e) => log::warn!("recipes: NeoForge's ingredient shapes stay unresolved: {e}"),
@@ -190,26 +196,14 @@ fn normalize_ingredients(r: &mut Value, items: &horizon_client::items::Items) ->
}
/// The packet's payload for the network copy's recipes (`network_recipes`).
pub fn update_recipes(
all: &BTreeMap<String, Box<str>>,
resources: &Path,
sync: &SyncData,
overlay: &horizon_world::loot::PackOverlay,
) -> Vec<u8> {
// The data's recipe filters, as the server's own tables apply them. A
// filter can also swap a recipe's inputs and outputs, so it runs on
// this packet's own parsed values, the book's copy keeping its files
// as loaded. Parsing every recipe takes about a millisecond.
let filters = horizon_items::recipe_filters::RecipeFilters::load(resources, overlay);
let mut chosen: Vec<Value> = Vec::new();
for (id, text) in all {
let Ok(mut own) = serde_json::from_str::<Value>(text) else {
continue;
};
if filters.apply(id, &mut own) {
chosen.push(own);
}
}
pub fn update_recipes(all: &BTreeMap<String, Box<str>>, sync: &SyncData) -> Vec<u8> {
// The copy is already filtered and swapped (`network_recipes`); the
// filters never run twice, so one swap cannot feed a later one again.
// Parsing every recipe takes about a millisecond.
let chosen: Vec<Value> = all
.values()
.filter_map(|text| serde_json::from_str::<Value>(text).ok())
.collect();
let tags: BTreeMap<&str, &[i32]> = sync
.tags
.registries
+87 -9
View File
@@ -73,6 +73,7 @@ pub mod render;
pub mod riders;
pub mod seen_grid;
pub mod shulker;
pub mod skin;
pub mod sniffer;
pub mod spawn_rules;
pub mod spawn_selection;
@@ -2799,6 +2800,11 @@ impl Mob {
} else {
0.0
};
// LivingEntity.getDamageAfterArmorAbsorb asks `hurtArmor` *before*
// `CombatRules.getDamageAfterAbsorb`, so a worn piece wears by the
// damage the armour, toughness and enchantment reductions have not
// touched yet, and only a hit outside `#bypasses_armor` asks at all.
let armor_hit = (!source.bypasses_armor() && amount > 0.0).then_some(amount);
if !source.bypasses_armor() && self.kind.armor + worn + covered > 0.0 {
// CombatRules.getDamageAfterAbsorb
let armor = self.kind.armor + worn + covered;
@@ -2881,17 +2887,19 @@ impl Mob {
if !absorbed && dealt != 0.0 {
self.record_damage(source, dealt);
}
// LivingEntity.hurtArmor: the body armour a mob wears (a horse's) wears
// on a hit that reached it. A wolf's armour has its own rule
// (`Wolf.actuallyHurt`, `hurt_wolf_armor`).
if dealt > 0.0
// LivingEntity.hurtArmor: `Horse` is the only mob that overrides it, and
// it hands `doHurtEquipment` the body slot alone. A wolf's armour has
// its own rule (`Wolf.actuallyHurt`, `hurt_wolf_armor`) and a player's
// four pieces theirs, so the other "body slot" species — donkey, mule,
// llama, camel, the skeleton and zombie horses — wear nothing, whatever
// `drops::head_is_body` says about where the item lives.
if let Some(damage) = armor_hit
&& self.species() == Species::Horse
&& self.extra.head.is_some()
&& self.species() != Species::Wolf
&& drops::head_is_body(self.species())
{
self.outbox.push(MobEvent::ArmorHurt {
mob: self.id,
amount: dealt,
amount: damage,
kind: source.kind,
});
}
@@ -4325,10 +4333,14 @@ mod armor_hurt_tests {
use horizon_core::geometry::Vec3;
fn horse() -> Mob {
mob("minecraft:horse", Species::Horse)
}
fn mob(id: &str, species: Species) -> Mob {
let mut kind = kind();
let k = Arc::make_mut(&mut kind);
k.id = "minecraft:horse".into();
k.species = Species::Horse;
k.id = id.into();
k.species = species;
Mob::new(1, kind, Vec3::ZERO, 0.0)
}
@@ -4363,6 +4375,72 @@ mod armor_hurt_tests {
.any(|e| matches!(e, MobEvent::ArmorHurt { .. }))
);
}
/// `Horse` is the only mob that overrides `LivingEntity.hurtArmor` (the
/// base one is empty, `LivingEntity.java:1887`), so a donkey wearing a body
/// item — `/item replace entity @e[type=minecraft:donkey,limit=1]
/// armor.body with minecraft:wolf_armor` — wears nothing, even though
/// `drops::head_is_body` stores a donkey's body item in the slot a horse's
/// goes in.
#[test]
fn a_donkey_wearing_a_body_item_wears_nothing() {
let mut donkey = mob("minecraft:donkey", Species::Donkey);
donkey.extra.head = Some("minecraft:wolf_armor".into());
assert!(
donkey.damage(6.0, Damage::of(DamageKind::MobAttack)),
"the hit landed"
);
assert!(
!donkey
.outbox
.iter()
.any(|e| matches!(e, MobEvent::ArmorHurt { .. })),
"only a horse's own override wears its body piece"
);
}
/// `getDamageAfterArmorAbsorb` asks `hurtArmor` only when the damage type
/// is not in `#bypasses_armor` (`LivingEntity.java:1907-1909`), so a horse
/// that falls keeps its body piece whole.
#[test]
fn a_fall_does_not_wear_the_body_piece() {
let mut m = horse();
m.extra.head = Some("minecraft:diamond_horse_armor".into());
assert!(
m.damage(6.0, Damage::of(DamageKind::Fall)),
"the fall landed"
);
assert!(
!m.outbox
.iter()
.any(|e| matches!(e, MobEvent::ArmorHurt { .. })),
"a #bypasses_armor hit asks for no wear"
);
}
/// The wear is measured by the damage `hurtArmor` is handed, which is the
/// one the armour, toughness and enchantment reductions have not touched
/// yet: 8 raw damage is `8 / 4 = 2` durability even though plate leaves far
/// less than 8 through to the health.
#[test]
fn the_wear_is_the_damage_before_the_armour_reduction() {
let mut m = horse();
let k = Arc::make_mut(&mut m.kind);
k.armor = 20.0;
m.extra.head = Some("minecraft:diamond_horse_armor".into());
let before = m.health;
assert!(m.damage(8.0, Damage::of(DamageKind::MobAttack)));
assert!(m.health < before, "the hit reached the health");
let owed = m.outbox.iter().find_map(|e| match e {
MobEvent::ArmorHurt { amount, .. } => Some(*amount),
_ => None,
});
assert_eq!(
owed,
Some(8.0),
"the pre-reduction damage, not the damage the health took"
);
}
}
#[cfg(test)]
+39 -26
View File
@@ -2,6 +2,7 @@
//! (quadrupeds, humanoids, creepers, spiders, chickens...) applied to a map of
//! part poses before the model is drawn.
use super::{Mob, Part, keyframes::AnimationState, living::wrap};
use crate::fast_hash::{FxHashMap, FxHashSet};
use horizon_core::geometry::trig::Mth;
use std::collections::HashMap;
use std::f32::consts::{FRAC_PI_6, PI};
@@ -101,15 +102,27 @@ impl AnimState {
}
pub fn end_tick(&mut self, _swinging: bool) {}
}
/// A part name as `Poses` keys it: its Fx hash, so filling a pose map allocates
/// nothing (a mob farm resets one per layer per mob each frame). Names are short
/// and few per model; `no_two_part_names_share_a_key` checks every vanilla model.
#[inline]
pub(crate) fn key(name: &str) -> u64 {
use std::hash::Hasher;
let mut h = crate::fast_hash::FxHasher::default();
h.write(name.as_bytes());
// The length too, so "a" and "a\0" differ (str's own Hash adds a terminator).
h.write_usize(name.len());
h.finish()
}
/// Part poses for one draw, keyed by part name.
#[derive(Clone, Default)]
pub struct Poses {
map: HashMap<String, [f32; 9]>,
map: FxHashMap<u64, [f32; 9]>,
dummy: [f32; 9],
/// Parts with `skipDraw` set: their own cubes are not drawn, children are.
skipped: std::collections::HashSet<String>,
skipped: FxHashSet<u64>,
/// Parts with `visible` cleared: neither they nor their children draw.
hidden: std::collections::HashSet<String>,
hidden: FxHashSet<u64>,
}
impl Poses {
pub fn new(model: &Part) -> Self {
@@ -120,9 +133,9 @@ impl Poses {
/// Refills this for another model, keeping the map and the sets' capacity:
/// a mob draws its body and every equipment layer each frame.
pub fn reset(&mut self, model: &Part) {
fn walk(p: &Part, map: &mut HashMap<String, [f32; 9]>) {
fn walk(p: &Part, map: &mut FxHashMap<u64, [f32; 9]>) {
// ModelPart.createPartLookup fills its map with putIfAbsent: the first part keeps a name.
map.entry(p.name.clone()).or_insert(p.pose);
map.entry(key(&p.name)).or_insert(p.pose);
for c in &p.children {
walk(c, map);
}
@@ -134,26 +147,26 @@ impl Poses {
walk(model, &mut self.map);
}
pub fn get(&mut self, name: &str) -> &mut [f32; 9] {
match self.map.get_mut(name) {
match self.map.get_mut(&key(name)) {
Some(p) => p,
None => &mut self.dummy,
}
}
pub fn pose(&self, part: &Part) -> [f32; 9] {
self.map.get(&part.name).copied().unwrap_or(part.pose)
self.map.get(&key(&part.name)).copied().unwrap_or(part.pose)
}
pub fn skip_draw(&mut self, name: &str) {
self.skipped.insert(name.to_owned());
self.skipped.insert(key(name));
}
pub fn skips(&self, part: &Part) -> bool {
self.skips_named(&part.name)
}
/// `skips` by the part's name.
pub fn skips_named(&self, name: &str) -> bool {
self.skipped.contains(name)
!self.skipped.is_empty() && self.skipped.contains(&key(name))
}
pub fn has(&self, name: &str) -> bool {
self.map.contains_key(name)
self.map.contains_key(&key(name))
}
pub fn xrot(&mut self, n: &str, v: f32) {
self.get(n)[3] = v;
@@ -167,9 +180,9 @@ impl Poses {
/// `ModelPart.visible = v`; a hidden part and its children do not draw.
pub fn visible(&mut self, n: &str, v: bool) {
if v {
self.hidden.remove(n);
} else if self.map.contains_key(n) {
self.hidden.insert(n.to_owned());
self.hidden.remove(&key(n));
} else if self.map.contains_key(&key(n)) {
self.hidden.insert(key(n));
}
}
pub fn is_hidden(&self, part: &Part) -> bool {
@@ -177,7 +190,7 @@ impl Poses {
}
/// `is_hidden` by the part's name.
pub fn is_hidden_named(&self, name: &str) -> bool {
self.hidden.contains(name)
!self.hidden.is_empty() && self.hidden.contains(&key(name))
}
}
/// The LivingEntityRenderState values setupAnim reads.
@@ -1003,7 +1016,7 @@ mod flight_pose_tests {
"left_leg",
]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1011,13 +1024,13 @@ mod flight_pose_tests {
};
player_model_visibility(&mut p, 0, true);
for part in ["body", "right_arm", "left_arm", "right_leg", "left_leg"] {
assert!(p.hidden.contains(part), "{part}");
assert!(p.hidden.contains(&key(part)), "{part}");
}
assert!(!p.hidden.contains("head") && !p.hidden.contains("hat"));
assert!(!p.hidden.contains(&key("head")) && !p.hidden.contains(&key("hat")));
// Back in another mode they all show, but for the layers the player hid.
player_model_visibility(&mut p, 1 << 6, false);
assert!(!p.hidden.contains("body") && !p.hidden.contains("left_leg"));
assert!(p.hidden.contains("hat"));
assert!(!p.hidden.contains(&key("body")) && !p.hidden.contains(&key("left_leg")));
assert!(p.hidden.contains(&key("hat")));
}
#[test]
fn blocking_arm_follows_the_head_with_limits_in_both_hands() {
@@ -1030,7 +1043,7 @@ mod flight_pose_tests {
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1087,7 +1100,7 @@ mod flight_pose_tests {
"left_leg",
]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1142,7 +1155,7 @@ mod flight_pose_tests {
"body",
]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1183,7 +1196,7 @@ mod flight_pose_tests {
let make = || Poses {
map: ["head", "right_arm", "left_arm", "right_leg", "left_leg"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1289,7 +1302,7 @@ mod flight_pose_tests {
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1310,7 +1323,7 @@ mod flight_pose_tests {
let mut p = Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
@@ -1338,7 +1351,7 @@ mod flight_pose_tests {
let make = || Poses {
map: ["head", "right_arm", "left_arm"]
.into_iter()
.map(|n| (n.to_string(), [0.0; 9]))
.map(|n| (key(n), [0.0; 9]))
.collect(),
dummy: [0.0; 9],
skipped: Default::default(),
+5 -112
View File
@@ -18,10 +18,12 @@
//! new geometry.
use super::{MobType, Part, Polygon};
use horizon_optifine::cem::model::{Bone, EntityModel, MAX_BYTES, parse_jem};
use horizon_optifine::cem::{
files::{atlas_name, jem_files, load_jem},
model::{Bone, EntityModel},
};
use std::{
collections::{BTreeMap, BTreeSet, HashMap},
fs,
path::Path,
sync::Arc,
};
@@ -130,39 +132,6 @@ impl CemModels {
}
}
/// The `.jem` at `path`, its warnings logged; None, with a warning, when it can't be read or
/// parsed.
fn load_jem(root: &Path, path: &str) -> Option<EntityModel> {
let Some(text) = read_small(&root.join(path)) else {
log::warn!("Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes");
return None;
};
let jem = match parse_jem(path, &text, &mut |p: &str| read_small(&root.join(p))) {
Ok(jem) => jem,
Err(e) => {
log::warn!("Horizon: custom entity model {e}");
return None;
}
};
for warning in &jem.warnings {
log::warn!("Horizon: custom entity model {warning}");
}
if let Some(texture) = &jem.texture {
if atlas_name(texture).is_none() {
log::warn!(
"Horizon: custom entity model {path}: its texture {texture} is outside \
assets/minecraft/textures/, so not in the atlas: the vanilla one draws"
);
} else if !root.join(texture).is_file() {
log::warn!(
"Horizon: custom entity model {path}: its texture {texture} doesn't exist, \
so not in the atlas: the vanilla one draws"
);
}
}
Some(jem)
}
/// Every model layer of `types` by its key.
fn vanilla_layers(types: &HashMap<String, Arc<MobType>>) -> HashMap<&str, &Arc<Part>> {
types
@@ -171,47 +140,6 @@ fn vanilla_layers(types: &HashMap<String, Arc<MobType>>) -> HashMap<&str, &Arc<P
.collect()
}
/// A pack file's text, None when it's missing or larger than a model may read: its size is
/// checked before anything is read.
fn read_small(path: &Path) -> Option<String> {
let len = fs::metadata(path).ok()?.len();
if len > MAX_BYTES as u64 {
return None;
}
fs::read_to_string(path).ok()
}
/// Every `.jem` of the CEM folders by model name, sorted, each name's files best first:
/// `emf/cem` before `optifine/cem` whichever form either uses, and within one folder the flat
/// `<name>.jem` before `<name>/<name>.jem`.
fn jem_files(root: &Path) -> BTreeMap<String, Vec<String>> {
let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
for base in ["assets/minecraft/emf/cem", "assets/minecraft/optifine/cem"] {
let Ok(entries) = fs::read_dir(root.join(base)) else {
continue;
};
let (mut flat, mut nested) = (Vec::new(), Vec::new());
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if let Some(model) = name.strip_suffix(".jem") {
// A directory named `x.jem` isn't a model.
if e.path().is_file() {
flat.push((model.to_owned(), format!("{base}/{name}")));
}
} else {
let path = format!("{base}/{name}/{name}.jem");
if root.join(&path).is_file() {
nested.push((name, path));
}
}
}
for (model, path) in flat.into_iter().chain(nested) {
out.entry(model).or_default().push(path);
}
}
out
}
/// The CEM model names with a layer key of their own; any other name `n` is `n#main`.
const LAYERS: &[(&str, &str)] = &[
("sheep_wool", "sheep#wool"),
@@ -500,13 +428,6 @@ impl CemLayer {
}
}
/// The atlas name of a texture resource path: `assets/minecraft/textures/entity/x.png` is
/// `entity/x`. Textures outside a `textures/` folder are not in the atlas.
fn atlas_name(path: &str) -> Option<String> {
let rest = path.strip_prefix("assets/minecraft/textures/")?;
Some(rest.strip_suffix(".png").unwrap_or(rest).to_owned())
}
/// The first part named `name`, depth first (`ModelPart.getChild` through the tree).
fn find_mut<'a>(part: &'a mut Part, name: &str) -> Option<&'a mut Part> {
if part.name == name {
@@ -544,6 +465,7 @@ fn part(bone: &Bone, count: &mut usize, ids: &mut HashMap<String, String>) -> Pa
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn plane(name: &str, children: Vec<Part>) -> Part {
Part {
@@ -705,35 +627,6 @@ mod tests {
Some(part.polygons.len() == 1)
}
#[test]
fn emf_files_win_and_a_flat_file_beats_its_folder() {
let (replace, attach) = (jem("leg1", false), jem("leg1", true));
let files = [
// EMF's folder form over OptiFine's flat file.
("optifine/cem/cow.jem", attach.as_str()),
("emf/cem/cow/cow.jem", replace.as_str()),
// One folder: the flat file over the folder form.
("optifine/cem/pig.jem", replace.as_str()),
("optifine/cem/pig/pig.jem", attach.as_str()),
// A directory named `sheep.jem` is no file: the folder form stands.
("optifine/cem/sheep.jem/readme.txt", ""),
("optifine/cem/sheep/sheep.jem", attach.as_str()),
];
let tree = plane("root", vec![plane("right_hind_leg", vec![])]);
let models = models(
"precedence",
&files,
&["cow#main", "pig#main", "sheep#main"],
&tree,
);
assert_eq!(attached(&models, "cow#main", "right_hind_leg"), Some(false));
assert_eq!(attached(&models, "pig#main", "right_hind_leg"), Some(false));
assert_eq!(
attached(&models, "sheep#main", "right_hind_leg"),
Some(true)
);
}
#[test]
fn a_baby_with_its_own_file_takes_it_not_the_adults() {
let (adult, baby) = (jem("head", false), jem("head", true));
+101 -7
View File
@@ -549,6 +549,24 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) {
((vx * fx + vz * fz) / len, (vx * -fz + vz * fx) / len)
}
};
// `Entity.isVisuallySwimming` (`Pose.SWIMMING`, id 3): a player's or mannequin's pose comes
// in `avatar_pose`, another mob's in `pose`. A drowned has no such pose, only the shared
// swimming flag (`extra.flag`).
let swim_pose = match &*m.kind.id {
"minecraft:player" | "minecraft:mannequin" => {
m.extra.avatar_pose == crate::pose::Pose::Swimming
}
_ => m.extra.pose == 3 || (m.species() == super::Species::Drowned && m.extra.flag),
};
// The arm the current swing moves: the main arm unless the swing named its hand.
let swinging = m.ai.swinging || m.extra.swing_right_arm.is_some();
let swing_right = m.extra.swing_right_arm.unwrap_or(!m.main_arm_left());
// `HumanoidArm`'s items: the main hand is on the main arm.
let (right_hand, left_hand) = if m.main_arm_left() {
(&m.extra.off_hand, &m.extra.main_hand)
} else {
(&m.extra.main_hand, &m.extra.off_hand)
};
for (i, (_, p)) in PARAMS.iter().enumerate() {
use Param::*;
slots[i] = match p {
@@ -582,6 +600,8 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) {
PlayerRotY => frame.player_rot[1],
// Random models' rule (a later change); the model's own rule is 0 until then.
RuleIndex => 0.0,
// A client knows only a wolf's anger end time (as a remaining time taken when it
// was synced, which doesn't count down) and never the start: 0 for now.
AngerTime | AngerTimeStart => 0.0,
Distance => (dx * dx + dy * dy + dz * dz).sqrt() as f32,
MoveForward => forward,
@@ -589,6 +609,8 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) {
// How deep the entity is in its fluid, which is what `fluid_depth_down` measures
// once it touches the bottom.
FluidDepth | FluidDepthDown => m.fluids.water.max(m.fluids.lava) as f32,
// Both need the blocks around the mob (the ground below, the fluid's surface
// above), which the draw doesn't have: 0 for now.
HeightAboveGround | FluidDepthUp => 0.0,
// The synced `Mob.isAggressive`, which a client's mobs have too.
Aggressive => flag(m.ai.aggressive),
@@ -599,10 +621,14 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) {
Hurt => flag(m.hurt_time > 0),
InWater => flag(m.fluids.in_water),
InLava => flag(m.fluids.in_lava),
// `isInWaterOrRain` without the rain: whether rain reaches the mob needs the
// weather and the sky above it, which the draw doesn't have.
Wet => flag(m.fluids.in_water),
Invisible => flag(m.invisible),
OnGround => flag(m.on_ground),
Riding => flag(m.extra.passenger),
// `Entity.isVehicle`: SetPassengers names its first rider.
Ridden => flag(m.extra.rider.is_some() || m.rider.is_some()),
Climbing => flag(m.climbing),
Jumping => flag(!m.on_ground && m.velocity.y > 0.0),
Paused => flag(frame.paused),
@@ -611,14 +637,32 @@ fn params(m: &Mob, s: &State, frame: &Frame, slots: &mut [f32]) {
ThirdPerson => flag(!frame.first_person && !frame.third_person_reversed),
ThirdPersonReversed => flag(frame.third_person_reversed),
RightHanded => flag(!m.main_arm_left()),
Sitting => flag(m.extra.sitting),
// The tamed animals' DATA_FLAGS_ID, and the species that keep their own.
Sitting => flag(match m.species() {
super::Species::Fox => super::fox::flag(m, super::fox::SITTING),
super::Species::Panda => super::panda::is_sitting(m),
super::Species::Camel | super::Species::CamelHusk => super::camel::sitting(m),
_ => m.extra.sitting,
}),
Tamed => flag(m.extra.tame),
Sprinting => flag(m.extra.sprinting),
Sneaking => flag(m.extra.crouching),
// Not known to the draw yet: 0 until something reads them.
InHand | InItemFrame | InGround | OnHead | OnShoulder | Ridden | Blocking
| Crawling | Swimming | SwingingRight | SwingingLeft | FirstPersonHand | UsingItem
| HoldingRight | HoldingLeft | Hovered => 0.0,
// A player's `isBlocking`: the item in use blocks attacks.
Blocking => flag(m.extra.blocking),
// `isVisuallyCrawling`: the swimming pose out of water.
Crawling => flag(swim_pose && !m.fluids.in_water),
Swimming => flag(swim_pose),
// DATA_LIVING_ENTITY_FLAGS' using bit (the client counts the ticks).
UsingItem => flag(m.extra.using_item > 0),
SwingingRight => flag(swinging && swing_right),
SwingingLeft => flag(swinging && !swing_right),
HoldingRight => flag(right_hand.is_some()),
HoldingLeft => flag(left_hand.is_some()),
// Where the model is drawn (a held, framed or worn item, a shoulder): the draw
// doesn't pass it yet, so 0 until something reads them.
InHand | InItemFrame | InGround | OnHead | OnShoulder | FirstPersonHand | Hovered => {
0.0
}
};
}
}
@@ -882,18 +926,68 @@ mod tests {
m.extra.crouching = true;
// The player's main hand option set to left.
m.extra.main_hand_left = true;
m.extra.blocking = true;
// The swimming pose out of water: crawling.
m.extra.pose = 3;
m.extra.using_item = 3;
// Only the main hand holds something, and it is the left one.
m.extra.set_main_hand(Some("minecraft:stick".into()));
// A swing that names no hand moves the main arm: the left.
m.ai.swinging = true;
m.extra.rider = Some(7);
let model = bone("root", vec![]);
let animation = animate(
&model,
&[(
"root.tx",
"is_aggressive + is_sitting * 2 + is_tamed * 4 + is_sprinting * 8 \
+ is_sneaking * 16 + is_right_handed * 32",
+ is_sneaking * 16 + is_right_handed * 32 + is_blocking * 64 \
+ is_crawling * 128 + is_swimming * 256 + is_using_item * 512 \
+ is_holding_item_right * 1024 + is_holding_item_left * 2048 \
+ is_swinging_right_arm * 4096 + is_swinging_left_arm * 8192 \
+ is_ridden * 16384",
)],
);
let mut poses = Poses::new(&model);
animation.run_in(&Frame::default(), &m, &still(), &mut poses);
assert_eq!(poses.get("root")[0], 31.0);
assert_eq!(
poses.get("root")[0],
(31 + 64 + 128 + 256 + 512 + 2048 + 8192 + 16384) as f32
);
}
#[test]
fn the_swimming_pose_in_water_swims_without_crawling() {
let mut m = mob();
m.extra.pose = 3;
let model = bone("root", vec![]);
let animation = animate(&model, &[("root.tx", "is_swimming + is_crawling * 2")]);
let mut read = |in_water: bool| {
m.fluids.in_water = in_water;
let mut poses = Poses::new(&model);
animation.run_in(&Frame::default(), &m, &still(), &mut poses);
poses.get("root")[0]
};
assert_eq!([read(true), read(false)], [1.0, 3.0]);
}
#[test]
fn a_fox_sits_by_its_own_flags() {
let mut m = mob();
m.kind = std::sync::Arc::new(crate::mobs::MobType {
species: crate::mobs::Species::Fox,
..(*m.kind).clone()
});
let model = bone("root", vec![]);
let animation = animate(&model, &[("root.tx", "is_sitting")]);
let mut sitting = |flags: i32| {
m.extra.ints[0] = flags;
let mut poses = Poses::new(&model);
animation.run_in(&Frame::default(), &m, &still(), &mut poses);
poses.get("root")[0]
};
assert_eq!(sitting(crate::mobs::fox::SITTING), 1.0);
assert_eq!(sitting(crate::mobs::fox::CROUCHING), 0.0);
}
#[test]
+326 -65
View File
@@ -1278,15 +1278,7 @@ fn draw_normals(
out: &mut Vec<Vertex>,
) {
let p = poses.pose(part);
let m = base
* glam::Mat4::from_translation(glam::Vec3::new(p[0], p[1], p[2]) / 16.0)
* glam::Mat4::from_quat(glam::Quat::from_euler(
glam::EulerRot::ZYX,
p[5],
p[4],
p[3],
))
* glam::Mat4::from_scale(glam::Vec3::new(p[6], p[7], p[8]));
let m = posed(base, p);
if poses.is_hidden(part) || p[6] == 0.0 && p[7] == 0.0 && p[8] == 0.0 {
return;
}
@@ -1324,6 +1316,46 @@ fn draw_normals(
draw_normals(c, m, normals, poses, uv, scroll, color, light, overlay, out);
}
}
/// A part's matrix under `base` for its pose (`ModelPart.translateAndRotate`
/// and its scale), as `draw` and the GPU path (`super::skin`) both use it.
pub(super) fn posed(base: glam::Mat4, p: [f32; 9]) -> glam::Mat4 {
base * glam::Mat4::from_translation(glam::Vec3::new(p[0], p[1], p[2]) / 16.0)
* glam::Mat4::from_quat(glam::Quat::from_euler(
glam::EulerRot::ZYX,
p[5],
p[4],
p[3],
))
* glam::Mat4::from_scale(glam::Vec3::new(p[6], p[7], p[8]))
}
/// `draw_scrolled` through the GPU path when the mob takes it (`skin`).
#[allow(clippy::too_many_arguments)]
fn put(
skin: &mut Option<&mut super::skin::Skin>,
part: &std::sync::Arc<Part>,
base: glam::Mat4,
poses: &Poses,
uv: [f32; 4],
scroll: f32,
color: u32,
light: u32,
overlay: u32,
out: &mut Vec<Vertex>,
) {
let paint = super::skin::Paint {
uv,
scroll,
color,
light,
overlay,
};
if let Some(skin) = skin
&& skin.draw(part, base, poses, paint)
{
return;
}
draw_scrolled(part, base, poses, uv, scroll, color, light, overlay, out);
}
/// Render a mob with its variant model, texture and layers.
pub fn render(
m: &Mob,
@@ -1360,7 +1392,7 @@ pub fn render_with(
let shifted =
camera - glam::DVec3::new(off[0] + w[0], off[1] + w[1], off[2] + w[2]);
let base = base_transform(m, alpha, shifted);
render_visible(m, alpha, base, atlas, light, data, extra, true, out);
render_visible(m, alpha, base, atlas, light, data, extra, true, out, None);
}
}
return;
@@ -1375,6 +1407,37 @@ pub fn render_with(
extra,
true,
out,
None,
);
}
/// `render` with the model's ordinary draws posed on the GPU when the mob
/// allows it (`super::skin`): the same picture, no vertices on the CPU.
#[allow(clippy::too_many_arguments)]
pub fn render_skinned(
m: &Mob,
alpha: f32,
camera: glam::DVec3,
atlas: Atlas,
light: u32,
data: &MobData,
out: &mut Vec<Vertex>,
skin: &mut super::skin::Skin,
) {
if super::trader::is_invisible(m) {
render_with(m, alpha, camera, atlas, light, data, &[], out);
return;
}
render_visible(
m,
alpha,
base_transform(m, alpha, camera),
atlas,
light,
data,
&[],
true,
out,
Some(skin),
);
}
/// `CapeLayer.submit` with `PlayerCapeModel`: the player model's body posed
@@ -1632,7 +1695,7 @@ pub fn render_layers(
out: &mut Vec<Vertex>,
) {
let base = base_transform(m, alpha, camera);
render_visible(m, alpha, base, atlas, light, data, extra, false, out);
render_visible(m, alpha, base, atlas, light, data, extra, false, out, None);
}
/// A mob drawn under `origin` instead of at its position (the spinning mob
/// of `SpawnerRenderer.submitEntityInSpawner`).
@@ -1646,7 +1709,7 @@ pub fn render_posed(
out: &mut Vec<Vertex>,
) {
let base = base_transform_at(m, alpha, origin);
render_visible(m, alpha, base, atlas, light, data, &[], true, out);
render_visible(m, alpha, base, atlas, light, data, &[], true, out, None);
}
/// The custom model's key of a body model: `main` is the wide player model, `kind.model`.
fn cem_key(model_key: &str) -> &str {
@@ -1730,6 +1793,7 @@ fn render_visible(
extra: &[(&Part, [f32; 4], u32, u32, bool)],
body_visible: bool,
out: &mut Vec<Vertex>,
skin: Option<&mut super::skin::Skin>,
) {
let overlay = u32::from(red_overlay(m));
let s = anim::state(m, alpha);
@@ -1799,56 +1863,6 @@ fn render_visible(
if let Some(animation) = body_animation {
animation.run(m, &s, &mut poses);
}
let draw_body = |out: &mut Vec<Vertex>| {
// A player or mannequin whose skin has no atlas sprite (an evicted or
// never stored download) draws with its default skin, not invisibly.
let drawn = sprite(texture).or_else(|| Some((texture, atlas(texture)?)));
let uv = drawn.map(|(_, uv)| uv).or_else(|| {
if avatar(m) {
atlas(&m.kind.texture)
} else {
None
}
});
if body_visible && let Some(uv) = uv {
let start = out.len();
draw(
&model,
base,
&poses,
uv,
body_color(m, alpha),
light,
body_overlay(m, alpha),
out,
);
if let Some(glow) = drawn.and_then(|(name, _)| emissive_uv(name)) {
draw(
&model,
base,
&poses,
glow,
body_color(m, alpha),
FULL_BRIGHT,
0,
out,
);
}
if translucent_body(m) {
mark_oit(&mut out[start..]);
}
}
};
// The translucent shell writes depth: draw the opaque core before it.
let shell_last = m.species() == Species::SulfurCube;
if !shell_last {
draw_body(out);
}
// The worn armour's layers (and their emissive overlays, full bright).
for (part, uv, color, layer_overlay, glows) in extra {
let light = if *glows { FULL_BRIGHT } else { light };
draw(part, base, &poses, *uv, *color, light, *layer_overlay, out);
}
let mut all_layers = layers(m, s.age);
// WardenRenderer: the translucent emissive layers fade with the animations.
if m.species() == Species::Warden {
@@ -1864,6 +1878,87 @@ fn render_visible(
}
}
}
// The GPU path takes the whole mob or none of it, so its draws keep their
// order: only plain opaque and cutout draws of models it can pose (no
// custom models, translucency, eyes or energy swirl, which the CPU path
// changes after drawing).
let mut skin = skin;
let posable = match skin.as_deref_mut() {
Some(skin) => {
custom_body.is_none()
&& extra.is_empty()
&& m.species() != Species::SulfurCube
&& !translucent_body(m)
&& energy_swirl(m, s.age).is_none()
&& skin.supports(&model)
&& all_layers.iter().all(|layer| {
!layer.full_bright
&& !translucent_layer(m, layer)
&& cem.layer(&layer.model).is_none()
&& m.kind
.layer(&layer.model)
.is_none_or(|part| skin.supports(part))
})
}
None => false,
};
if !posable {
skin = None;
}
let draw_body = |out: &mut Vec<Vertex>, skin: &mut Option<&mut super::skin::Skin>| {
// A player or mannequin whose skin has no atlas sprite (an evicted or
// never stored download) draws with its default skin, not invisibly.
let drawn = sprite(texture).or_else(|| Some((texture, atlas(texture)?)));
let uv = drawn.map(|(_, uv)| uv).or_else(|| {
if avatar(m) {
atlas(&m.kind.texture)
} else {
None
}
});
if body_visible && let Some(uv) = uv {
let start = out.len();
put(
skin,
&model,
base,
&poses,
uv,
0.0,
body_color(m, alpha),
light,
body_overlay(m, alpha),
out,
);
if let Some(glow) = drawn.and_then(|(name, _)| emissive_uv(name)) {
put(
skin,
&model,
base,
&poses,
glow,
0.0,
body_color(m, alpha),
FULL_BRIGHT,
0,
out,
);
}
if translucent_body(m) {
mark_oit(&mut out[start..]);
}
}
};
// The translucent shell writes depth: draw the opaque core before it.
let shell_last = m.species() == Species::SulfurCube;
if !shell_last {
draw_body(out, &mut skin);
}
// The worn armour's layers (and their emissive overlays, full bright).
for (part, uv, color, layer_overlay, glows) in extra {
let light = if *glows { FULL_BRIGHT } else { light };
draw(part, base, &poses, *uv, *color, light, *layer_overlay, out);
}
// One Poses refilled for every layer (armour draws up to seven): it keeps
// its maps' capacity.
let mut layer_poses = Poses::default();
@@ -1917,7 +2012,8 @@ fn render_visible(
None => overlay,
};
let start = out.len();
draw_scrolled(
put(
&mut skin,
&part,
base,
&layer_poses,
@@ -1929,7 +2025,8 @@ fn render_visible(
out,
);
if let Some(glow) = emissive_uv(drawn) {
draw_scrolled(
put(
&mut skin,
&part,
base,
&layer_poses,
@@ -1979,7 +2076,7 @@ fn render_visible(
}
}
if shell_last {
draw_body(out);
draw_body(out, &mut skin);
}
}
/// `EnergySwirlLayer`'s model, texture and `xOffset(t)` for a charged creeper
@@ -3084,6 +3181,170 @@ mod scroll_tests {
]
);
}
/// `anim::Poses` keys parts by a hash of their name: within any vanilla model, two
/// different names never share one (they would share a pose).
#[test]
fn no_two_part_names_share_a_key() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
return;
}
fn names<'a>(p: &'a Part, out: &mut Vec<&'a str>) {
out.push(&p.name);
for c in &p.children {
names(c, out);
}
}
let types = super::super::MobTypes::load(&root).unwrap();
let mut models = 0;
for kind in types.types.values() {
for model in std::iter::once(&kind.model).chain(kind.layers.values()) {
let mut all = Vec::new();
names(model, &mut all);
let mut keys = std::collections::HashMap::new();
for name in all {
let other = *keys.entry(super::super::anim::key(name)).or_insert(name);
assert_eq!(other, name, "{} shares a key", kind.id);
}
models += 1;
}
}
assert!(models > 100);
}
/// The CPU cost of posing a mob farm (tools/stress_scenes.py `stress_mobs`): 1000 mobs of
/// 8 kinds through `render_with`, as `mob_geometry` draws them each frame. Prints the median
/// thread CPU time per frame (busy machines stretch wall time), the vertices and a hash of them (the same hash before and after a speed-up
/// means the same picture). `cargo test --profile fast -p horizon-client --lib
/// benchmark_mob_farm_geometry -- --ignored --nocapture`.
#[test]
#[ignore = "manual benchmark; needs the prepared resources"]
fn benchmark_mob_farm_geometry() {
use std::{hash::Hasher, hint::black_box, time::Instant};
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
return;
}
let types = super::super::MobTypes::load(&root).unwrap();
let kinds = [
"husk", "creeper", "cow", "pig", "sheep", "chicken", "villager", "spider",
]
.map(|k| std::sync::Arc::new(types.types[&format!("minecraft:{k}")].clone()));
let mobs: Vec<Mob> = (0..1000u32)
.map(|i| {
let (layer, row, col) = (i / 200, i / 20 % 10, i % 20);
let at = horizon_core::geometry::Vec3::new(
-14.25 + 1.5 * f64::from(col),
1.0 + 2.5 * f64::from(layer),
8.0 + 2.0 * f64::from(row),
);
let mut m = Mob::new(i + 1, kinds[i as usize % 8].clone(), at, 180.0);
m.previous = at;
m.age = i;
m
})
.collect();
let data = MobData {
states: Vec::new(),
placements: Default::default(),
heightmaps: Default::default(),
animations: Default::default(),
biome_tags: Default::default(),
entity_tags: Default::default(),
villager_hats: Default::default(),
cem: Default::default(),
random_textures: Default::default(),
emissive: Default::default(),
blinks: Default::default(),
pois: Default::default(),
species_placements: Default::default(),
};
let atlas = |name: &str| {
let h = name.len() as f32 / 64.0;
Some([h, h, h + 0.25, h + 0.25])
};
let camera = glam::DVec3::new(0.5, 9.6, 0.5);
let mut out = Vec::new();
let frame = |out: &mut Vec<Vertex>| {
out.clear();
for m in &mobs {
render_with(m, 0.5, camera, &atlas, 0xf0f0, &data, &[], out);
}
};
frame(&mut out);
let mut hash = std::collections::hash_map::DefaultHasher::new();
hash.write(bytemuck::cast_slice(&out));
// The thread's own CPU time where the platform gives it, else wall time.
let timed = |f: &mut dyn FnMut()| {
let (wall, cpu) = (Instant::now(), horizon_client_render::spans::thread_cpu());
f();
match (horizon_client_render::spans::thread_cpu(), cpu) {
(Some(end), Some(start)) => (end - start).as_secs_f64() * 1000.0,
_ => wall.elapsed().as_secs_f64() * 1000.0,
}
};
let mut times = Vec::new();
for _ in 0..30 {
times.push(timed(&mut || frame(black_box(&mut out))));
}
times.sort_by(f64::total_cmp);
println!(
"mob farm: 1000 mobs, {} vertices, {:.3} ms/frame (median of 30, min {:.3}), hash {:016x}",
out.len(),
times[15],
times[0],
hash.finish()
);
// Where it goes: the body's poses alone (state, pose map, setupAnim), then the
// body's vertices alone from those poses.
let median = |mut f: Box<dyn FnMut() + '_>| {
let mut t: Vec<f64> = (0..30).map(|_| timed(&mut f)).collect();
t.sort_by(f64::total_cmp);
t[15]
};
let posed: Vec<(std::sync::Arc<Part>, Poses)> = mobs
.iter()
.map(|m| {
let model = m.kind.model.clone();
let mut poses = Poses::new(&model);
anim::setup(m, &anim::state(m, 0.5), &mut poses, &data.animations);
(model, poses)
})
.collect();
let poses_ms = median(Box::new(|| {
for m in &mobs {
let model = m.kind.model.clone();
let mut poses = Poses::new(&model);
anim::setup(m, &anim::state(m, 0.5), &mut poses, &data.animations);
black_box(&poses);
}
}));
let mut body = Vec::new();
let draw_ms = median(Box::new(|| {
body.clear();
for (m, (model, poses)) in mobs.iter().zip(&posed) {
let base = base_transform(m, 0.5, camera);
draw(
model,
base,
poses,
[0.0, 0.0, 0.25, 0.25],
u32::MAX,
0xf0f0,
0,
&mut body,
);
}
black_box(&body);
}));
println!(
" body poses only {poses_ms:.3} ms; body vertices only {draw_ms:.3} ms ({} vertices)",
body.len()
);
}
}
#[cfg(test)]
+422
View File
@@ -0,0 +1,422 @@
//! Mob models posed on the GPU (`gfx::entity_skin`): the store of models
//! uploaded once, and the walk that turns one `render::draw` into the parts'
//! matrices and jobs the shader expands into the very vertices `draw` would
//! push, in its order.
//!
//! A model takes this path only when every polygon faces an axis: the
//! normal matrix times an axis is one of its columns, exactly, so the shader's
//! packed normals are the CPU's to the bit. Positions and UVs are the same
//! sums in the same order (`Mat4::transform_point3`).
use super::{ModelNormals, Part, anim::Poses};
use crate::fast_hash::FxHashMap;
use crate::gfx::entity_skin::{SkinFrame, SkinJob, SkinPart, SkinVertex};
use std::sync::Arc;
/// A stored model: where its vertices start, and per part (pre-order) its
/// own vertices and the end of its subtree, for the walk.
struct Stored {
first: u32,
parts: Vec<StoredPart>,
/// The model's bounds in its parts' own space, per part: min and max of
/// its own vertices (none: no polygons).
bounds: Vec<Option<([f32; 3], [f32; 3])>>,
}
#[derive(Clone, Copy)]
struct StoredPart {
/// The part's first vertex, from the model's.
first: u32,
count: u32,
/// One past the last part of its subtree.
end: u32,
}
/// The models on the GPU, by the `Arc` they are drawn from. An entry holds
/// its `Arc`, so the address is never reused while stored; models no one
/// else holds any more are dropped by `begin`.
#[derive(Default)]
pub struct SkinModels {
stored: FxHashMap<usize, (Arc<Part>, Option<Stored>)>,
vertices: u32,
}
/// Models whose store would grow past this restart it (a pack reload's
/// models pile up otherwise).
const STORE_LIMIT: u32 = 4 << 20;
impl SkinModels {
/// Starts a frame: drops the models nothing else holds (their vertices
/// stay in the store until it restarts), restarts the store when it grew
/// too large or the renderer's (`stored`, its size) is out of step.
pub fn begin(&mut self, frame: &mut SkinFrame, stored: u32) {
if self.vertices > STORE_LIMIT || stored + frame.new_model.len() as u32 != self.vertices {
self.restart(frame);
}
self.stored
.retain(|_, (model, _)| Arc::strong_count(model) > 1);
frame.clear_draws();
}
fn restart(&mut self, frame: &mut SkinFrame) {
self.stored.clear();
self.vertices = 0;
frame.new_model.clear();
frame.base = 0;
frame.reset = true;
}
fn entry(&mut self, model: &Arc<Part>, frame: &mut SkinFrame) -> Option<&Stored> {
let key = Arc::as_ptr(model) as usize;
let vertices = &mut self.vertices;
let (_, stored) = self.stored.entry(key).or_insert_with(|| {
// The renderer appends `new_model` to its store in order, so a
// model's first vertex is the store's size when it is added.
let before = frame.new_model.len();
let stored = flatten(model, *vertices, &mut frame.new_model);
*vertices += (frame.new_model.len() - before) as u32;
(model.clone(), stored)
});
stored.as_ref()
}
}
/// The model's vertices in pre-order (a part's polygons, then its children),
/// appended to `out`; `None` when a polygon does not face an axis.
fn flatten(model: &Part, first: u32, out: &mut Vec<SkinVertex>) -> Option<Stored> {
let start = out.len();
let mut parts = Vec::new();
let mut bounds = Vec::new();
fn walk(
part: &Part,
start: usize,
out: &mut Vec<SkinVertex>,
parts: &mut Vec<StoredPart>,
bounds: &mut Vec<Option<([f32; 3], [f32; 3])>>,
) -> bool {
let index = parts.len();
let own = (out.len() - start) as u32;
parts.push(StoredPart {
first: own,
count: 0,
end: 0,
});
bounds.push(None);
let mut lo = [f32::INFINITY; 3];
let mut hi = [f32::NEG_INFINITY; 3];
for poly in &part.polygons {
let Some(axis) = axis(poly.normal) else {
return false;
};
for v in poly.vertices {
let pos = (glam::Vec3::new(v[0], v[1], v[2]) / 16.0).to_array();
for a in 0..3 {
lo[a] = lo[a].min(pos[a]);
hi[a] = hi[a].max(pos[a]);
}
out.push(SkinVertex {
pos,
uv: [v[3], v[4]],
axis,
part: index as u32,
});
}
}
parts[index].count = (out.len() - start) as u32 - own;
if !part.polygons.is_empty() {
bounds[index] = Some((lo, hi));
}
for child in &part.children {
if !walk(child, start, out, parts, bounds) {
return false;
}
}
parts[index].end = parts.len() as u32;
true
}
if !walk(model, start, out, &mut parts, &mut bounds) {
out.truncate(start);
return None;
}
Some(Stored {
first,
parts,
bounds,
})
}
/// 0..2 for +x, +y, +z, 3..5 for -x, -y, -z; `None` for any other normal.
fn axis(n: [f32; 3]) -> Option<u32> {
let mut found = None;
for (a, &c) in n.iter().enumerate() {
if c == 1.0 || c == -1.0 {
if found.is_some() {
return None;
}
found = Some(a as u32 + if c < 0.0 { 3 } else { 0 });
} else if c != 0.0 {
return None;
}
}
found
}
/// One draw's parameters (`render::draw_scrolled`'s).
#[derive(Clone, Copy)]
pub struct Paint {
pub uv: [f32; 4],
pub scroll: f32,
pub color: u32,
pub light: u32,
pub overlay: u32,
}
/// Where a frame's posed draws go, and the bounds of the current mob's.
pub struct Skin<'a> {
pub models: &'a mut SkinModels,
pub frame: &'a mut SkinFrame,
/// Camera-relative bounds of what this mob drew here (for culling).
pub bounds: Option<(glam::Vec3, glam::Vec3)>,
}
impl Skin<'_> {
/// Whether this model can be posed here (stored on first sight).
pub fn supports(&mut self, model: &Arc<Part>) -> bool {
self.models.entry(model, self.frame).is_some()
}
/// `render::draw_scrolled` on the GPU: false (nothing done) when the
/// model cannot take this path.
pub fn draw(
&mut self,
model: &Arc<Part>,
base: glam::Mat4,
poses: &Poses,
paint: Paint,
) -> bool {
let Some(stored) = self.models.entry(model, self.frame) else {
return false;
};
let first_part = self.frame.parts.len() as u32;
self.frame.parts.resize(
self.frame.parts.len() + stored.parts.len(),
bytemuck::Zeroable::zeroed(),
);
let mut walk = Walk {
stored,
poses,
parts: &mut self.frame.parts[first_part as usize..],
jobs: &mut self.frame.jobs,
vertices: &mut self.frame.vertices,
bounds: &mut self.bounds,
first_part,
paint,
index: 0,
};
walk.part(model, base, ModelNormals::new(base));
true
}
}
struct Walk<'a> {
stored: &'a Stored,
poses: &'a Poses,
parts: &'a mut [SkinPart],
jobs: &'a mut Vec<SkinJob>,
vertices: &'a mut u32,
bounds: &'a mut Option<(glam::Vec3, glam::Vec3)>,
first_part: u32,
paint: Paint,
/// The pre-order index of the part being walked.
index: u32,
}
impl Walk<'_> {
/// `render::draw_normals` without the vertices: the part's matrices, and
/// its polygons as (part of) a job.
fn part(&mut self, part: &Part, base: glam::Mat4, normals: ModelNormals) {
let index = self.index;
let stored = self.stored.parts[index as usize];
let p = self.poses.pose(part);
let m = super::render::posed(base, p);
if self.poses.is_hidden(part) || p[6] == 0.0 && p[7] == 0.0 && p[8] == 0.0 {
// The whole subtree is left out.
self.index = stored.end;
return;
}
let normals = normals.part(p);
let n = if normals.trusted {
normals.matrix
} else {
glam::Mat3::from_cols(
normals.matrix.x_axis.normalize_or_zero(),
normals.matrix.y_axis.normalize_or_zero(),
normals.matrix.z_axis.normalize_or_zero(),
)
};
self.parts[index as usize] = SkinPart {
matrix: m.to_cols_array(),
normal: n.to_cols_array(),
};
if !self.poses.skips(part) && stored.count > 0 {
self.emit(stored);
if let Some((lo, hi)) = self.stored.bounds[index as usize] {
let (lo, hi) = (glam::Vec3::from_array(lo), glam::Vec3::from_array(hi));
let center = m.transform_point3((lo + hi) * 0.5);
let half = (hi - lo) * 0.5;
let r = glam::Mat3::from_mat4(m);
let extent =
r.x_axis.abs() * half.x + r.y_axis.abs() * half.y + r.z_axis.abs() * half.z;
let (a, b) = (center - extent, center + extent);
*self.bounds = Some(match *self.bounds {
Some((l, h)) => (l.min(a), h.max(b)),
None => (a, b),
});
}
}
self.index += 1;
for child in &part.children {
self.part(child, m, normals);
}
}
/// The part's vertices: extends the last job when they follow its own.
fn emit(&mut self, stored: StoredPart) {
let first = self.stored.first + stored.first;
let paint = self.paint;
if let Some(job) = self.jobs.last_mut()
&& job.first + job.count == first
&& job.part == self.first_part
&& job.dst + job.count == *self.vertices
{
job.count += stored.count;
} else {
self.jobs.push(SkinJob {
first,
count: stored.count,
part: self.first_part,
dst: *self.vertices,
rect: paint.uv,
scroll: paint.scroll,
color: paint.color,
light: paint.light,
overlay: paint.overlay,
glint: u32::from(paint.overlay == super::render::ARMOR_GLINT),
spare: [0; 3],
});
}
*self.vertices += stored.count;
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gfx::entity_skin::expand;
use crate::mobs::{Mob, MobData, MobTypes, render};
/// Every vanilla mob, at a few ages and turns: the GPU path's vertices
/// (its jobs expanded as `entity_skin.wgsl` does) are the CPU path's, to
/// the bit and in the same order, for each mob it takes; the mobs it
/// leaves to the CPU draw exactly as before. Needs the prepared resources.
#[test]
fn posed_on_the_gpu_every_mob_draws_what_the_cpu_draws() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
if !root.join("entity-models.json").exists() {
eprintln!("no prepared resources: skipped");
return;
}
let types = MobTypes::load(&root).unwrap();
let data = MobData {
states: Vec::new(),
placements: Default::default(),
heightmaps: Default::default(),
animations: MobData::load_animations(&root),
biome_tags: Default::default(),
entity_tags: Default::default(),
villager_hats: Default::default(),
cem: Default::default(),
random_textures: Default::default(),
emissive: Default::default(),
blinks: Default::default(),
pois: Default::default(),
species_placements: Default::default(),
};
// Every texture its own rect, so a swapped texture shows.
let atlas = |name: &str| {
let h = name
.bytes()
.fold(7u32, |h, b| h.wrapping_mul(31).wrapping_add(u32::from(b)));
let u = (h % 61) as f32 / 64.0;
let v = (h / 61 % 61) as f32 / 64.0;
Some([u, v, u + 1.0 / 64.0, v + 1.0 / 32.0])
};
let camera = glam::DVec3::new(0.3, 62.0, -4.0);
let mut models = SkinModels::default();
let mut frame = SkinFrame::default();
let mut store: Vec<crate::gfx::entity_skin::SkinVertex> = Vec::new();
let (mut posed, mut kept) = (0, 0);
let mut ids: Vec<_> = types.types.keys().cloned().collect();
ids.sort();
for (n, id) in ids.iter().enumerate() {
let kind = Arc::new(types.types[id].clone());
for (age, yaw) in [(0u32, 0.0f32), (37, 123.0), (412, -71.5)] {
let at = horizon_core::geometry::Vec3::new(1.5 + n as f64 % 5.0, 64.0, 3.25);
let mut m = Mob::new(n as u32 + 1, kind.clone(), at, yaw);
m.previous = horizon_core::geometry::Vec3::new(at.x - 0.1, at.y, at.z + 0.05);
m.age = age;
m.limb_speed = 0.6;
m.limb_position = age as f32 * 0.37;
let mut cpu = Vec::new();
render::render_with(&m, 0.4, camera, &atlas, 0x50a0, &data, &[], &mut cpu);
models.begin(&mut frame, store.len() as u32);
let mut gpu = Vec::new();
let mut skin = Skin {
models: &mut models,
frame: &mut frame,
bounds: None,
};
render::render_skinned(&m, 0.4, camera, &atlas, 0x50a0, &data, &mut gpu, &mut skin);
let bounds = skin.bounds;
if frame.reset {
store.clear();
}
assert_eq!(store.len() as u32, frame.base);
store.extend_from_slice(&frame.new_model);
frame.handed();
if frame.jobs.is_empty() {
kept += 1;
assert_eq!(
bytemuck::cast_slice::<_, u8>(&gpu),
bytemuck::cast_slice::<_, u8>(&cpu),
"{id} age {age}: the CPU path changed"
);
continue;
}
posed += 1;
assert!(gpu.is_empty(), "{id}: a posed mob left vertices on the CPU");
let expanded = expand(&store, &frame);
assert_eq!(expanded.len(), cpu.len(), "{id} age {age}: vertex count");
for (i, (a, b)) in expanded.iter().zip(&cpu).enumerate() {
assert_eq!(
bytemuck::bytes_of(a),
bytemuck::bytes_of(b),
"{id} age {age} vertex {i}: {a:?} != {b:?}"
);
}
// The culling bounds hold every vertex.
let (lo, hi) = bounds.expect("a posed mob has bounds");
for v in &cpu {
let p = glam::Vec3::from_array(v.pos);
assert!(
p.cmpge(lo - 1e-4).all() && p.cmple(hi + 1e-4).all(),
"{id}: {p} outside {lo}..{hi}"
);
}
}
}
eprintln!("posed on the GPU: {posed} mob draws; kept on the CPU: {kept}");
assert!(posed > 3 * 40, "only {posed} draws took the GPU path");
}
}
+18 -15
View File
@@ -345,25 +345,28 @@ pub fn work_sound(profession: u8) -> Option<&'static str> {
}
/// A `CropBlock` at `p`.
pub fn crop(w: &MobWorld, p: [i32; 3]) -> bool {
matches!(
w.block_name(p[0], p[1], p[2]),
"minecraft:wheat"
| "minecraft:carrots"
| "minecraft:potatoes"
| "minecraft:beetroots"
| "minecraft:torchflower_crop"
)
w.facts
.get(w.world.block(p[0], p[1], p[2]))
.has_class("CropBlock")
}
/// `CropBlock.isMaxAge` for the block at `p`.
pub fn ripe_crop(w: &MobWorld, p: [i32; 3]) -> bool {
let max = match w.block_name(p[0], p[1], p[2]) {
"minecraft:wheat" | "minecraft:carrots" | "minecraft:potatoes" => "7",
"minecraft:beetroots" => "3",
// TorchflowerCropBlock.getMaxAge is 2, past its age property (0-1):
// the crop is never at max age (age 2 is the torchflower itself).
_ => return false,
let facts = w.facts.get(w.world.block(p[0], p[1], p[2]));
if !facts.has_class("CropBlock") {
return false;
}
// The class chain survives content registration, including the two
// getMaxAge overrides. PitcherCropBlock is not a CropBlock.
let max = if facts.has_class("BeetrootBlock") {
3
} else if facts.has_class("TorchflowerCropBlock") {
2
} else {
7
};
w.prop(p[0], p[1], p[2], "age") == Some(max)
w.prop(p[0], p[1], p[2], "age")
.and_then(|age| age.parse::<i32>().ok())
.is_some_and(|age| age >= max)
}
/// The gift loot table of a villager (`GiveGiftToHero.getLootTableToThrow`).
@@ -775,7 +775,7 @@ fn sense_babies(m: &mut Mob, w: &MobWorld) {
}
mem(m).visible_villager_babies.set_list(babies);
}
/// `SecondaryPoiSensor`: a farmer's farmland within four blocks.
/// `SecondaryPoiSensor.doTick`: a farmer's farmland within four blocks.
fn sense_secondary(m: &mut Mob, w: &MobWorld) {
let mut sites = Vec::new();
if super::villager::is_farmer(m) {
@@ -784,7 +784,7 @@ fn sense_secondary(m: &mut Mob, w: &MobWorld) {
for y in -2..=2 {
for z in -4..=4 {
let p = [c[0] + x, c[1] + y, c[2] + z];
if w.block_name(p[0], p[1], p[2]) == "minecraft:farmland" {
if farmland(w, p) {
sites.push(p);
}
}
@@ -3494,6 +3494,10 @@ fn restock_if_due(m: &mut Mob, w: &MobWorld) {
t.restock(w.game_time);
}
}
#[cfg(test)]
#[path = "villager_farming_tests.rs"]
mod farming_tests;
/// `HarvestFarmland.validFarmlandAroundVillager`: ripe crops and bare
/// farmland around the villager.
fn farmland_around(m: &Mob, w: &MobWorld) -> Vec<[i32; 3]> {
@@ -3514,10 +3518,16 @@ fn farmland_around(m: &Mob, w: &MobWorld) -> Vec<[i32; 3]> {
}
valid
}
/// Farmland subclasses include mod templates.
fn farmland(w: &MobWorld, p: [i32; 3]) -> bool {
w.facts
.get(w.world.block(p[0], p[1], p[2]))
.has_class("FarmlandBlock")
}
/// `HarvestFarmland.validPos`: a ripe CropBlock or air above farmland.
fn farmland_valid(w: &MobWorld, p: [i32; 3]) -> bool {
super::villager::ripe_crop(w, p)
|| w.facts.is_air(w.world.block(p[0], p[1], p[2]))
&& w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:farmland"
|| w.facts.is_air(w.world.block(p[0], p[1], p[2])) && farmland(w, [p[0], p[1] - 1, p[2]])
}
/// `HarvestFarmland.getValidFarmland`: one picked by the level random.
fn pick_farmland(valid: &[[i32; 3]], cx: &mut Bx) -> Option<[i32; 3]> {
@@ -3527,17 +3537,13 @@ fn pick_farmland(valid: &[[i32; 3]], cx: &mut Bx) -> Option<[i32; 3]> {
let i = cx.lr.next_int_bound(valid.len() as i32).unwrap_or(0) as usize;
Some(valid[i])
}
/// The seeds a villager plants (`VILLAGER_PLANTABLE_SEEDS`) and their crops.
fn seed_crop(item: &str) -> Option<&'static str> {
Some(match item {
"minecraft:wheat_seeds" => "minecraft:wheat",
"minecraft:potato" => "minecraft:potatoes",
"minecraft:carrot" => "minecraft:carrots",
"minecraft:beetroot_seeds" => "minecraft:beetroots",
"minecraft:torchflower_seeds" => "minecraft:torchflower_crop",
"minecraft:pitcher_pod" => "minecraft:pitcher_crop",
_ => return None,
})
/// A tagged BlockItem plants its block's default state.
fn seed_crop(w: &MobWorld, item: &str) -> Option<horizon_world::registry::BlockStateId> {
let items = w.items?;
if !items.item_in_tag("minecraft:villager_plantable_seeds", item) {
return None;
}
items.get(item)?.block
}
/// `HarvestFarmland.tick` at the farmland: harvest a ripe crop, sow bare
/// farmland, and move on from a growing crop.
@@ -3559,23 +3565,22 @@ fn harvest_tick(
if ripe {
cx.events.push(MobEvent::BreakBlock { pos: p, drop: true });
}
if air && w.block_name(p[0], p[1] - 1, p[2]) == "minecraft:farmland" {
if air && farmland(w, [p[0], p[1] - 1, p[2]]) {
let e = super::villager::ext_mut(m);
if let Some(slot) = e
if let Some((slot, crop)) = e
.inventory
.iter()
.position(|s| s.count > 0 && seed_crop(&s.item).is_some())
.enumerate()
.filter(|(_, s)| s.count > 0)
.find_map(|(i, s)| seed_crop(w, &s.item).map(|crop| (i, crop)))
{
let crop = seed_crop(&e.inventory[slot].item).unwrap_or("minecraft:wheat");
e.inventory[slot].count -= 1;
if e.inventory[slot].count == 0 {
e.inventory.remove(slot);
}
cx.events.push(MobEvent::SetBlockName {
cx.events.push(MobEvent::SetBlock {
pos: p,
name: crop,
effect: false,
flags: horizon_world::level::flags::UPDATE_ALL,
state: crop,
});
cx.events.push(MobEvent::Sound(
"item.crop.plant",
@@ -0,0 +1,322 @@
use super::*;
use crate::{items::ItemStack, mod_content::ContentMod};
use std::{
path::PathBuf,
sync::{Arc, OnceLock},
};
fn resources() -> &'static std::path::Path {
static ROOT: OnceLock<PathBuf> = OnceLock::new();
ROOT.get_or_init(|| {
let base = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client/26.3");
let scratch = std::env::temp_dir().join(format!("fresh-farming-{}", std::process::id()));
let dir = scratch.join("mod");
let mut files = vec![(
"horizon-mod.toml".to_owned(),
"id = \"fieldtest\"\nname = \"Field test\"\nversion = \"1.0\"\napi = \"0.2\"\nside = \"both\"\ncapabilities = [\"registries\"]\n".to_owned(),
)];
for (id, template) in [
("soil", "farmland"),
("grain", "wheat"),
("roots", "beetroots"),
("flower", "torchflower_crop"),
("pod", "pitcher_crop"),
("fake_crop", "stone"),
] {
files.push((
format!("data/fieldtest/horizon/block/{id}.json"),
format!(r#"{{"template":"minecraft:{template}"}}"#),
));
}
files.push((
"data/minecraft/tags/item/villager_plantable_seeds.json".into(),
r#"{"replace":true,"values":["fieldtest:grain","fieldtest:roots","minecraft:stick"]}"#.into(),
));
for (name, text) in files {
let path = dir.join(name);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
let package = horizon_mods::Package::read(&dir).unwrap();
let content = horizon_mods::Content::read(&dir, &package.manifest).unwrap();
let out = scratch.join("resources");
crate::mod_content::build(&base, &[ContentMod { id: "fieldtest".into(), content }], &out)
.unwrap();
out
})
}
fn farmer(types: &super::super::MobTypes) -> Mob {
let mut m = Mob::new(
1,
Arc::new(types.types["minecraft:villager"].clone()),
Vec3::new(0.5, 1.0, 0.5),
0.0,
);
super::super::villager::set_profession(&mut m, 5);
m
}
fn harvest() -> B {
B::HarvestFarmland {
t: timed(60, 60),
next_ok: 0,
target: None,
valid: Vec::new(),
worked: 0,
}
}
fn put(
world: &mut crate::world::World,
x: i32,
y: i32,
state: horizon_world::registry::BlockStateId,
) {
if !world.chunks.contains_key(&(x >> 4, 0)) {
world
.chunks
.insert((x >> 4, 0), Arc::new(crate::chunk::Chunk::empty(x >> 4, 0)));
}
world.set(x, y, 0, state).unwrap();
}
#[test]
#[ignore = "full content root: tools/test_villager_farming_parity.py runs it"]
fn mod_farmland_is_a_secondary_site_and_farming_target() {
assert!(super::super::world::with_resource_test_world(
resources(),
&[],
true,
|world, reg| {
put(world, 0, 0, reg.default_state("fieldtest:soil").unwrap());
},
|w, types| {
let mut m = farmer(types);
sense_secondary(&mut m, w);
assert_eq!(
mem(&mut m).secondary_job_site.value(),
Some(vec![[0, 0, 0]])
);
let mut lr = LegacyRandom::new(11);
let mut events = Vec::new();
let mut cx = Bx {
w,
lr: &mut lr,
events: &mut events,
now: 1000,
refresh: false,
};
assert!(harvest().try_start(&mut m, &mut cx));
let mut denied = w.clone();
denied.mob_griefing = false;
mem(&mut m).look_target.clear();
mem(&mut m).walk_target.clear();
cx.w = &denied;
assert!(!harvest().try_start(&mut m, &mut cx));
}
));
}
#[test]
#[ignore = "full content root: tools/test_villager_farming_parity.py runs it"]
fn mod_crops_are_harvested_only_at_their_vanilla_templates_max_age() {
assert!(super::super::world::with_resource_test_world(
resources(),
&[],
true,
|world, reg| {
for (x, block, age) in [
(0, "grain", "6"),
(1, "grain", "7"),
(2, "roots", "3"),
(3, "flower", "1"),
(4, "pod", "4"),
] {
let mut state = reg
.state(reg.default_state(&format!("fieldtest:{block}")).unwrap())
.unwrap()
.clone();
state.properties.insert("age".into(), age.into());
put(world, x, 1, reg.resolve(&state).unwrap());
}
put(
world,
5,
1,
reg.default_state("fieldtest:fake_crop").unwrap(),
);
},
|w, types| {
let mut m = farmer(types);
let mut lr = LegacyRandom::new(12);
let mut events = Vec::new();
for (x, ripe) in [
(0, false),
(1, true),
(2, true),
(3, false),
(4, false),
(5, false),
] {
events.clear();
harvest_tick(
&mut m,
&mut Bx {
w,
lr: &mut lr,
events: &mut events,
now: 1000,
refresh: false,
},
&mut 0,
&mut Some([x, 1, 0]),
&mut Vec::new(),
);
assert_eq!(events.iter().any(|e| matches!(e, MobEvent::BreakBlock { pos, drop: true } if *pos == [x, 1, 0])), ripe, "x={x}");
}
}
));
}
#[test]
#[ignore = "full content root: tools/test_villager_farming_parity.py runs it"]
fn farming_uses_the_first_tagged_block_item_and_consumes_one_seed() {
assert!(super::super::world::with_resource_test_world(
resources(),
&[],
true,
|world, reg| {
for x in 0..=1 {
put(world, x, 0, reg.default_state("fieldtest:soil").unwrap());
}
},
|w, types| {
let mut m = farmer(types);
let inventory = &mut super::super::villager::ext_mut(&mut m).inventory;
*inventory = vec![
ItemStack::new("minecraft:wheat_seeds", 2),
ItemStack::new("minecraft:stick", 2),
ItemStack::new("fieldtest:flower", 2),
ItemStack::new("fieldtest:roots", 1),
ItemStack::new("fieldtest:grain", 2),
];
let mut lr = LegacyRandom::new(13);
let mut events = Vec::new();
harvest_tick(
&mut m,
&mut Bx {
w,
lr: &mut lr,
events: &mut events,
now: 1000,
refresh: false,
},
&mut 0,
&mut Some([0, 1, 0]),
&mut Vec::new(),
);
let planted = events.iter().find_map(|e| match e {
MobEvent::SetBlock { state, .. } => {
Some(w.registry.state(*state).unwrap().name.as_str())
}
MobEvent::SetBlockName { name, .. } => Some(*name),
_ => None,
});
assert_eq!(planted, Some("fieldtest:roots"));
let inventory = &super::super::villager::ext(&m).unwrap().inventory;
assert_eq!(
inventory
.iter()
.map(|s| (&*s.item, s.count))
.collect::<Vec<_>>(),
vec![
("minecraft:wheat_seeds", 2),
("minecraft:stick", 2),
("fieldtest:flower", 2),
("fieldtest:grain", 2)
]
);
events.clear();
harvest_tick(
&mut m,
&mut Bx {
w,
lr: &mut lr,
events: &mut events,
now: 1001,
refresh: false,
},
&mut 0,
&mut Some([1, 1, 0]),
&mut Vec::new(),
);
let expected = w.registry.default_state("fieldtest:grain").unwrap();
assert!(events.iter().any(
|e| matches!(e, MobEvent::SetBlock { pos: [1, 1, 0], state } if *state == expected)
));
assert_eq!(
super::super::villager::ext(&m)
.unwrap()
.inventory
.last()
.unwrap()
.count,
1
);
assert_eq!(
events
.iter()
.filter(|e| matches!(e, MobEvent::Sound("item.crop.plant", _, 1.0, 1.0)))
.count(),
1
);
}
));
}
#[test]
#[ignore = "tools/test_villager_farming_parity.py supplies the vanilla oracle"]
fn vanilla_farming_classification_matches_the_oracle() {
let path = std::env::var_os("HORIZON_FARMING_ORACLE").expect("vanilla oracle output");
let rows: Vec<(u32, Vec<bool>)> = std::fs::read_to_string(path)
.unwrap()
.lines()
.map(|line| {
let mut fields = line.split_whitespace();
(
fields.next().unwrap().parse().unwrap(),
fields.map(|f| f.parse().unwrap()).collect(),
)
})
.collect();
assert!(!rows.is_empty());
assert!(super::super::world::with_built_test_world(
&[],
|world, _| {
for (x, (state, _)) in rows.iter().enumerate() {
put(
world,
x as i32,
0,
horizon_world::registry::BlockStateId(*state),
);
}
},
|w, _| {
for (x, (state, expected)) in rows.iter().enumerate() {
let p = [x as i32, 0, 0];
assert_eq!(
vec![
super::super::villager::crop(w, p),
super::super::villager::ripe_crop(w, p),
farmland_valid(w, [p[0], 1, 0])
],
*expected,
"state={state}"
);
}
}
));
}
+19 -8
View File
@@ -417,24 +417,35 @@ pub(crate) fn with_built_test_world_rules(
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
with_resource_test_world(&root, others, mob_griefing, build, f)
}
/// The same mob world loaded from a content mod's resource root.
#[cfg(test)]
pub(crate) fn with_resource_test_world(
root: &std::path::Path,
others: &[Seen],
mob_griefing: bool,
build: impl FnOnce(&mut crate::world::World, &horizon_world::registry::BlockRegistry),
f: impl FnOnce(&MobWorld<'_>, &super::MobTypes),
) -> bool {
if !root.join("entity-types.json").exists() || !root.join("movement-properties.nbt").exists() {
return false;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let assets = crate::assets::Assets::load(root).unwrap();
let registry = assets.registry;
let facts = BlockFacts::load(&root, registry).unwrap();
let motion = crate::motion::MotionResources::load(&root, &assets).unwrap();
let data = super::MobData::load(&root, &facts);
let types = super::MobTypes::load(&root).unwrap();
let biomes = crate::biome::Biomes::load(&root).unwrap();
let facts = BlockFacts::load(root, registry).unwrap();
let motion = crate::motion::MotionResources::load(root, &assets).unwrap();
let data = super::MobData::load(root, &facts);
let types = super::MobTypes::load(root).unwrap();
let biomes = crate::biome::Biomes::load(root).unwrap();
let items = crate::items::Items::load(
&root,
root,
registry,
&facts,
&horizon_world::loot::PackOverlay::NONE,
)
.unwrap();
let collision = crate::collisions::load(&root, registry).unwrap();
let collision = crate::collisions::load(root, registry).unwrap();
let air = registry.default_state("minecraft:air").unwrap();
let mut world = crate::world::World::new(air, 0);
build(&mut world, registry);
+2 -1
View File
@@ -1144,7 +1144,8 @@ pub trait Hooks: Send + Sync {
/// the clicked position, whether the clicked block is being replaced, the
/// item in hand) and `fluid` the fluid in the position the block would go
/// in, which a waterlogged block reads. Build the state from `state` with
/// `native_state_with_property`. It is asked for every placer that goes
/// `native_state_with_property`, and know whether `state` is yours with
/// `native_state_block`. It is asked for every placer that goes
/// through vanilla's placement (a player's `BlockItem.place`, a
/// dispenser's `DirectionalPlaceContext`), and the answer still takes its
/// survival and entity obstruction checks. A trait mod returns
+139 -4
View File
@@ -344,6 +344,10 @@ pub struct PlayerMotion {
pub local_crouching: bool,
pub pose: crate::pose::Pose,
pub dimensions: crate::pose::Dimensions,
/// The `minecraft:scale` the dimensions were last built with
/// (`Entity.refreshDimensions`), for the poses the body takes before it
/// next refreshes.
pub scale: f32,
pub speed: f32,
/// `LocalPlayer.itemUseSpeedMultiplier` while using an item (not riding).
pub item_use_speed: Option<f32>,
@@ -428,6 +432,16 @@ fn attribute_error(error: impl std::fmt::Debug) -> io::Error {
io::Error::other(format!("movement attribute: {error:?}"))
}
impl PlayerMotion {
/// `ServerGamePacketListenerImpl.handleMovePlayer`: the movement the client
/// sent is the server copy's own delta movement. A glide's wall hit reads it
/// as the speed the glide had (`LivingEntity.handleFallFlyingCollisions`'s
/// moveHorLength); a copy whose travels start from its own last velocity
/// otherwise keeps a few hundredths there, and no hit reaches the damage.
pub fn carry_client_movement(&mut self, movement: Vec3) {
if self.fall_flying {
self.velocity = movement;
}
}
/// `Player.isSweepAttack`, using the resident player's known horizontal velocity.
pub fn is_sweep_attack(&self, strength: f32, critical: bool, sword: bool) -> bool {
let movement = self.velocity;
@@ -559,6 +573,7 @@ impl PlayerMotion {
local_crouching: false,
pose: crate::pose::Pose::Standing,
dimensions: crate::pose::Pose::Standing.dimensions(1.0),
scale: 1.0,
swimming: false,
is_passenger: false,
fire_ticks: -20,
@@ -717,6 +732,18 @@ impl PlayerMotion {
/// player's `attributes` list (LivingEntity.addAdditionalSaveData), each
/// instance's id, base and permanent modifiers.
pub fn saved_attributes(&self) -> horizon_world::nbt::Tag {
self.packed_attributes(true)
}
/// `ServerPlayer.restoreFrom`: the attributes a player's new body takes
/// over from the old one. A body that comes back from a death takes the
/// base values only (`assignBaseValues`); the same player in another
/// level (`ServerPlayer.teleport`) is the same entity, with its permanent
/// modifiers too (`restoreAll`, `assignPermanentModifiers`). A scale of
/// 0.1 is still 0.1 after a respawn or a trip through a portal (#3684).
pub fn carried_attributes(&self, restore_all: bool) -> horizon_world::nbt::Tag {
self.packed_attributes(restore_all)
}
fn packed_attributes(&self, modifiers: bool) -> horizon_world::nbt::Tag {
use horizon_client_core::nbt_value::string;
use horizon_world::nbt::Tag;
let list = self
@@ -729,7 +756,7 @@ impl PlayerMotion {
("base", Tag::Double(packed.base.to_bits())),
];
// `modifiers` is an optional field whose default is the empty list.
if !packed.modifiers.is_empty() {
if modifiers && !packed.modifiers.is_empty() {
let modifiers = packed
.modifiers
.iter()
@@ -1035,9 +1062,9 @@ impl MotionWorld<'_, '_, '_> {
if self.player.spectator {
self.player.flying = true;
self.player.ground.on_ground = false;
} else if !self.player.may_fly {
self.player.flying = false;
} else if !was_jumping && jumping && !self.player.was_auto_jump {
} else if self.player.may_fly && !was_jumping && jumping && !self.player.was_auto_jump {
// `LocalPlayer.aiStep`: mayfly gates the flight toggle, not the
// flying state independently supplied by the server's abilities.
if self.player.jump_trigger_time == 0 {
self.player.jump_trigger_time = 7;
} else if !self.player.swimming {
@@ -3430,6 +3457,60 @@ mod tests {
assert!(player.events.is_empty());
}
/// `LivingEntity.handleFallFlyingCollisions`: a glide that meets a wall loses
/// the horizontal speed it had, and the server's copy hurts by that loss,
/// `(moveHorLength - newMoveHorLength) * 10 - 3`, as `minecraft:fly_into_wall`.
/// The speed it starts the tick with is the client's own movement
/// (`handleMovePlayer` gives the server copy its delta movement), so the
/// loss is the speed the glide actually flew at.
#[test]
fn a_glide_into_a_wall_hurts_by_the_speed_it_lost() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let mut scene = World::test_scene(&assets, &render, 1).unwrap();
// A wall in the way of a glide along +x, at feet height.
let stone = assets.registry.default_state("minecraft:stone").unwrap();
for y in 4..=5 {
for z in 19..=21 {
scene.set(23, y, z, stone).unwrap();
}
}
let mut player = PlayerMotion::new(&resources, Vec3::new(22.0, 4.0, 20.5)).unwrap();
player.fall_flying = true;
// Looking along +x (the `getLookAngle` of a flat -90 yaw) keeps the
// glide on its course through `updateFallFlyingMovement`'s pull.
player.yaw = -90.0;
player.pitch = 0.0;
player.carry_client_movement(Vec3::new(1.2, 0.0, 0.0));
MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
}
.server_travel()
.unwrap();
assert!(
player
.hurt_log
.last()
.is_some_and(|hurt| hurt.source == "minecraft:fly_into_wall"),
"{:?}",
player.hurt_log
);
assert!(player.health.health() < 20.0, "{}", player.health.health());
}
#[test]
fn spectator_scroll_speed_controls_horizontal_and_vertical_flight() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -5166,6 +5247,60 @@ mod tests {
assert!(w.player.sprinting);
}
#[test]
fn server_forced_flight_survives_without_permission_to_toggle_it() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
eprintln!("skipped forced flight: prepared resources unavailable");
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let mut scene = World::test_scene(&assets, &render, 3).unwrap();
let start = Vec3::new(20.5, 20.0, 20.5);
let mut player = PlayerMotion::new(&resources, start).unwrap();
player.may_fly = false;
player.flying = true;
player.flying_speed = 0.0;
let mut w = MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
};
// Idle, movement in all directions, and repeated jump presses must
// neither cancel server-forced flight nor move a zero-speed flyer.
for tick in 0..60 {
w.controlled_simulation_tick(crate::sprint::Keys {
forward: tick % 4 == 0,
backward: tick % 4 == 1,
left: tick % 4 == 2,
right: tick % 4 == 3,
jump: tick % 2 == 0,
shift: tick % 3 == 0,
..Default::default()
})
.unwrap();
assert!(w.player.flying, "server-forced flight ended on tick {tick}");
assert_eq!(w.player.position, start);
assert_eq!(w.player.velocity, Vec3::ZERO);
}
// A later abilities update from the server can still end flight.
w.player.flying = false;
for _ in 0..3 {
w.controlled_simulation_tick(crate::sprint::Keys::default())
.unwrap();
}
assert!(!w.player.flying);
assert!(w.player.position.y < start.y);
}
/// Vanilla `Player.getFlyingSpeed`: a flying player moves at the ability
/// speed, doubled while the sprint state holds. The sprint key starts the
/// state; releasing it does not change the speed.
+2 -1
View File
@@ -94,7 +94,8 @@ impl PlayerMotion {
Ok(pose.dimensions(self.attribute("minecraft:scale")? as f32))
}
pub fn refresh_dimensions(&mut self) -> Result<()> {
self.dimensions = self.dimensions_for_pose(self.pose)?;
self.scale = self.attribute("minecraft:scale")? as f32;
self.dimensions = self.pose.dimensions(self.scale);
Ok(())
}
pub fn set_pose(&mut self, pose: Pose) -> Result<()> {
+7 -8
View File
@@ -717,14 +717,13 @@ impl RecipeIndex {
// The packs' files replace first and the gate answers the merged
// file, as the crafting loader's does: a false-conditioned pack
// file leaves the lower one gone too.
let mut sources: Vec<(String, serde_json::Value)> = crate::crafting::recipe_files(&dir)
.unwrap_or_default()
.into_iter()
.filter_map(|(name, path)| {
let v = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice::<serde_json::Value>(&b).ok())?;
Some((format!("minecraft:{name}"), v))
let folder = crate::json_folder::read(&dir).ok();
let mut sources: Vec<(String, serde_json::Value)> = folder
.iter()
.flat_map(|folder| &folder.files)
.filter_map(|file| {
let v = serde_json::from_slice::<serde_json::Value>(file.bytes().ok()?).ok()?;
Some((format!("minecraft:{}", file.name), v))
})
.collect();
for (id, files) in overlay.kind("recipe") {
+7 -9
View File
@@ -503,18 +503,16 @@ impl StationRecipes {
};
for namespace in namespaces {
let dir = root.join(format!("data/{namespace}/recipe"));
let more = crate::crafting::recipe_files(&dir).unwrap_or_default();
files.extend(
more.into_iter()
.map(|(name, p)| (namespace.clone(), name, p)),
);
if let Ok(folder) = crate::json_folder::read(&dir) {
files.push((namespace, folder));
}
}
let mut sources: Vec<(String, Value)> = files
.iter()
.filter_map(|(namespace, name, p)| {
let bytes = fs::read(p).ok()?;
let v = serde_json::from_slice::<Value>(&bytes).ok()?;
Some((format!("{namespace}:{name}"), v))
.flat_map(|(namespace, folder)| folder.files.iter().map(move |f| (namespace, f)))
.filter_map(|(namespace, file)| {
let v = serde_json::from_slice::<Value>(file.bytes().ok()?).ok()?;
Some((format!("{namespace}:{}", file.name), v))
})
.collect();
// The world's packs' recipes over the game's own: the top pack's
+25 -5
View File
@@ -470,7 +470,8 @@ pub(crate) struct Button {
enabled: bool,
id: u8,
icon: Option<(&'static str, f32)>,
/// `SpriteIconButton.TextAndIcon`: the icon at the right end, the text centred.
/// `SpriteIconButton.TextAndIcon`: the icon at the right end, the text
/// centred in the room left of it.
trailing: Option<(&'static str, f32)>,
}
impl Button {
@@ -479,17 +480,36 @@ impl Button {
/// (`AbstractWidget.extractScrollingStringOverContents`), instead of being
/// drawn past it over the buttons beside it (so_so's 590 px World Type
/// label on Create World, hi_in's Join Server in the Multiplayer footer).
/// A button with a `trailing` icon is `SpriteIconButton.TextAndIcon`, whose
/// label keeps clear of the icon: see [`Button::draw_label_at`].
fn draw_label(&self, batch: &mut HudBatch, atlas: &HudAtlas, color: u32) {
let label = Text::literal(&self.label);
crate::ui::scrolling_text(
self.draw_label_at(batch, atlas, color, crate::hud_overlays::now_ms());
}
/// [`Button::draw_label`] at `now_ms`. `SpriteIconButton.TextAndIcon`
/// (`extractContents`) hands the label to `acceptScrolling` with `left`
/// at the button's x plus 2, `right` at its x plus width minus the
/// sprite's width minus 4, and `centerX` at its x plus half its width: the
/// label slides inside that box when wider, and one that fits is moved
/// left until it ends where the icon's room begins. A button without an
/// icon has `right` 2 short of its edge, where `centerX` is the middle of
/// the box and nothing moves.
fn draw_label_at(&self, batch: &mut HudBatch, atlas: &HudAtlas, color: u32, now_ms: i64) {
let right = match self.trailing {
Some((_, size)) => self.x + self.w - size - 4.0,
None => self.x + self.w - 2.0,
};
crate::ui::scrolling_text_around_at(
batch,
atlas,
&label,
&Text::literal(&self.label),
self.x + (self.w / 2.0).floor(),
self.x + 2.0,
self.x + self.w - 2.0,
right,
self.y,
self.y + 20.0,
self.y + 6.0,
now_ms,
color,
);
}
@@ -535,15 +535,14 @@ impl Title {
u32::MAX,
);
}
// The label sits 3 pixels lower on unselected tabs.
let top = if selected { 0.0 } else { 3.0 };
batch.text_styled(
ui::menu_tab_label(
batch,
atlas,
&label,
x + (tw / 2.0).floor() - (lw / 2.0).floor(),
((top + TAB_BAR - 9.0) / 2.0).floor() + 1.0,
u32::MAX,
(x, 0.0, tw, TAB_BAR),
selected,
true,
crate::hud_overlays::now_ms(),
);
}
let (gx, gy) = grid(w, h, o.tab);
+145
View File
@@ -2074,3 +2074,148 @@ fn labels_at(f: &Fixture) -> (f32, f32, (f32, f32)) {
(customize.x + 10.0, customize.y + 10.0),
)
}
/// A button with an icon at its right end (`SpriteIconButton.TextAndIcon`, the
/// Music & Sounds button of the Accessibility screen): `extractContents` gives
/// the label `left = x + 2` and `right = x + width - spriteWidth - 4`.
mod text_and_icon {
use super::*;
fn atlas() -> HudAtlas {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
fn button(label: &str, icon: Option<(&'static str, f32)>) -> Button {
Button {
x: 30.0,
y: 40.0,
w: 150.0,
label: label.into(),
enabled: true,
id: 0,
icon: None,
trailing: icon,
}
}
/// The x of every vertex of the label's text.
fn xs(batch: &HudBatch) -> Vec<f32> {
batch
.vertices
.iter()
.filter(|v| v.text == 1)
.map(|v| v.pos[0])
.collect()
}
/// The first `n` letters of a text with no repeating pattern.
fn label(n: usize) -> String {
"Bezpostrzydnie połōnczynie Direct Connection Join Server"
.chars()
.take(n)
.collect()
}
fn width(atlas: &HudAtlas, label: &str) -> i32 {
HudBatch::text_width(atlas, label).ceil() as i32
}
/// A label that fits beside the icon but, centred on the button, would end
/// under it (`Settings sa Aksesibilidad...` in `fil_ph`, and about 40 other
/// languages) is moved left until it ends where the icon's room begins:
/// `Mth.clamp(centerX, left + lineWidth / 2, right - lineWidth / 2)` in
/// `defaultScrollingHelper`. It does not scroll. The same label in a plain
/// button stays centred.
#[test]
fn a_label_that_fits_beside_the_icon_is_moved_clear_of_it() {
let atlas = atlas();
// A 16 px icon: `left = x + 2`, `right = x + 130`, `centerX = x + 75`.
let (left, right, center) = (32, 30 + 130, 30 + 75);
// The widest label that fits; `right - width / 2 < centerX` by most.
let text = (1..60)
.map(label)
.rfind(|t| width(&atlas, t) <= right - left)
.expect("a label that fits");
let half = width(&atlas, &text) / 2;
assert!(right - half < center, "{text}: {}", width(&atlas, &text));
let drawn_from = |from: i32| {
let mut batch = HudBatch::new([640, 480]);
batch.text(&atlas, &text, from as f32, 46.0, u32::MAX, true);
xs(&batch)
};
let draw = |b: &Button, now_ms: i64| {
let mut batch = HudBatch::new([640, 480]);
b.draw_label_at(&mut batch, &atlas, u32::MAX, now_ms);
xs(&batch)
};
let b = button(&text, Some(("icon/music_notes", 16.0)));
// Vanilla: `textX = right - width / 2`, drawn from `textX - width / 2`.
assert!(draw(&b, 0) == drawn_from(right - half - half), "{text}");
for now_ms in [700, 1500, 31_337] {
assert!(draw(&b, now_ms) == draw(&b, 0), "it scrolls at {now_ms}");
}
let plain = button(&text, None);
assert!(draw(&plain, 0) == drawn_from(center - half), "plain {text}");
}
/// A label wider than the room beside the icon scrolls inside
/// `x + 2 ..= x + width - spriteWidth - 4`, never under the icon.
#[test]
fn a_label_wider_than_the_room_beside_the_icon_scrolls_clear_of_it() {
let atlas = atlas();
let text = label(40);
let b = button(&text, Some(("icon/music_notes", 16.0)));
assert!(width(&atlas, &text) > 130 - 2);
let mut seen = std::collections::BTreeSet::new();
for now_ms in (0..40_000).step_by(250) {
let mut batch = HudBatch::new([640, 480]);
b.draw_label_at(&mut batch, &atlas, u32::MAX, now_ms);
let xs = xs(&batch);
assert!(!xs.is_empty());
for x in &xs {
assert!(
(32.0..=30.0 + 150.0 - 16.0 - 4.0).contains(x),
"{x} at {now_ms} ms"
);
}
seen.insert(xs.iter().map(|x| x.to_bits()).collect::<Vec<_>>());
}
assert!(seen.len() > 3, "the label hardly moves");
}
}
/// `MenuTabBar.MenuTabButton.renderLabel`: a tab's label slides inside the
/// tab's box with a 1 px margin when it is wider than the tab (here a window
/// too narrow for the English labels), instead of running into the next tab.
#[test]
fn a_create_world_tab_label_wider_than_its_tab_stays_inside_it() {
let mut f = Fixture::new();
f.click("Singleplayer");
let atlas = HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap();
// 90 px wide: three 20 px tabs from x = 14, too narrow for "Game".
let mut batch = HudBatch::new([90, 262]);
f.title.draw(&atlas, &mut batch, (0.0, 100.0), true);
let labels: Vec<f32> = batch
.vertices
.iter()
.filter(|v| v.text == 1 && v.pos[1] < 24.0)
.map(|v| v.pos[0])
.collect();
assert!(!labels.is_empty());
let boxes: Vec<(f32, f32)> = (0..3)
.map(|tab| {
(
14.0 + 20.0 * tab as f32 + 1.0,
14.0 + 20.0 * (tab + 1) as f32 - 1.0,
)
})
.collect();
for x in &labels {
assert!(
boxes.iter().any(|(lo, hi)| (*lo..=*hi).contains(x)),
"label vertex at {x} is outside every tab's box"
);
}
for (tab, (lo, hi)) in boxes.iter().enumerate() {
assert!(
labels.iter().any(|x| (*lo..=*hi).contains(x)),
"tab {tab} draws no label"
);
}
}
@@ -156,7 +156,7 @@ impl Session {
let before = (self.streaming.generated_total, self.world.chunks.len());
self.update();
thread::sleep(Duration::from_millis(5));
let idle = self.streaming.pending() == 0
let idle = !self.streaming.busy()
&& before == (self.streaming.generated_total, self.world.chunks.len());
quiet = if idle { quiet + 1 } else { 0 };
}
@@ -86,7 +86,7 @@ fn columns_loaded_past_the_render_distance_mesh_when_they_come_in_range() {
// and nothing comes back (`pending` empties).
streaming.free(uploads);
thread::sleep(Duration::from_millis(5));
let idle = streaming.pending() == 0 && before == (world.chunks.len(), meshed.len());
let idle = !streaming.busy() && before == (world.chunks.len(), meshed.len());
quiet = if idle { quiet + 1 } else { 0 };
}
};
@@ -181,7 +181,7 @@ fn relit_columns_keep_every_mesh_current() {
while quiet < 40 {
assert!(Instant::now() < deadline, "streaming never settled");
let (uploads, _) = streaming.update(world);
let idle = uploads.is_empty() && streaming.pending() == 0;
let idle = uploads.is_empty() && !streaming.busy();
meshes.extend(uploads);
thread::sleep(Duration::from_millis(5));
quiet = if idle { quiet + 1 } else { 0 };
+1 -1
View File
@@ -90,7 +90,7 @@ fn a_tall_column_streams_lights_and_meshes_to_its_top() {
while quiet < 40 {
assert!(Instant::now() < deadline, "streaming never settled");
let (uploads, _) = streaming.update(world);
let idle = uploads.is_empty() && streaming.pending() == 0;
let idle = uploads.is_empty() && !streaming.busy();
meshes.extend(uploads);
thread::sleep(Duration::from_millis(5));
quiet = if idle { quiet + 1 } else { 0 };
+37
View File
@@ -272,6 +272,23 @@ impl PalettedContainer {
Palette::Global => self.raw(i) as u32,
}
}
/// `get` of the entries `start..start + out.len()`, the palette matched once.
#[inline]
pub fn get_run(&self, start: usize, out: &mut [u32]) {
match &self.palette {
Palette::Single(value) => out.fill(*value),
Palette::List(list) => {
for (k, cell) in out.iter_mut().enumerate() {
*cell = list[self.raw(start + k)];
}
}
Palette::Global => {
for (k, cell) in out.iter_mut().enumerate() {
*cell = self.raw(start + k) as u32;
}
}
}
}
/// The value when every entry is equal, without the palette having to be
/// minimal.
pub fn uniform(&self) -> Option<u32> {
@@ -736,6 +753,26 @@ mod tests {
*state
}
#[test]
fn runs_read_what_get_reads() {
let mut state = 0x9E37_79B9_7F4A_7C15;
// One value, a list palette, and the global palette.
for distinct in [1u64, 5, 3000] {
let values: Vec<u32> = (0..4096)
.map(|_| (rng(&mut state) % distinct) as u32)
.collect();
let container = PalettedContainer::from_values(BLOCKS, &values);
for _ in 0..200 {
let start = (rng(&mut state) % 4096) as usize;
let len = (rng(&mut state) % 20) as usize;
let len = len.min(4096 - start);
let mut run = vec![u32::MAX; len];
container.get_run(start, &mut run);
let expected: Vec<u32> = (start..start + len).map(|i| container.get(i)).collect();
assert_eq!(run, expected, "{distinct} values, {start}+{len}");
}
}
}
#[test]
fn containers_stored_alike_are_the_same() {
let values: Vec<u32> = (0..4096).map(|i| (i % 7) as u32).collect();
let a = PalettedContainer::from_values(BLOCKS, &values);
+136
View File
@@ -0,0 +1,136 @@
//! Where packs keep their custom entity models and how a game reads one: the `.jem` files of
//! `emf/cem/` and `optifine/cem/` by model name, each read under [`MAX_BYTES`], parsed, its
//! warnings logged; and the atlas name of a model's texture. Shared by the mobs' and the block
//! entities' models.
use super::model::{EntityModel, MAX_BYTES, parse_jem};
use std::{collections::BTreeMap, fs, path::Path};
/// Every `.jem` of the CEM folders by model name, sorted, each name's files best first:
/// `emf/cem` before `optifine/cem` whichever form either uses, and within one folder the flat
/// `<name>.jem` before `<name>/<name>.jem`.
pub fn jem_files(root: &Path) -> BTreeMap<String, Vec<String>> {
let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
for base in ["assets/minecraft/emf/cem", "assets/minecraft/optifine/cem"] {
let Ok(entries) = fs::read_dir(root.join(base)) else {
continue;
};
let (mut flat, mut nested) = (Vec::new(), Vec::new());
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().into_owned();
if let Some(model) = name.strip_suffix(".jem") {
// A directory named `x.jem` isn't a model.
if e.path().is_file() {
flat.push((model.to_owned(), format!("{base}/{name}")));
}
} else {
let path = format!("{base}/{name}/{name}.jem");
if root.join(&path).is_file() {
nested.push((name, path));
}
}
}
for (model, path) in flat.into_iter().chain(nested) {
out.entry(model).or_default().push(path);
}
}
out
}
/// The `.jem` at `path`, its warnings logged; None, with a warning, when it can't be read or
/// parsed.
pub fn load_jem(root: &Path, path: &str) -> Option<EntityModel> {
let Some(text) = read_small(&root.join(path)) else {
log::warn!("Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes");
return None;
};
let jem = match parse_jem(path, &text, &mut |p: &str| read_small(&root.join(p))) {
Ok(jem) => jem,
Err(e) => {
log::warn!("Horizon: custom entity model {e}");
return None;
}
};
for warning in &jem.warnings {
log::warn!("Horizon: custom entity model {warning}");
}
if let Some(texture) = &jem.texture {
if atlas_name(texture).is_none() {
log::warn!(
"Horizon: custom entity model {path}: its texture {texture} is outside \
assets/minecraft/textures/, so not in the atlas: the vanilla one draws"
);
} else if !root.join(texture).is_file() {
log::warn!(
"Horizon: custom entity model {path}: its texture {texture} doesn't exist, \
so not in the atlas: the vanilla one draws"
);
}
}
Some(jem)
}
/// A pack file's text, None when it's missing or larger than a model may read: its size is
/// checked before anything is read.
fn read_small(path: &Path) -> Option<String> {
let len = fs::metadata(path).ok()?.len();
if len > MAX_BYTES as u64 {
return None;
}
fs::read_to_string(path).ok()
}
/// The atlas name of a texture resource path: `assets/minecraft/textures/entity/x.png` is
/// `entity/x`. Textures outside a `textures/` folder are not in the atlas.
pub fn atlas_name(path: &str) -> Option<String> {
let rest = path.strip_prefix("assets/minecraft/textures/")?;
Some(rest.strip_suffix(".png").unwrap_or(rest).to_owned())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn emf_files_win_and_a_flat_file_beats_its_folder() {
let root = std::env::temp_dir().join(format!("horizon-jem-files-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
for path in [
// EMF's folder form over OptiFine's flat file.
"optifine/cem/cow.jem",
"emf/cem/cow/cow.jem",
// One folder: the flat file over the folder form.
"optifine/cem/pig.jem",
"optifine/cem/pig/pig.jem",
// A directory named `sheep.jem` is no file: the folder form stands.
"optifine/cem/sheep.jem/readme.txt",
"optifine/cem/sheep/sheep.jem",
] {
let path = root.join("assets/minecraft").join(path);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, "{}").unwrap();
}
let files = jem_files(&root);
fs::remove_dir_all(&root).unwrap();
let paths = |paths: &[&str]| {
paths
.iter()
.map(|p| format!("assets/minecraft/{p}"))
.collect::<Vec<_>>()
};
assert_eq!(
files,
BTreeMap::from([
(
"cow".to_owned(),
paths(&["emf/cem/cow/cow.jem", "optifine/cem/cow.jem"])
),
(
"pig".to_owned(),
paths(&["optifine/cem/pig.jem", "optifine/cem/pig/pig.jem"])
),
("sheep".to_owned(), paths(&["optifine/cem/sheep/sheep.jem"])),
])
);
}
}
+1
View File
@@ -5,5 +5,6 @@
mod curves;
pub mod expr;
pub mod files;
pub mod json;
pub mod model;
Loaded 100 of 133 files, more files were not shown because too many files have changed in this diff. Show more