Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 d6424564e6 Dedicated server: item renderer without the client item definitions
A dedicated server's ItemRenderer kept what only drawing reads: the
~1,500 client item definitions (items/**/*.json), the special
renderers' entity models and the grass colormap, 1.4 MB of the 7.3 MB.
ItemRenderer::load_for_server keeps the prototypes (the stacks' default
components, which gameplay reads) and the item ids.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:03:50 +01:00
Ludovic MarieandClaude Opus 5.5 0871e93d49 Commands: cache what the registries take from the export, not the export
command_export kept the parsed command-registries.json (4.2 MB of
serde_json Values) for the whole run, though the registries take only
part of it. Cache Registries::from_export, the fields fill_from_export
fills (2.2 MB), and fill from it with fill_from_exported. A dedicated
server opens its world once: it keeps nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:03:50 +01:00
Ludovic MarieandClaude Opus 5.5 cdb64fbc9b LAN server: keep recipe files as compact JSON text, parse per book entry
ServerAdvancements kept all ~2,000 recipe files parsed (4.6 MB of
serde_json Values) for the server's whole run, only to build recipe book
entries when a player joins or unlocks a recipe. Keep each file as its
compact JSON text (0.57 MB) and parse the one an entry needs: parsing
every recipe takes 1.2 ms, the worst case of a full book on join.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 19:03:50 +01:00
Ludovic Marie c86e861455 Merge pull request #5696 from spidercraft/fsr-frame-generation-setting
🤖 Agent: main merged again (submit thread, leaf and entity culling). With frame generation on, a frame is submitted in place so its present thread presents it, and the hand stays out of the world pass so it draws over the real and the made frame; frameGeneration sits beside leafCulling in the settings format. verify green on 39737da; on playhorizon cargo check -p horizon-client --all-targets and the graphics (33), frame_gen (9) and gfx (176) tests pass. Thanks @spidercraft.
2026-11-01 17:59:39 +01:00
Ludovic Marie 11427c2960 Merge pull request #6097 from Akirabane/fix/6066-6067-command-block-feedback
🤖 Agent: Command block and command minecart admin lines go to operators through broadcastToAdmins (host and console included); Done's confirmation reaches only the editing player.
2026-11-01 17:55:43 +01:00
Ludovic Marie 1600ce6fa9 Merge pull request #6096 from k3yh/fix/tnt-selectors
🤖 Agent: Primed TNT is an entity for selectors, /tag, /data, /tp and /kill; it gets its entity id when primed.
2026-11-01 17:55:39 +01:00
Ludovic Marie 9ddc5e7498 Merge pull request #6095 from xFedeT/fix/singleplayer-owner
🤖 Agent: A world without singleplayer_uuid gives its owner their own (or a new) player file, never the newest other player's (PlayerList.loadPlayerData).
2026-11-01 17:55:35 +01:00
Ludovic Marie ff518b90c1 Merge pull request #6090 from HrafnaValkyrja/fix/mob-effects
🤖 Agent: Mobs act on instant health/damage, Resistance, Absorption (saved as AbsorptionAmount) and Slow Falling, as LivingEntity does.
2026-11-01 17:55:31 +01:00
Ludovic Marie 46ba16b36a Merge pull request #6089 from Xmilon/native/xp-orb-merge
🤖 Agent: Native hooks experience_orb_can_merge / experience_orb_merge around ExperienceOrb.scanForMerges and the spawn merge; no-op stubs without native mods.
2026-11-01 17:55:27 +01:00
Ludovic Marie 74672ea0e9 Merge pull request #6103 from horizon-rust/perf/lazy-block-tags
🤖 Agent: measured on playhorizon (alternated, THP off): idle anonymous −4.2 MB on every start; Done and 20-bot MSPT unchanged (3 rounds). pr_ready ✅ (368 horizon-world tests); clippy -D warnings and fmt clean on the Mac.
2026-11-01 17:55:23 +01:00
Ludovic Marie ac18068674 Merge pull request #5859 from doodlyyousuf/fix/5720-atlas-custom-directory
🤖 Agent: Review by Ludovic's AI agent (Claude). Extra atlas folders from blocks.json are packed (one walked list, 42e6769). Verify green. The parity failure was the runner's dead .horizon-prepare.lock link (2 s, before any harness), not this change; parity rerun.
2026-11-01 17:55:19 +01:00
Ludovic Marie ab0c403b75 Merge pull request #5737 from Stink-O/native/read-columns
🤖 Agent: Review by Ludovic's AI agent (Claude). Native column and surface-height reads on #5735, bounded by MAX_READ. Verify green on the whole stack. The parity failure was the runner's dead .horizon-prepare.lock link (2 s, before any harness), not this change; parity rerun.
2026-11-01 17:55:15 +01:00
Ludovic Marie 816ebe9925 Merge pull request #5735 from Stink-O/native/edit-blocks
🤖 Agent: Review by Ludovic's AI agent (Claude). Native bulk block edit API on #5733 (merged); each new block gets its own block entity (65ef100, tested). Verify green. The parity failure was the runner's dead .horizon-prepare.lock link (2 s, before any harness), not this change; parity rerun.
2026-11-01 17:55:11 +01:00
Ludovic Marie 58eaf93f13 Merge pull request #6086 from HrafnaValkyrja/fix/minecart-mob-rider
🤖 Agent: A cart with any passenger (mob, guest or the player) uses the occupied slowdown and speed scale (AbstractMinecart.getSlowdownFactor keys on isVehicle).
2026-11-01 17:50:16 +01:00
Ludovic Marie e076162fb3 Merge pull request #5811 from ASOwnerYT/fix/cauldron-bucket
🤖 Agent: Review by Ludovic's AI agent (Claude). A bucket used on a cauldron fills or empties the cauldron only, as vanilla's CauldronInteraction consumes the use; kacpherek found nothing more. Main merged in (lan_e2e check list: both sides kept, cauldron included). Verify green.
2026-11-01 17:50:11 +01:00
Ludovic Marie 9b391d7c80 Merge pull request #5645 from mariuslozo-boop/i18n/horizon-strings-c
🤖 Agent: #5644 merged in. pl_pl and uk_ua merged key by key with main's newer shader-option and split-profile strings (main's wording kept where both had one). All 9 lang files valid, no repeated keys, no key en_us lacks, placeholders match. On playhorizon: cargo check -p horizon-client --all-targets, lang/extras/options_ui tests pass. verify green on 8726619. Thanks @mariuslozo-boop.
2026-11-01 17:50:06 +01:00
Ludovic Marie eea324d489 Merge pull request #5644 from mariuslozo-boop/i18n/horizon-strings-b
🤖 Agent: #5643 (main merged) merged in; en_us.json union, valid JSON, no repeated keys.
2026-11-01 17:50:02 +01:00
Ludovic Marie 983eec0256 Merge pull request #5643 from mariuslozo-boop/i18n/horizon-strings-a
🤖 Agent: main merged again: its new Chat rows in Extras (Smooth chat, Message indicator and their header) read the language table too, en_us keys added. verify green on 79b92a3.
2026-11-01 17:49:58 +01:00
Ludovic Marie 88d3609268 Merge pull request #5708 from navinor/fix/attack-strength-on-attack
🤖 Agent: Review by Ludovic's AI agent (Claude). Attacking a boat, minecart or armor stand resets the attack strength, as Player.attack does for every entity. Main merged in; verify green.
2026-11-01 17:49:54 +01:00
k3yhandClaude Opus 5.5 2b01586564 Selectors find primed TNT
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 11:44:44 -05:00
Federico TaglianiandClaude Sonnet 5.5 be35f0cd18 A world without singleplayer_uuid gives its owner a new player, not another player's file
Level::read took the newest file of players/data when level.dat had no singleplayer_uuid, so
a world copied from a server gave the owner someone else's inventory and position, and the
owner's save then wrote into that player's file and named them as the world's owner.
PlayerList.loadPlayerData has no such lookup: the owner loads their own file, else starts new.
The fallback is gone, and save_owner names the owner's own profile in singleplayer_uuid
(MinecraftServer.saveAllChunks writes the integrated server's profile).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:44:11 +01:00
Ludovic Marie 1c47bf7fde Merge pull request #6078 from Xmilon/container-viewers-openers
🤖 Agent: Mod container viewers count the host's own player and copper golems too (ContainerOpenersCounter counts any ContainerUser), every tick from tick_world; spectators excluded.
2026-11-01 17:43:13 +01:00
Ludovic Marie 3fa0d50e2c Merge pull request #6071 from Corbonat/fix/enchantment-levels-6030
🤖 Agent: Enchantment levels outside 1..=255 drop that entry and keep the rest (ItemEnchantments codec); levels past X keep their untranslated key unless a language names them.
2026-11-01 17:43:08 +01:00
Akirabane cb24175ff3 Merge remote-tracking branch 'origin/main' into fix/6066-6067-command-block-feedback 2026-11-01 17:42:21 +01:00
AkirabaneandClaude Sonnet 5.5 fdd8066b77 Command blocks: the output reaches the operators, the editor's confirmation reaches the editor
A command block's or minecart's `chat.type.admin` line went to the server game's own chat, so no
operator saw it; it now joins the broadcastToAdmins queue (to every operator when
send_command_feedback, the world's host too, the console when log_admin_commands). A command that
sends its success unbroadcast (`sendSuccess(.., false)`) no longer reaches the operators either.
The confirmation of Done on a command block or a command minecart (advMode.setCommand.*) is sent
to the editing player only, through as_guest_messaged.

Fixes #6066
Fixes #6067

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:41:14 +01:00
Ludovic Marie 07c41af9db Merge pull request #6055 from JustKarkat/fix/ime-text-input
🤖 Agent: IME: the options searches, game rule boxes and block editor fields allow the input method, and the chat TextField reports its caret so the composition and candidate window show (TextInputManager.setTextInputArea).
2026-11-01 17:40:00 +01:00
Ludovic Marie 2691d2eb82 Merge pull request #6081 from doodlyyousuf/fix/5837-armor-stand-powder-snow
🤖 Agent: Leather-boot armor stands walk on powder snow; main merged in (main already lerps stands, its interpolation kept); verify passes.
2026-11-01 17:39:57 +01:00
Ludovic Marie 3dc0154745 Merge pull request #5733 from Stink-O/native/bulk-block-edits
🤖 Agent: Review by Ludovic's AI agent (Claude). Bulk block edits write whole sections; a resent column keeps its unchanged sections, takes the packet's light (arismoko rechecked it in game) and remeshes biome-only changes with all neighbours (kacpherek's point, aec1d41). Main merged in. Verify green.
2026-11-01 17:39:53 +01:00
HrafnaValkyrjaandClaude Opus 5.5 e5d3a0ec6c Mobs feel instant health and damage, Resistance, Absorption and Slow Falling
The mob effect tick and damage path never read the effect list. They now do, matching the real server.

Fixes #6087

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 11:37:06 -05:00
[email protected]andClaude Sonnet 5.5 962d50bffa feat(mods): native hooks to decide experience orb merging
native_experience_orb_can_merge and native_experience_orb_merge (cancellable)
at spawn and on the tick scan, plus native_set_experience_orb for value,
count and age.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:37:04 +01:00
Ludovic Marie abbfe4996b Merge pull request #5990 from xFedeT/fix/overworld-dragon
🤖 Agent: A dragon summoned outside the End flies (EnderDragon.aiStep without a fight); its drawing came with #5789; main merged in.
2026-11-01 17:34:20 +01:00
Ludovic Marie 484f7f74bb Merge pull request #5822 from xFedeT/fix/fox-item-pickup
🤖 Agent: Review by Ludovic's AI agent (Claude). Fox and dolphin item pickup follows vanilla (canTakeItem/pickUpItem rules); server-side only, nothing on the frame path. Main merged in; verify green.
2026-11-01 17:34:13 +01:00
Ludovic Marie 59773fcece Merge pull request #5791 from kacpherek/fix/arrows-through-portals
🤖 Agent: Review by Ludovic's AI agent (Claude). Arrows travel through nether and End portals as the vanilla Entity.handlePortal path does. Main merged in; verify green.
2026-11-01 17:34:08 +01:00
Ludovic Marie 67797d1884 Merge pull request #6063 from oskal675-design/fix/piston-server-updates
🤖 Agent: /tick step broadcasts the command's count before the tick's block events, joins still get the remaining ticks (ServerTickRateManager.updateStepTicks / updateJoiningPlayer).
2026-11-01 17:32:50 +01:00
Ludovic Marie ca15b06a8c Merge pull request #6062 from doodlyyousuf/fix/5248-nixos-native-mod-build
🤖 Agent: On NixOS the native-mod build check names the launcher's shell.nix (now with udev and ALSA) instead of a package manager.
2026-11-01 17:32:44 +01:00
Ludovic Marie 856de4c13d Merge pull request #6016 from kacpherek/fix/gpu-device-loss
🤖 Agent: Device loss recovery; main merged in and the rebuilt renderer now gets the Translucent Batching arguments build_renderer takes since #5984 (the LAND-STOP).
2026-11-01 17:32:38 +01:00
Ludovic Marie dc262543a6 Merge pull request #6035 from mariuslozo-boop/tools/build-times-compare
🤖 Agent: tools/build_times.py compare, on top of #6034; tooling only, skip-release.
2026-11-01 17:32:32 +01:00
Ludovic Marie d2a39539be Merge pull request #6034 from mariuslozo-boop/tools/build-times
🤖 Agent: tools/build_times.py run: cold, no-op and incremental build times per crate in its own target folder, leaving the tree as it found it (tested); tooling only, skip-release.
2026-11-01 17:32:27 +01:00
Ludovic Marie 4684962b73 Merge pull request #6042 from oskal675-design/fix/piston-tick-step
🤖 Agent: A frozen level ticks no client block entities (Level.tickBlockEntities behind runsNormally): a /tick step advances a moving piston once; e2e against a vanilla server.
2026-11-01 17:32:21 +01:00
Ludovic Marie 85a65f9469 Merge pull request #6054 from mariuslozo-boop/fix/windows-cpu-name
🤖 Agent: Windows CPU brand from the registry (ProcessorNameString) for bench reports and telemetry, cached once, reg run with CREATE_NO_WINDOW; one reader shared with crash reports.
2026-11-01 17:32:16 +01:00
Ludovic Marie 7a5563f90f Merge pull request #6046 from doodlyyousuf/fix/tnt-bubble-column
🤖 Agent: Primed TNT runs applyEffectsFromBlocks' bubble column push after its move and before drag, as items and mobs already do; test with a soul sand column.
2026-11-01 17:32:09 +01:00
Ludovic Marie 810da289d6 Merge pull request #5981 from CayMac97/fix/png-ignore-checksums-smp-packs
🤖 Agent: PNG chunk checksums ignored as stb_image does, PostChain effect textures included (fix pushed for kacpherek's review).
2026-11-01 17:32:04 +01:00
Ludovic Marie ac29461e82 Merge pull request #5934 from doodlyyousuf/fix/5836-powder-snow-walk-particles
🤖 Agent: Powder snow snowflake now emitted on the server copy and sent to nearby players (PowderSnowBlock.entityInside, count 1, ALTERNATIVE randomization), the client copy no longer double-spawns; I merged main and dropped this branch's handleParticleEvent copy in favour of main's spread(); verify green.
2026-11-01 17:31:58 +01:00
Ludovic Marie 7e87aefe15 Merge pull request #5885 from doodlyyousuf/fix/5842-shulker-bullet-trail
🤖 Agent: Shulker bullet END_ROD trail on the client (ShulkerBullet.tick: two in the 0.3125 box), client visual only; I merged main (kept main's remote_projectile_power beside it), verify green on the merge.
2026-11-01 17:31:53 +01:00
Ludovic MarieandClaude Opus 5.5 1affe879c9 Merge main into #6081: main already lerps armor stands (keeps its interpolation, drops the remote_objects reset that would undo it); leather boots still walk on powder snow; tests pass BlockFacts to tick
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:26:27 +01:00
Ludovic MarieandClaude Opus 5.5 39737da855 Merge main into #5696 (entity culling modes)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:26:19 +01:00
HrafnaValkyrjaandClaude Opus 5.5 26dc0d815f Minecarts with a mob or guest aboard keep their speed
Only the local player counted as a rider, so other carts took the empty-cart drag. A guest or a mob passenger now counts too, as isVehicle does.

Fixes #6085

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 11:25:20 -05:00
Ludovic MarieandClaude Opus 5.5 1252c14927 Merge main into #5990 (#5789 already draws the dragon in every dimension; this keeps its flight outside the End)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:23:13 +01:00
DoodlyyousufandGrok 4.6 3b68c85316 Walk leather-boot armor stands on powder snow and lerp their falls
Co-Authored-By: Grok 4.6 <[email protected]>
2026-11-01 16:20:28 +00:00
Ludovic Marie f6512a3f49 Merge pull request #5637 from mariuslozo-boop/feat/native-knockback-hook
🤖 Agent: verify and parity are green on levzzz5154's main merge, levzzz5154 approved it, and arismoko's in-game sample showed the hook changing and canceling the push. Queued for merge, thanks @mariuslozo-boop.
2026-11-01 17:20:15 +01:00
Ludovic Marie bc59771299 Merge #5735 into #5737
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/game/block_edits.rs
2026-11-01 17:20:05 +01:00
Ludovic MarieandClaude Opus 5.5 65ef1000fa Native bulk edits: each new block gets its own block entity
The constructor ran once per block type and every position took a clone, so a
mod's per-instance state (a serial, a seed) was shared. Vanilla calls
EntityBlock.newBlockEntity for each position; a block that makes none is still
asked once (arismoko's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:19:43 +01:00
Ludovic MarieandClaude Opus 5.5 ff91172d22 Merge #5733 into #5735
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:18:07 +01:00
Ludovic MarieandClaude Opus 5.5 aec1d411f7 Streaming: a resent column's new biomes remesh the sections above and below too
A block's tint can sample the biome quart of the section next to it, so a
biome-only resend of one section must remesh its vertical neighbours, as a
light update does (kacpherek's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:18:00 +01:00
Ludovic Marie f4d7d4d59d Merge pull request #5928 from Teiwazik/shader/vertex
🤖 Agent: shader API stack with main c9d474d merged in: fmt, clippy -D warnings, client-render + shader-api tests green at 54cbbadbb.
2026-11-01 17:17:33 +01:00
Ludovic Marie 0aa6a87d5e Merge pull request #5920 from Teiwazik/shader/material-blocks
🤖 Agent: shader API stack with main c9d474d merged in: fmt, clippy -D warnings, client-render + shader-api tests green at 54cbbadbb.
2026-11-01 17:17:28 +01:00
[email protected]andClaude Sonnet 5.5 74a8a866f8 fix(mods): count the host's own player and golems as container viewers
The count now runs in tick_world, so it also runs in-frame and without LAN.

Fixes #6077

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:16:10 +01:00
Ludovic Marie 45e95666d2 Merge main into #5811
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	tools/lan_e2e.py
2026-11-01 17:15:17 +01:00
Ludovic MarieandClaude Opus 5.5 f6d35e00e2 Merge #5735 (main merged) into #5737
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:13:48 +01:00
Ludovic MarieandClaude Opus 5.5 75e2c36a9d Merge #5733 (main merged) into #5735
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:13:41 +01:00
Ludovic Marie 74404c28fc Merge main into #5733
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client/src/lib.rs
2026-11-01 17:13:40 +01:00
Ludovic Marie fcbfd000ee Merge pull request #5790 from kacpherek/fix/glider-any-slot
🤖 Agent: Review by Ludovic's AI agent (Claude). Dispensing an elytra onto a mob now picks the slot with the dispenser's own random, as vanilla's Util.getRandom does; the level random was used before (fixed on the branch). The earlier parity failure was command_exec (failing on main since #5646, experience orb case) plus lunge_motion (a known flake), neither touched here. Verify green on this head.
2026-11-01 17:12:57 +01:00
Ludovic Marie 6bfd219e30 Merge pull request #6059 from oskal675-design/fix/boat-ice-reboard-spin
🤖 Agent: Boat copy the local player does not drive drops its cached velocity and spin (AbstractBoat.tick); main merged in again (lan_e2e list).
2026-11-01 17:12:53 +01:00
Ludovic Marie 79e9d1b044 Merge pull request #6075 from xFedeT/fix/tnt-portal-travel
🤖 Agent: Primed TNT goes through nether portals like other entities (Entity.handlePortal), saved with its fuse and portal cooldown.
2026-11-01 17:12:49 +01:00
Ludovic Marie d6ce584b6a Merge pull request #6070 from HrafnaValkyrja/fix/explosions-push-all
🤖 Agent: Explosions break and push boats and push falling blocks (VehicleEntity.hurtServer, pushFromExplosion); a mob's blast spares vehicles with mob griefing off; wind charges take no push.
2026-11-01 17:12:45 +01:00
Ludovic MarieandClaude Opus 5.5 516927b342 Merge main into #6059 (lan_e2e: boat_ice_reboard beside piglin_offspring)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:11:22 +01:00
Ludovic Marie 21936c6298 Merge pull request #6045 from HrafnaValkyrja/fix/wind-pushes-all
🤖 Agent: Wind bursts push orbs, falling blocks, boats and minecarts as vanilla; main merged in again (lan_e2e beside lightning-box).
2026-11-01 17:11:06 +01:00
Ludovic MarieandClaude Opus 5.5 87266190f4 Merge #5644 (main merged) into #5645: main's Polish and Ukrainian shader-option strings kept
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:09:14 +01:00
Ludovic MarieandClaude Opus 5.5 f4283f7c82 Merge main into #6045 (lan_e2e: wind-bystanders beside lightning-box)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:08:55 +01:00
Ludovic MarieandClaude Opus 5.5 75efcb360a Merge #5643 (main merged) into #5644
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:08:50 +01:00
Federico TaglianiandClaude Sonnet 5.5 c25499d504 Primed TNT goes through nether portals
Entity.handlePortal applies to any entity that canUsePortal; primed TNT was not in the list of
what Horizon sends through (items, mobs, boats, minecarts). It now is: swept through the
portal box (0.98 across), saved as its tag (fuse, motion), and loaded at the exit with its
PortalCooldown, which the TNT loader now remembers so it does not go straight back. A TNT that
carries a mob stays.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 17:08:44 +01:00
Ludovic Marie 54cbbadbb7 Merge #5920's branch (main after #5900) into shader/vertex 2026-11-01 17:08:38 +01:00
Ludovic Marie 6472b1af4c Merge main into shader/material-blocks (after #5900) 2026-11-01 17:08:38 +01:00
Ludovic MarieandClaude Opus 5.5 79b92a3f8f Merge main into #5643: the new Chat rows read the language table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:08:35 +01:00
Ludovic MarieandClaude Opus 5.5 3073eed985 Merge main into #5696: frame generation beside the submit thread
Frames with frame generation are submitted in place (its present thread
presents them), and the hand stays out of the world pass so it draws over
the real and the made frame.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 17:02:51 +01:00
HrafnaValkyrjaandClaude Opus 5.5 9a696bd74b Explosions push boats and falling blocks, not wind charges
Boats and falling blocks take the blast's push as vanilla's hurtEntities does. Wind charges no longer do, as in vanilla.

Fixes #6069

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 11:02:12 -05:00
Ludovic MarieandClaude Opus 5.5 a4742538b2 Merge main into #5790
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:58:21 +01:00
Ludovic MarieandClaude Opus 5.5 5501103883 Bind each feature block tag the first time it is read
FeatureBlockBindings::bind_tags built every block tag's state set up
front: vanilla's 300 block tags hold 406,779 states (blocks_motion and
mineable/pickaxe ~30,000 each), and world generation reads a few dozen of
them. BoundTags keeps each tag's block names and builds its set the first
time it is read (OnceLock); the blocks are still checked when the tags are
bound, so an unknown block fails as before and a later read cannot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:56:35 +01:00
Ludovic MarieandClaude Opus 5.5 c2ea13fc7c Merge main into #6045 (lan_e2e: wind-bystanders beside boat_submerged)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:55:47 +01:00
Ludovic MarieandClaude Opus 5.5 52da60975d Merge main into #6059 (lan_e2e: boat_ice_reboard beside blast-projectiles)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:50:26 +01:00
Ludovic Marie 31d53c8b47 Merge #5920's branch (main after #5926) into shader/vertex 2026-11-01 16:49:03 +01:00
Ludovic Marie b0fee0d30d Merge main into shader/material-blocks (after #5926)
# Conflicts:
#	crates/horizon-client-render/src/mesher.rs
#	crates/horizon-client-render/src/mesher/reuse_tests.rs
#	crates/horizon-client-render/src/streaming.rs
#	crates/horizon-client/examples/chunk_load_bench.rs
#	crates/horizon-client/examples/probe/section_mesh_fixture.rs
#	crates/horizon-client/tests/streaming_meshes.rs
#	crates/horizon-client/tests/tall_column.rs
2026-11-01 16:49:02 +01:00
Ludovic MarieandClaude Opus 5.5 b84758065f Merge main into #5696: Frame Generation beside main's Leaf Culling setting
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:43:52 +01:00
CorbonatandCodex gpt-6.1-sol ac5f51226b Fix persistent enchantment level decoding and names
Co-Authored-By: Codex gpt-6.1-sol <[email protected]>
2026-11-01 18:42:25 +03:00
doodlyyousufandhorizon-agent 07002b127b docs(launcher): say the NixOS shell covers native mod builds
Co-Authored-By: horizon-agent (GLM 5.2) <[email protected]>
2026-11-01 15:41:57 +00:00
doodlyyousufandhorizon-agent 79e783ec84 fix(launcher): name the NixOS shell instead of a package manager
On NixOS pkg-config only sees packages listed by a shell's setup hooks, so
installing them in environment.systemPackages leaves the check failing, and
the APT advice it fell through to did not apply. Point at the launcher's own
shell.nix, which now carries those packages.

Co-Authored-By: horizon-agent (GLM 5.2) <[email protected]>
2026-11-01 15:41:57 +00:00
oskal675-designandCodex GPT-6.1 58a3d74122 Preserve tick-step requests and send them before piston events
Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 20:41:54 +05:00
doodlyyousufandhorizon-agent 6b1f5be9ec fix(launcher): give the NixOS shell the ALSA and libudev files native mods need
The shell provided GPUI's libraries but not the two native mods' build looks up
with pkg-config, and the client it produces needs libudev at runtime.

Fixes #5248

Co-Authored-By: horizon-agent (GLM 5.2) <[email protected]>
2026-11-01 15:41:50 +00:00
HrafnaValkyrjaandClaude Opus 5.5 5c9bc076ac Merge origin/main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 10:40:28 -05:00
Ludovic MarieandClaude Opus 5.5 e37e2f163f Device recovery: pass translucent batching to the rebuilt renderer
build_renderer takes the Translucent Batching setting and the picked pack's
flat drawing since #5984.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:31:23 +01:00
Ludovic Marie b773fa45c1 Merge remote-tracking branch 'origin/main' into HEAD 2026-11-01 16:31:05 +01:00
TeiwazikandClaude Opus 5.5 51643e956a Merge the branch below, with main
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 16:28:04 +01:00
JustKarkatandJustKarkat's AI agent c481ffedf2 IME: the chat box, the options searches and the remaining boxes take composed text
#4347 let the input method in, but Game::text_field_focused left four fields
out of the window's gate, so the OS was never asked for an input method while
they had the focus: the language list's search, the settings search, the
resource pack screen's search and a game master block's boxes. The title
screens' game-rule boxes were out too: focused_field is None while the rules
screen is open, and has_caret_field asks it.

The chat box — where the report typed — is a screen_draw::TextField, and that
box never reported its caret, so flush_ime had no area: the composition was
not drawn and set_ime_cursor_area was never called, which leaves typing blind
until the commit even with #4347 merged. TextField now reports the caret where
it draws the cursor, as EditBox and MultiLineEditBox already do.

Co-Authored-By: JustKarkat's AI agent (ZCode) <[email protected]>
2026-11-01 09:24:06 -06:00
oskal675-designandCodex GPT-6 326c36879b Clear cached boat motion when the client stops driving
Match AbstractBoat.tick by resetting velocity and angular velocity on non-authoritative client copies. Cover dismount/reboard ownership transitions with a real vanilla tick oracle and the actual headless Game ticker, plus ice reboarding for host and guest.

Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 20:22:46 +05:00
Ludovic MarieandClaude Opus 5.5 8bde063ed6 Effect textures ignore PNG chunk checksums too
PostChain's textures/effect inputs had their own png::Decoder with CRC
checks on (kacpherek's review).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:22:40 +01:00
TeiwazikandClaude Opus 5.5 c80ab3bbcd Merge the branch below, with main
shader_packs.rs: main's `picked_pack`, owned here since `set_translucent_layers` borrows the game.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 16:17:42 +01:00
MariuslozoandClaude Opus 5.5 ebdd6a9d9a Windows CPU name: read the registry once, test it against the registry (#6032)
windows_cpu ran `reg` on every call: twice per Hardware::detect (its own
cpu() and System::detect's), and again for every telemetry item. The name
does not change while the game runs, so it is read once, as the module
says of its other command probes.

System's cpu() no longer runs `reg` on systems that are neither Linux,
macOS nor Windows.

The Windows test compared the name only against the family/model format,
so a parser that kept only part of the name would have passed. It now
checks the exact value against the registry as PowerShell reads it, and
expects PROCESSOR_IDENTIFIER where the registry has no name (some VMs),
instead of failing there. Its comment no longer says a CI runner runs it:
no Windows job tests this crate. The parser test gains tab columns and a
name with runs of inner spaces; a comment that said padding is kept now
says it is trimmed.

docs: benchmark-report.md and telemetry.md say when cpu is
PROCESSOR_IDENTIFIER.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 16:17:09 +01:00
Ludovic MarieandClaude Opus 5.5 1ed2aa47d4 Merge main into #5696: frameGeneration in main's serialize format beside translucentBatching
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:17:08 +01:00
HrafnaValkyrjaandClaude Opus 5.5 5f52402370 Wind bursts push orbs, falling blocks, boats and minecarts
They run ServerExplosion.hurtEntities like any other entity, so they take the same push. Dropped items are only pushed by a mace burst.

Fixes #6044

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_018zuDTsnyHkP28bzn3u7S3m
2026-11-01 10:15:34 -05:00
Muhammad AsifandOhMyAgent f6dc590d3e Lift primed TNT in soul sand bubble columns
PrimedTnt.tick updated its fluid immersion but never ran
Entity.applyEffectsFromBlocks, so BubbleColumnBlock.entityInside never pushed
it (handleOnInsideBubbleColumn) and a primed TNT dropped into a soul sand
column sank.

The tick now asks motion::bubble_column_push for the columns its box touches,
after the move and before the drag, as items, mobs and armor stands already do.

Closes #6008.

Co-Authored-By: OhMyAgent (grok-4.6) <[email protected]>
2026-11-01 20:13:42 +05:00
oskal675-designandCodex GPT-6.1 d1787596da Freeze client block entities between stepped world ticks
Gate client block-entity tickers on runsNormally, keeping moving pistons alive until subsequent server steps. Verify18 snapshots against an actual vanilla server, including extension, retraction and unfreeze controls. Part of #5977; server notification flags remain a separate follow-up.

Co-Authored-By: Codex GPT-6.1 <[email protected]>
2026-11-01 20:08:40 +05:00
Ludovic Marie e111cb947a Merge #5644 (main merged) into #5645 2026-11-01 16:07:25 +01:00
Ludovic Marie e4bbe6055b Merge #5643 (main merged) into #5644 2026-11-01 16:07:16 +01:00
Ludovic MarieandClaude Opus 5.5 5d01df7504 Merge main into #5643: Hide block-break particles, UI sound reserve and their headers read the language table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 16:07:15 +01:00
Muhammad AsifandOhMyAgent a2ddc9f681 Lift primed TNT in soul sand bubble columns
PrimedTnt.tick updated its fluid immersion but never ran
Entity.applyEffectsFromBlocks, so a bubble column's entityInside push never
reached it. A primed TNT dropped into a soul sand column sank.

The tick now asks motion::bubble_column_push for the columns its box touches
after the move and before the drag, as items and mobs already do.

Closes #6008.

Co-Authored-By: OhMyAgent (grok-4.6) <[email protected]>
2026-11-01 19:59:55 +05:00
Ludovic MarieandClaude Opus 5.5 4ea4cd559b Frame generation: its post draws pass no pass timing (main's finish_with takes one)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:56:05 +01:00
MariuslozoandClaude Opus 5.5 2ae7c880c4 tools: build_times.py compare puts two reports side by side (#5794)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 15:52:35 +01:00
MariuslozoandClaude Opus 5.5 e30ff91de7 docs: build-times.md, how to run tools/build_times.py and the first numbers (#5794)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 15:52:34 +01:00
MariuslozoandClaude Opus 5.5 c65cb2b929 tools: build_times.py measures cold and incremental builds per crate (#5794)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 15:52:34 +01:00
MariuslozoandClaude Opus 5.5 7594506012 Windows: benchmark reports and telemetry name the CPU by its brand (#6032)
hardware.cpu in every benchmark report and the cpu of telemetry items read
PROCESSOR_IDENTIFIER on Windows, which always holds family, model and stepping
(AMD64 Family 25 Model 97 Stepping 2, AuthenticAMD), never the name. The
telemetry server groups its benchmark summary by that string, so chips of one
family would have shared a row.

horizon_telemetry::system::windows_cpu reads ProcessorNameString from the
registry (reg query, hidden window, trimmed) and falls back to
PROCESSOR_IDENTIFIER. horizon-bench, horizon-telemetry and the crash report
use it; the crash report's own copy of the parser is gone and its test moved
next to the shared one, with more cases (CRLF, padding, missing, garbage).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 15:50:55 +01:00
Ludovic MarieandClaude Opus 5.5 03c7c60808 Merge main into #5696: draw_overlay keeps #5887's pass timings (the real frame's only)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:34:59 +01:00
KacpherekandCodex GPT-6 c92f0357c9 Merge upstream main into GPU recovery fix
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 15:19:25 +01:00
Ludovic MarieandClaude Opus 5.5 a02d561d49 Merge main into #5791
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:59:51 +01:00
Federico TaglianiandClaude Sonnet 5.5 3f6221b268 An ender dragon summoned outside the End is drawn and flies
The dragon was drawn and ticked only in the End (its fight), so /summon ender_dragon in the
overworld put an invisible, motionless dragon there that only made sounds. EnderDragon.aiStep
does not depend on a fight (ServerLevel.getDragonFight is null outside the End): draw the
level's dragon in any dimension, and tick it outside the End without the fight.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 14:59:48 +01:00
Ludovic MarieandClaude Opus 5.5 337c3c2bb4 Merge main into #5822
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:58:01 +01:00
Ludovic MarieandClaude Opus 5.5 42e6769488 Atlas: one list of walked texture folders, for the walk and its skip
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:57:32 +01:00
Ludovic MarieandClaude Opus 5.5 3244c41556 Merge main into #5859
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:57:24 +01:00
Ludovic Marie c865a354f2 Merge #5644 (main merged) into #5645 2026-11-01 14:57:04 +01:00
Ludovic Marie 3d26d4ccfa Merge #5643 (main merged) into #5644
# Conflicts:
#	crates/horizon-client/assets/horizon/lang/en_us.json
2026-11-01 14:57:03 +01:00
Ludovic MarieandClaude Opus 5.5 43f96dda2b Merge main into #5811
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:56:56 +01:00
Ludovic MarieandClaude Opus 5.5 aec9421cf3 Merge main into #5643: the Extras page's Splitscreen switch gets its language keys
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:56:43 +01:00
Ludovic MarieandClaude Opus 5.5 ba68d1505e Glide wear: pick the worn glider with the entity's own random
Util.getRandom(slotsWithGliders, this.random) draws from the entity's random,
so the pick must not take a draw from the level's stream.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:55:31 +01:00
Ludovic MarieandClaude Opus 5.5 9e1f8431fa Merge main into #5790
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:55:13 +01:00
Ludovic MarieandClaude Opus 5.5 536b8462a7 Merge main into #5708
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:55:00 +01:00
Ludovic MarieandClaude Opus 5.5 58055f7214 Merge #5735 (with #5733 and main) into #5737
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:52:04 +01:00
CayMac97 065e7b8efb Ignore PNG chunk checksum errors in sprites, fonts and textures
Certain server resource packs (notably on SMP servers like HugoSMP using custom font and GUI sheets) contain PNG textures with stripped or corrupted CRC-32 chunk checksums.

Vanilla Java Edition (26.3) reads images via STB Image (NativeImage.read), which does not enforce or fail on PNG chunk CRC-32 errors. Setting decoder.ignore_checksums(true) matches vanilla behavior and allows custom font sheets, emojis, scoreboard icons and sprites to decode properly.

Fixes #5979.
2026-11-01 14:51:54 +01:00
Ludovic MarieandClaude Opus 5.5 8ce6505397 Merge #5733 (with main) into #5735
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:50:06 +01:00
Ludovic MarieandClaude Opus 5.5 c507ec4730 Resent columns: a section with the same blocks takes the packet's new light
keep_unchanged_sections kept the old section whole when its blocks matched, so a
server that resent a column with new light and no separate light update left a
resident client with stale light (arismoko's review on #5733). Vanilla's
handleLevelChunkWithLight always applies the packet's light. Such a section now
takes the packet's light and remeshes with its neighbours (light_updated); a
section whose light is unchanged is still shared with the old column.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:48:51 +01:00
Ludovic MarieandClaude Opus 5.5 d4da2469cc Merge main into #5733
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:48:12 +01:00
KacpherekandCodex GPT-6 d3fdb8d19c Recover the renderer after graphics device loss
Co-Authored-By: Codex GPT-6 <[email protected]>
2026-11-01 14:47:11 +01:00
Ludovic MarieandClaude Opus 5.5 45161095d6 Merge main into fix/5836-powder-snow-walk-particles
main's spread() already applies RandomizationType ALTERNATIVE in
handleParticleEvent, so this branch's copy of it is dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:40:35 +01:00
Ludovic MarieandClaude Opus 5.5 596b47850d Merge main into fix/5842-shulker-bullet-trail
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:39:24 +01:00
Ludovic MarieandClaude Opus 5.5 a94150a97c Merge main into #5696: frameGeneration beside main's texture budget and dithered mips
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 14:32:37 +01:00
Ludovic Marie 47b5a77bce Merge #5928 into the shader API union 2026-11-01 14:27:09 +01:00
Ludovic Marie 2947ff96b2 Merge #5920 into the shader API union 2026-11-01 14:27:09 +01:00
TeiwazikandClaude Opus 5.5 9f3a88a41d Merge main: the lint fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 14:18:33 +01:00
TeiwazikandClaude Opus 5.5 c41d1ee7b4 Merge main: the lint fix
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 14:08:44 +01:00
DoodlyyousufandGrok 4.6 47f85029fe Send powder-snow walk snowflakes from the server
PowderSnowBlock.entityInside's SNOWFLAKE particles only ran on the
in-frame Game. The server copy skipped them, and guest ticks dropped
the event, so the window never drew them.

Emit them on server_block_damage, send ALTERNATIVE packets to nearby
players, and skip the client-side copy.

Fixes #5836

Co-Authored-By: Grok 4.6 <[email protected]>
2026-11-01 12:49:27 +00:00
TeiwazikandClaude Opus 5.5 724d94d0dd Shader API: the [vertex] stage, where corners of terrain are drawn
fn vertex(input: VertexInput) -> vec3<f32> moves terrain in the world and the shadow map, for
waving plants and rolling water. It needs a [material] and shares its module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 13:39:42 +01:00
TeiwazikandClaude Opus 5.5 dc4a9f2494 Shader API: [[materials]] blocks and translucent
An id can name blocks as well as sprites, and can move its sprites into the translucent pass. A
sprite pattern beats a block on the same sprite. Sections mesh again when the list changes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01EBqm1v5zTsDGwuZF6XKFjL
2026-11-01 13:18:39 +01:00
Federico Tagliani c03ef707ed Merge remote-tracking branch 'origin/main' into fix/fox-item-pickup 2026-11-01 12:47:41 +01:00
DoodlyyousufandGrok 4.6 bcf52293d9 Spawn a shulker bullet's END_ROD trail on the client
Vanilla ShulkerBullet.tick draws two END_ROD particles in its 0.3125
box every client tick. Horizon moved the bullet and drew the model,
but never spawned the trail, so the sparkle behind it was missing.

Fixes #5842

Co-Authored-By: Grok 4.6 <[email protected]>
2026-11-01 11:08:18 +00:00
levzzz 4b57957180 Merge remote-tracking branch 'hr/main' into pr-5637 2026-11-01 15:48:10 +05:00
Stink-OandClaude Opus 5.5 3073cb461e fix(mods): column reads answer why they read nothing, and read less a call
`native_surface_heights` and `native_read_columns` return `Result<_, String>` as `native_edit_blocks` does, so a mod
tells "wrong level" from "ask for less". A read is at most 1,048,576 blocks (was 16,777,216: it runs on the server
tick), a height query at most 65,536 columns, and an empty span (`below + above` of 0) is refused. The docs say the
tints are the biome's own at the column's top, not blended as the game draws them.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 10:43:03 +00:00
levzzz 1d34a59372 Merge remote-tracking branch 'hr/main' into pr-5733 2026-11-01 15:42:22 +05:00
DoodlyyousufandGrok 4.6 c50935983a Pack extra atlas directories a pack adds to blocks.json
HudAtlas walked only a fixed set of texture folders, then skipped
minecraft: file sources whose sprite name matched the texture path,
assuming the walk had packed them. Pack folders such as custom/ never
got a sprite, so item models that used them were invisible.

Keep the have.contains skip, and only treat matching File origins as
already packed when they sit under a walked folder.

Fixes #5720

Co-Authored-By: Grok 4.6 <[email protected]>
2026-11-01 10:42:19 +00:00
levzzzandClaude Opus 5.5 a34c63b88c Bulk edits: a resent column keeps the packet's light where it changed; the anticheat and the far ring hear of bulk chunks
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 15:32:03 +05:00
levzzz 7a2ea363ce Merge remote-tracking branch 'hr/main' into pr-5637
# Conflicts:
#	crates/horizon-client/src/game.rs
#	crates/horizon-client/src/game/creatures.rs
#	crates/horizon-client/src/game/explosion.rs
#	crates/horizon-client/src/game/lan_pvp.rs
#	crates/horizon-client/src/game/native_hooks.rs
#	crates/horizon-client/src/game/shield.rs
2026-11-01 15:17:03 +05:00
levzzz f27e5c5796 Merge remote-tracking branch 'hr/main' into pr-5733 2026-11-01 15:10:25 +05:00
Federico TaglianiandClaude Sonnet 5.5 0a1dd1fe1f Foxes and dolphins pick up items as vanilla
Fox: FoxSearchForItemsGoal walks to the first dropped item within 8 blocks while
the mouth is empty (goal 11, after the stroll), and Fox.pickUpItem takes it:
the extra items of the stack drop at its feet, what it held is spat out ahead
(entity.fox.spit, pickup delay 40, thrown by the fox), it holds one item and its
eating clock restarts. Fox.canHoldItem swaps non-food for food once it has eaten.

Dolphin: MoveToItemGoal (priority 7) swims to an item floating in the water within
8 blocks with entity.dolphin.play, PlayWithItemsGoal (8) throws what it holds, and
Dolphin.pickUpItem takes a whole stack into an empty mouth. ItemGoal.drop throws
from the eye, forward at 0.3, with a pickup delay of 40.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 10:24:43 +01:00
Stink-OandClaude Opus 5.5 b808a05dd9 fix(mods): column reads are bounded and read each chunk's own height
`below + above` could overflow and `columns.len() * per` could ask for
gigabytes: a read past the level's height or 16,777,216 blocks now answers
None, reading nothing. Blocks past the top of the column read as air from
the chunk's own layout rather than the vanilla -64..320, the always-0 `tag`
and the unused `NativeSurfaceHeights` (leftovers of an async API) are gone,
the reads are tested through free functions on `World`, fork names are out
of the docs, and docs/native-mods.md describes the helpers.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:37:30 +00:00
Stink-O ce05d13f46 Merge branch 'native/edit-blocks' into native/read-columns
# Conflicts:
#	crates/horizon-client/src/game/block_edits.rs
2026-11-01 08:37:30 +00:00
Stink-OandClaude Opus 5.5 c43496c5e8 fix(mods): one entry lookup a block entity name (clippy map_entry)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:30:41 +00:00
Stink-OandClaude Opus 5.5 41ae4e9ceb fix(mods): bulk edits check every run first and make block entities as native_set_block does
A run naming a palette entry or filter past the end panicked mid-edit, and a
filter index past u16::MAX wrapped: every run is now checked before anything
changes, and the edit answers Err. Palette entries and filters with NBT are
refused rather than silently dropped. New blocks get their block entity from
`new_block_entity`, so a mod block's own (API 15) is made, as
`native_set_block` does. The heightmap walk stops at the chunk's own bottom,
`Game::block_matches` calls `block_input_matches` instead of repeating it,
fork names are out of the docs, and docs/native-mods.md describes the helper.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:26:19 +00:00
Stink-O 78d6f69e90 Merge branch 'native/bulk-block-edits' into native/edit-blocks 2026-11-01 08:24:18 +00:00
Stink-OandClaude Opus 5.5 53b34ed9b6 fix(world): bulk edits follow each chunk's layout; resent columns keep new biomes
`edit_runs` clamped runs to the vanilla column (-64..320), so a data pack's
taller dimension lost the blocks above or below it: each chunk's own
`min_y`/sections now bound its runs. `replace_remote` kept an unchanged
section whole, biomes included, so a resend with new biomes under the same
blocks kept the old ones: those sections now take the new biomes and
remesh. `edit_runs`'s doc lists what it does not record per block (block
entities, watched writes, block_log, path_writes, light_edits).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:08:58 +00:00
asownerytandClaude Opus 5.5 51b8552e70 Buckets used on a cauldron no longer pour beside it too
A client of the server (single player's window, a LAN guest, a dedicated
server's player) asked only horizon-world's useItemOn whether the clicked
block takes the item, and that has no cauldrons. So after UseItemOn the
click went on to UseItem: the client predicted the bucket's own pour beside
the cauldron (a water or lava block that showed until the server answered,
and the empty sound a second time), and in creative the server poured the
bucket a second time beside the cauldron, where it stayed.

Like vanilla's client (AbstractCauldronBlock.useItemOn, CauldronInteractions,
which act only server side), the client now answers the cauldron's
interaction without acting, ends the click on SUCCESS or CONSUME and swings
on SUCCESS. A sneak with an item in hand skips the cauldron on both sides
(Player.isSecondaryUseActive). lan_e2e gets a cauldron check, which fails on
upstream/main and passes with this.

Fixes #5294.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 21:04:53 +13:00
Stink-O 37d4b92459 Merge main into #5733 2026-11-01 08:00:14 +00:00
Kacpherek 2f31408502 Let arrows go through nether and End portals as vanilla does
Skeleton arrows passed through portal blocks and kept flying. The portal
sweep in find_entity_portals covered only items, mobs, empty boats and
minecarts, so an arrow never got a portal state and never changed
dimension.

Vanilla does not exempt projectiles: Entity.canUsePortal is true for a
living arrow that is not a passenger, so NetherPortalBlock.entityInside
and EndPortalBlock.entityInside (NetherPortalBlock.java,
EndPortalBlock.java) call setAsInsidePortal on it. AbstractArrow.tick
(AbstractArrow.java) then hands it to handlePortal, and the transition
time is 0 for anything that is not a player
(NetherPortalBlock.getPortalTransitionTime). A stuck arrow skips its move
while in the ground, so it enters no portal.

Arrows now join the portal sweep from where they stood at the start of
their tick, which also covers an arrow shot this tick. An arrow that went
through is saved with arrow_to_nbt as a waiting arrival, like an item or
mob. On load it restores its portal cooldown, so it does not go straight
back through the portal it came out of. Tridents are unchanged.
2026-11-01 08:42:58 +01:00
Kacpherek 6aa7f4c227 Glide with a glider in any worn armour slot, or a patched one
Horizon let a player glide only when the chest slot held a glider from
its own item definition. A glider worn in the helmet, leggings or boots
did not count, and neither did a glider component a server patched onto
the stack, which Paper plugins do. The glide's wear also always hit the
chest piece, so a helmet glider would wear down the chestplate.

Vanilla's LivingEntity.canGlide checks every slot: canGlideUsing needs
the item to have DataComponents.GLIDER, the slot its EQUIPPABLE names
to be that slot, and a next point of wear that won't break it. The
server's updateFallFlying then picks one such piece at random with
Util.getRandom and runs ItemStack.hurtAndBreak(1) on it.

Horizon now checks each worn piece this way (gliding_pieces, also used
for the server's copy of a guest), and the wear goes to a random glider
piece (wear_glider), for the local player and the guest path alike.
2026-11-01 08:42:22 +01:00
MariuslozoandClaude Opus 5.5 178ce862a7 Localization: translations follow the splitscreen.profiles keys and main's friend keys
pl_pl, uk_ua, de_de and fr_fr use the renamed horizon.splitscreen.profiles.*
keys. Main now keys the two "Connected to your friend" lines as
horizon.multiplayer.connected_friend_*, so de_de and fr_fr use those names,
and they gain main's two new keys (not_signed_in, invalid_address).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 08:41:21 +01:00
MariuslozoandClaude Opus 5.5 e1ba0bf533 Localization: Swiss German without ß; three translation fixes
Review of the bundled de, fr and uk strings:

- de_ch read de_de's file with its "ß"; vanilla's de_ch writes "ss" (1
  "ß" in its file, 312 in de_de's), so Bundled serves de_ch as de_de with
  "ß" spelled "ss".
- de "Shortcut: %s" was "Kurzanwahl" (a phone's speed dial): "Tastenkürzel".
- de: the calibration's white step hint trailed off ("..., bei deinem
  Licht."); fr: "one test picture at a time" had become "image test".
- uk: the Extras caption "Коліщатко: перенос" put a second colon on its
  button ("...: перенос: Увімк."): "Перенос коліщатком".
- The lang test checks one German and one French string and that de_at,
  fr_ca and fr_ch read the same file, instead of pinning two strings in six
  languages.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:40:56 +01:00
MariuslozoandClaude Sonnet 5.5 ee55ff3d67 Localization: add German and French for Horizon's own strings
de_de.json and fr_fr.json cover all 289 keys of en_us.json. de_at, de_ch,
fr_ca and fr_ch read the same files, as the es_* variants read es_es. AI
translation (Claude), not reviewed by a native speaker. Terms follow vanilla's
de_de/fr_fr (Optionen/Einstellungen, Fertig, Mehrspieler; Options, Terminé,
Multijoueur, "Oui/Non" for On/Off, a space before ":" and "?"). German uses
"du", French "vous", as vanilla does. The lang test now also loads the title
footer and the Graphics button in de, fr, pl and uk.

Refs #1927

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 08:40:56 +01:00
MariuslozoandClaude Sonnet 5.5 15b1dbaa14 Localization: complete Polish and Ukrainian for Horizon's own strings
pl_pl.json and uk_ua.json had 16 keys each; they now have all 289 keys of
en_us.json. AI translation (Claude), not reviewed by a native speaker. Terms
follow vanilla's own pl_pl/uk_ua files (Ustawienia/Налаштування, Paczki/
Пакети, Gotowe/Готово, byty/сутності, ...), and the ellipsis is the single
character vanilla uses in those languages. Placeholders (%s, %.2f) are kept.

Refs #1927

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 08:40:56 +01:00
MariuslozoandClaude Opus 5.5 0e64bf0818 Localization: splitscreen profile keys under horizon.splitscreen.profiles
Review on #5644: more splitscreen strings are coming, so the profile
screen's keys move from horizon.profiles.* to horizon.splitscreen.profiles.*.
The English text is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 08:40:54 +01:00
MariuslozoandClaude Sonnet 5.5 fd7a47175c Localization: HDR calibration, profiles, Play with Friends and the keyboard read the language table
More of Horizon's own screens drew English literals (or looked up keys that
en_us.json did not have, which also shows English). They now go through the
language table:

- HDR calibration screen: title, the three step names and hints, Cancel/Back/
  Save/Next (vanilla gui.cancel, gui.back, selectWorld.edit.save), and the
  "Shortcut:" row; the Options > Graphics HDR descriptions and the Calibrate
  buttons that had no en_us entry.
- Splitscreen profile page (split_profiles.rs): every label and line.
- Play with Friends screen, its chat notices and the two "Connected to your
  friend" loading lines.
- The on-screen keyboard's key labels and button hints.

English text stays the fallback (lang::get_or) so nothing changes without a
translation. en_us.json gains 55 keys.

Refs #1927

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
2026-11-01 08:40:19 +01:00
MariuslozoandClaude Opus 5.5 cddfdcea34 Localization: the lang test checks every key the code names, CRLF or not
Review of the bundled lang file test:

- `every_horizon_key_the_code_names_is_in_en_us` skipped a key whose group
  had no other entry in en_us, so a new screen's keys were never checked
  (deleting `horizon.title.not_affiliated` passed). A key is now any
  `horizon.<group>.<name>` of lower-case letters, digits and `_`, with the
  one server address that looks like one listed.
- The CRLF assertion failed on a Git for Windows checkout
  (core.autocrlf=true) although CRLF is harmless to the parser; it goes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 08:40:07 +01:00
MariuslozoandClaude Sonnet 5.5 570e3c9656 Localization: test the bundled horizon lang files against en_us
Every bundled horizon lang file must be served by Bundled::read (a file with
no match arm is never loaded), parse as a flat map without a repeated key,
have no key en_us lacks (stale or renamed), and keep the placeholders of the
English text. Every `horizon.<group>.<name>` key the client sources name must
exist in en_us.json. A language does not have to be complete: a missing key
falls back to English.

Refs #1927

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 08:40:07 +01:00
MariuslozoandClaude Sonnet 5.5 b3209dd9b9 Localization: the title footer and the Extras page read the language table
The title screen's "Not affiliated with Mojang or Microsoft" and the whole
Horizon Extras page (section headers, the eleven captions, their tooltips and
the On/Off values) were English literals that never reached the language table,
so they stayed English in every language.

They now read `horizon.title.not_affiliated` and `horizon.extras.*` from the
bundled `horizon` namespace (lang::get_or, with the English text as fallback,
as the multiplayer buttons do). en_us.json gains the 27 keys; other languages
fall back to English until they are translated.

Refs #1927

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-11-01 08:40:07 +01:00
Stink-O 3d685f468f Clippy: the changed cells' rows through as_chunks, not chunks_exact (Rust 1.99's lint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 1606f86e4c)
2026-11-01 05:07:30 +00:00
Stink-O ace8c981f5 Clippy: the changed cells' rows through as_chunks, not chunks_exact (Rust 1.99's lint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
(cherry picked from commit 1606f86e4c)
2026-11-01 05:07:30 +00:00
Stink-OandClaude Opus 5.5 1606f86e4c Clippy: the changed cells' rows through as_chunks, not chunks_exact (Rust 1.99's lint)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 05:07:22 +00:00
Stink-OandClaude Opus 5.5 baabab598f feat(mods): native server mods can read surface heights and columns
`native_surface_heights(dimension, columns, heightmap)` and `native_read_columns(dimension, columns, heightmap, (below, above))` for server mods, in the level being ticked: a column's heightmap height, or the blocks round it as a palette and indexes with the biome's grass, foliage and water tints, thousands of columns a call. Ported from Stink-O/horizon cbceb3f0e, a3ba97c94 and 20a8a553c.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:32:01 +00:00
Stink-OandClaude Opus 5.5 33fd9148b0 feat(mods): native server mods can edit blocks in bulk
`native_edit_blocks(dimension, edit)` for server mods: a palette of block states and filters read as commands read them, runs up columns from a height or a heightmap's, made at once in the level being ticked through `World::edit_runs`, with what `fill ... strict` does for block entities. Millions of blocks a second where `native_set_block` does one: for world generators, terraformers, schematics. Ported from Stink-O/horizon ee55a05e6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:31:54 +00:00
Stink-OandClaude Opus 5.5 1bfb26017b perf(world): bulk block edits into chunk sections; resent columns relit only where they changed
`World::edit_runs` (block_edit.rs): runs of blocks up columns written straight into chunk sections, a section replaced at once (`Chunk::replace_blocks`); the chunks changed that way go whole to the players who see them (`World.bulk_log`). A column a server sends again while it is resident keeps its light and is relit and remeshed only round the cells that changed (`Streaming::replace_remote`, `PalettedContainer::same_storage`, `LightChanges::classes_differing`), which also makes any resent column cheaper. Tests for the runs, the storage comparison and the sections dirtied. Ported from Stink-O/horizon ee55a05e6 and 8820a77ca.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:31:46 +00:00
Aaron Wang 8967e6abf1 A hit on a boat, minecart or armor stand starts the attack strength again
Player.attack calls onAttack, which resets the attack strength ticker, for
every entity that passes cannotAttack. Horizon did it for mobs, players and
the dragon, but not for boats, minecarts and armor stands, so on a server
only the client's swing packet reset it: a client that skipped the swing
hit vehicles at full strength every time. A fixed item frame refuses the
hit of a player who isn't in creative, and a cushion that of a player who
may not build, so skipAttackInteraction is false there and they reset too.

server_packet_rules_e2e checks it with two bots that hit a boat each in one
go without swinging: eight hits leave one standing (it breaks on the fifth
without the reset), 24 still break the other. horizon-bots' --attack takes
a UUID, so a bot can hit an entity summoned with one, and --attack-rounds
stops it after N rounds. The harness turns the anticheat off, which would
drop those attacks, and force-loads the boats' chunks.

Fixes #5631

Written with Claude Opus 5.5 in OpenCode.
2026-10-31 23:59:42 -04:00
spiderandClaude Opus 5.5 a9c0d90d2d Frame Generation setting: FSR 3.1, its present thread and the hand
Options > Graphics > Display: Frame Generation, Off or FSR 3.1 (off by default,
frameGeneration in horizon-graphics.txt). The world is kept at the screen's size
without the hand and HUD, the optical flow and interpolation make the frame
halfway (with mobs' and dropped items' own motion vectors), the hand (at its
halfway pose), outlines, post effects, screen effects and HUD are drawn over it,
and a present thread shows it half a real frame before the real one. Off under
shader packs, HDR, the title screen and blurred menus.

When the present thread gets anything but a frame from the surface (outdated,
lost, occluded, timeout, validation, suboptimal) or dies, the renderer stops it
and acquires the next frame itself, so gfx.rs's normal path handles the surface;
frame generation resumes once that path has a frame again. The F3 screen's first
line adds the frames reaching the screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 04:28:26 +01:00
MariuslozoandClaude Sonnet 5.5 be5d818a58 Native mods: a hook at LivingEntity.knockback to change or cancel a push
native_living_knockback (NeoForge's LivingKnockBackEvent) runs in
LivingEntity.knockback once the knockback resistance took its share and
before the motion changes, with the pushed entity's id and type, the
strength, the direction (x, z) and a cancel flag; a non-finite value a mod
leaves is put back. In 26.3 every knockback has a DamageSource, so the hook
names it (damage type, causing and direct entity) as
native_living_incoming_damage names the same hit (`Hurting`,
`hurt_server_args`). The API number comes from the fragment
(tools/native-hook-fragments).

It is reached by every LivingEntity.knockback Horizon makes on players
(including LAN guests and a dedicated server's) and mobs: a hit's own push
(dealDefaultKnockback), a sprinting or Knockback hit and a spear
(causeExtraKnockback), a sweep, a goat's ram (RamTarget, `mobAttack`) and a
mob's spear push, which now carry their source in MobEvent::Knockback, and
LivingEntity.blockedByItem's push on a shield's holder, which was
open-coded past it (and past the knockback resistance) in shield.rs and is
now knock_back_player(0.5, ...). A sulfur cube with a block hit by an entity
doesn't reach it (SulfurCube.knockback doesn't call super there). The docs
list the pushes Horizon still makes another way: a mob's melee push from its
attack knockback or a Knockback weapon, an armor stand's, the ender
dragon's.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-01 02:07:08 +01:00
163 changed files with 10958 additions and 853 deletions

No files matched your search

+13 -1
View File
@@ -232,7 +232,7 @@ fn os_version() -> Option<String> {
#[cfg(target_os = "windows")]
fn cpu() -> Option<String> {
std::env::var("PROCESSOR_IDENTIFIER").ok()
horizon_telemetry::system::windows_cpu()
}
#[cfg(target_os = "windows")]
@@ -281,4 +281,16 @@ mod tests {
assert!(cpu_seconds(std::process::id()).unwrap() >= 0.0);
}
}
/// `hardware.cpu` is the brand name, not the family and model `PROCESSOR_IDENTIFIER` holds.
#[cfg(windows)]
#[test]
fn windows_reports_the_cpu_brand() {
let cpu = Hardware::detect().cpu.expect("a CPU name");
assert!(
!cpu.is_empty() && !cpu.contains(" Family ") && !cpu.contains("Stepping"),
"family and model, not a name: {cpu:?}"
);
assert_eq!(cpu, cpu.trim());
}
}
+41 -4
View File
@@ -73,8 +73,11 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
in the same tick (a multi-target kill aura, for the anticheat's tests);
the report gives each bot's last health
the report gives each bot's last health. A UUID instead of a NAME: the
entity with that UUID (a boat summoned with it, say)
--attack-after S start attacking S seconds after joining; default 5
--attack-rounds N stop after N rounds of attacks (each hits every target seen so far once);
default no limit
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
ignores the player's moves and actions for its first 3 seconds
@@ -153,9 +156,11 @@ struct Options {
seed: u64,
explore_speed: f64,
pvp: bool,
/// The player to attack, and how long after joining to start.
/// The players or entity UUIDs to attack, how long after joining to start,
/// and how many rounds.
attack: Option<String>,
attack_after: f64,
attack_rounds: u32,
no_swing: bool,
flood: usize,
no_read: bool,
@@ -190,6 +195,7 @@ fn options() -> Result<Options, String> {
pvp: true,
attack: None,
attack_after: 5.0,
attack_rounds: u32::MAX,
no_swing: false,
flood: 100,
no_read: false,
@@ -247,6 +253,7 @@ fn options() -> Result<Options, String> {
"--flood" => o.flood = value.parse().map_err(|_| number("packets a tick"))?,
"--attack" => o.attack = Some(value),
"--attack-after" => o.attack_after = value.parse().map_err(|_| number("seconds"))?,
"--attack-rounds" => o.attack_rounds = value.parse().map_err(|_| number("a number"))?,
"--name" => o.name = value,
"--report" => o.report = Some(value),
"--brand" => o.brand = Some(value),
@@ -401,6 +408,16 @@ fn join_summary(mut times: Vec<u64>) -> serde_json::Value {
})
}
/// An `--attack` target's UUID: a UUID as written (`8-4-4-4-12` hex), else
/// the offline UUID of the player with that name (a name has no `-`).
fn target_uuid(name: &str) -> u128 {
let hex: String = name.chars().filter(|&c| c != '-').collect();
match u128::from_str_radix(&hex, 16) {
Ok(uuid) if name.contains('-') && hex.len() == 32 => uuid,
_ => offline_uuid(name),
}
}
/// A small deterministic generator (SplitMix64), so every run moves the bots the same way.
struct Random(u64);
impl Random {
@@ -984,7 +1001,7 @@ fn run_bot(
.attack
.iter()
.flat_map(|names| names.split(','))
.map(|name| (offline_uuid(name), None))
.map(|name| (target_uuid(name), None))
.collect();
bot.online.store(true, Ordering::SeqCst);
{
@@ -1039,6 +1056,7 @@ fn run_bot(
send(play::Serverbound::SetCarriedItem(play::SetCarriedItem { slot: 0 }).encode());
}
let mut sequence = 0;
let mut rounds = 0;
let mut random = Random(o.seed ^ (index as u64).wrapping_mul(0x2545_F491_4F6C_DD1D));
let mut pos = spawn;
// Fight bots stay at their spawn height; the others climb to fly above the terrain.
@@ -1202,8 +1220,13 @@ fn run_bot(
send(body);
}
}
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
if joined.elapsed().as_secs_f64() >= o.attack_after
&& tick % 10 == 0
&& rounds < o.attack_rounds
{
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
// A round counts once a target is known.
rounds += u32::from(!targets.is_empty());
for id in targets {
send(play::Serverbound::Attack(play::Attack { id }).encode());
// Minecraft.startAttack swings right after the attack.
@@ -1446,6 +1469,20 @@ mod tests {
})
}
/// `--attack`: a UUID names that entity, anything else a player.
#[test]
fn attack_targets_a_uuid_or_a_player() {
assert_eq!(target_uuid("00000000-0000-0000-0000-00000000000a"), 10);
assert_eq!(
target_uuid("0000000a-0000-0001-0000-000000000002"),
0x0000000a_0000_0001_0000_000000000002
);
assert_eq!(target_uuid("V0"), offline_uuid("V0"));
// A name of 32 hex digits is a name: a UUID is written with dashes.
let name = "0123456789abcdef0123456789abcdef";
assert_eq!(target_uuid(name), offline_uuid(name));
}
#[test]
fn tracks_living_entities_only() {
let mut e = Entities::default();
+169
View File
@@ -72,6 +72,19 @@ impl ResourceSource for Bundled {
"assets/horizon/lang/tr_tr.json" => {
include_str!("../../horizon-client/assets/horizon/lang/tr_tr.json")
}
"assets/horizon/lang/de_de.json" | "assets/horizon/lang/de_at.json" => {
include_str!("../../horizon-client/assets/horizon/lang/de_de.json")
}
// Swiss German writes "ss" for "ß", as vanilla's de_ch does.
"assets/horizon/lang/de_ch.json" => {
let de = include_str!("../../horizon-client/assets/horizon/lang/de_de.json");
return Some(de.replace('ß', "ss").into_bytes());
}
"assets/horizon/lang/fr_fr.json"
| "assets/horizon/lang/fr_ca.json"
| "assets/horizon/lang/fr_ch.json" => {
include_str!("../../horizon-client/assets/horizon/lang/fr_fr.json")
}
"assets/horizon/lang/es_es.json"
| "assets/horizon/lang/es_ar.json"
| "assets/horizon/lang/es_cl.json"
@@ -316,6 +329,20 @@ mod tests {
assert_eq!(tr.get("horizon.options.controller"), Some("Kontrolcü"));
let es = Language::load(&[&empty], "es_es");
assert_eq!(es.get("horizon.options.controller"), Some("Mando"));
// German and French, and their regional variants on the same file
// (Swiss German without "ß").
let de = Language::load(&[&empty], "de_de");
assert_eq!(de.get("horizon.osk.close"), Some("Schließen"));
let fr = Language::load(&[&empty], "fr_fr");
assert_eq!(fr.get("horizon.osk.close"), Some("Fermer"));
for (code, base) in [("de_at", &de), ("fr_ca", &fr), ("fr_ch", &fr)] {
let variant = Language::load(&[&empty], code);
assert_eq!(variant.map(), base.map(), "{code}");
}
let de_ch = Language::load(&[&empty], "de_ch");
assert_eq!(de_ch.get("horizon.osk.close"), Some("Schliessen"));
assert!(de_ch.map().values().all(|v| !v.contains('ß')));
assert_eq!(de_ch.len(), de.len());
let es_ve = Language::load(&[&empty], "es_ve");
assert_eq!(
es_ve.get("horizon.options.on_screen_keyboard"),
@@ -323,6 +350,148 @@ mod tests {
);
}
/// `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"))
.join("../horizon-client/assets/horizon/lang")
}
/// The keys of a flat bundled file in file order, duplicates included (a map
/// would keep only the last of two equal keys). One `"key": "value"` per line.
fn file_keys(body: &str) -> Vec<String> {
body.lines()
.filter(|line| line.starts_with(" \""))
.map(|line| {
serde_json::Deserializer::from_str(line.trim_start())
.into_iter::<String>()
.next()
.and_then(Result::ok)
.unwrap_or_else(|| panic!("no key on line {line}"))
})
.collect()
}
/// The fields the code fills in (`%s`, `%d`, `%1$s`, `%.2f`, `%%`), sorted. A
/// percent sign that starts none of them ("100% is") is plain text.
fn placeholders(text: &str) -> Vec<String> {
let mut found = Vec::new();
let mut rest = text;
while let Some(at) = rest.find('%') {
rest = &rest[at + 1..];
let spec = rest
.find(|c: char| !(c.is_ascii_digit() || c == '$' || c == '.'))
.unwrap_or(rest.len());
let after = rest[spec..].chars().next();
if after.is_some_and(|c| matches!(c, 's' | 'd' | 'f' | '%')) {
let end = spec + 1;
found.push(format!("%{}", &rest[..end]));
rest = &rest[end..];
}
}
found.sort();
found
}
#[test]
fn bundled_files_are_served_and_have_no_stale_or_repeated_keys() {
let dir = bundled_dir();
let en_body = std::fs::read_to_string(dir.join("en_us.json")).unwrap();
let en: HashMap<String, String> = serde_json::from_str(&en_body).unwrap();
let mut seen = 0;
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let name = entry.file_name().into_string().unwrap();
let Some(code) = name.strip_suffix(".json") else {
continue;
};
seen += 1;
// A file that Bundled::read does not name is never loaded.
let path = format!("assets/horizon/lang/{name}");
assert!(
Bundled.read(&path).is_some(),
"{name} is not served by Bundled"
);
let body = std::fs::read_to_string(entry.path()).unwrap();
let map: HashMap<String, String> = serde_json::from_str(&body)
.unwrap_or_else(|e| panic!("{name} is not a flat string map: {e}"));
let keys = file_keys(&body);
assert_eq!(
keys.len(),
map.len(),
"{name} repeats a key or has a multi-line entry"
);
for (key, value) in &map {
// Stale: the code no longer asks for it, or it was renamed in en_us only.
let english = en
.get(key)
.unwrap_or_else(|| panic!("{code}: {key} is not in en_us.json"));
// A translation keeps the fields the code fills in.
assert_eq!(
placeholders(value),
placeholders(english),
"{code}: {key} changes the placeholders of the English text"
);
assert!(!value.trim().is_empty(), "{code}: {key} is empty");
}
}
assert!(
seen >= 7,
"found {seen} bundled lang files in {}",
dir.display()
);
}
#[test]
fn every_horizon_key_the_code_names_is_in_en_us() {
fn rust_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
rust_files(&path, out);
} else if path.extension().is_some_and(|e| e == "rs") {
out.push(path);
}
}
}
let en: HashMap<String, String> = serde_json::from_str(
&std::fs::read_to_string(bundled_dir().join("en_us.json")).unwrap(),
)
.unwrap();
let crates = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..");
let mut files = Vec::new();
for krate in [
"horizon-client",
"horizon-client-render",
"horizon-client-core",
] {
rust_files(&crates.join(krate).join("src"), &mut files);
}
assert!(files.len() > 100, "found only {} sources", files.len());
let mut found = 0;
for file in files {
let text = std::fs::read_to_string(&file).unwrap();
for (at, _) in text.match_indices("\"horizon.") {
let rest = &text[at + 1..];
let Some(end) = rest.find('"') else { continue };
let key = &rest[..end];
// A key is `horizon.<group>.<name>`: lower-case letters, digits and `_`
// between the dots. A key built at run time has a brace; a file name
// has one dot; the server addresses that look like keys are listed.
const NOT_KEYS: &[&str] = &["horizon.bunchy7s.dev"];
let key_like = key
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b"_.".contains(&b));
if key.matches('.').count() < 2 || !key_like || NOT_KEYS.contains(&key) {
continue;
}
found += 1;
assert!(
en.contains_key(key),
"{} names {key}, which is not in en_us.json",
file.display()
);
}
}
assert!(found > 50, "found only {found} key names");
}
#[test]
fn layers_selected_language_over_english() {
let base = Mem(HashMap::from([
@@ -213,6 +213,48 @@ impl Registries {
}
}
}
/// Only what [`Self::fill_from_export`] takes from an export, to keep
/// in place of the export itself (a third of its memory).
pub fn from_export(r: &serde_json::Value) -> Self {
let mut out = Self::default();
out.fill_from_export(r);
out
}
/// [`Self::fill_from_export`] from an export already read with
/// [`Self::from_export`]: each field the game left empty, each registry
/// it did not list.
pub fn fill_from_exported(&mut self, e: &Self) {
let fill = |field: &mut Vec<String>, from: &Vec<String>| {
if field.is_empty() {
field.clone_from(from);
}
};
fill(&mut self.effects, &e.effects);
fill(&mut self.enchantments, &e.enchantments);
fill(&mut self.attributes, &e.attributes);
fill(&mut self.damage_types, &e.damage_types);
fill(&mut self.templates, &e.templates);
fill(&mut self.persistent_components, &e.persistent_components);
fill(&mut self.learnable_recipes, &e.learnable_recipes);
for (name, ids) in &e.by_registry {
if !self.by_registry.contains_key(name) {
self.by_registry.insert(name.clone(), ids.clone());
}
}
if self.block_entities.is_empty() {
self.block_entities.clone_from(&e.block_entities);
}
if self.block_tag_members.is_empty() {
self.block_tag_members.clone_from(&e.block_tag_members);
}
if self.clock_time_markers.is_empty() {
self.clock_time_markers.clone_from(&e.clock_time_markers);
}
if self.advancement_criteria.is_empty() {
self.advancement_criteria
.clone_from(&e.advancement_criteria);
}
}
/// The element ids of a registry (sorted).
pub fn ids(&self, registry: &str) -> &[String] {
if let Some(r) = self.by_registry.get(registry) {
@@ -549,6 +549,7 @@ pub fn fullscreen_request(
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
pub fn icon() -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
let mut bytes = vec![0; reader.output_buffer_size()?];
@@ -172,6 +172,7 @@ fn color(v: &Value) -> Option<u32> {
fn colormap(root: &Path, name: &str) -> Result<Vec<u32>> {
let path = root.join(format!("assets/minecraft/textures/colormap/{name}.png"));
let mut decoder = png::Decoder::new(std::io::BufReader::new(fs::File::open(path)?));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![0; reader.output_buffer_size().ok_or("colormap too large")?];
@@ -0,0 +1,306 @@
//! Bulk block edits: runs of blocks up columns written straight into the
//! resident sections, a section unpacked and packed once however many of its
//! blocks change, instead of a `World::set` per block. Nothing round them is
//! told (as `fill ... strict`); the light of each chunk changed is worked out
//! again whole, off the game thread, rather than block by block, and each
//! section changed is remeshed once.
use crate::chunk::index;
use crate::fast_hash::FxHashMap;
use crate::world::World;
/// A run of one column, resolved: blocks `y0..=y1` of column (x, z) set to
/// `state` where filter `only` (if any) passes the block there.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Run {
pub x: i32,
pub z: i32,
pub y0: i32,
pub y1: i32,
pub state: u32,
pub only: Option<u16>,
}
/// What an edit changed.
#[derive(Debug, Default)]
pub struct Outcome {
/// Blocks changed.
pub changed: u64,
/// Chunks with a change.
pub chunks: usize,
/// Changes of the states `tracked` asked for (block entities, watched
/// blocks): position, previous and new state.
pub tracked: Vec<((i32, i32, i32), u32, u32)>,
}
/// Per state answers, worked out once each.
struct Memo(Vec<u8>);
impl Memo {
#[inline]
fn get(&mut self, state: u32, ask: impl FnOnce(u32) -> bool) -> bool {
let i = state as usize;
if i >= self.0.len() {
self.0.resize(i + 1, 0);
}
if self.0[i] == 0 {
self.0[i] = 1 + u8::from(ask(state));
}
self.0[i] == 2
}
}
impl World {
/// Make `runs` in order, each seeing the changes of those before it.
/// `matches(filter, state)` says whether a filter passes a state, and
/// `tracked(state)` whether its changes are reported; each is asked once
/// per state. Runs in unloaded chunks and the parts of runs outside each
/// chunk's own column (`Chunk::layout`) are left out.
///
/// Unlike `World::set`, this records nothing per block: no
/// `block_entities.state_changed` (a caller adds or removes block
/// entities itself, from `Outcome::tracked`), no `watched_writes` (ask
/// for those states through `tracked`), no `block_log` entry (the chunk
/// goes in `bulk_log` instead, sent whole), no `path_writes` (mobs'
/// paths are not recomputed for these blocks) and no `light_edits` (the
/// chunk and its neighbours are lit again whole, through `dirty_light`).
pub fn edit_runs(
&mut self,
runs: &[Run],
mut matches: impl FnMut(u16, u32) -> bool,
mut tracked: impl FnMut(u32) -> bool,
) -> Outcome {
let mut out = Outcome::default();
// the runs of each chunk, in order, and the chunks in the order they were first named
let mut by_chunk: FxHashMap<(i32, i32), Vec<u32>> = FxHashMap::default();
let mut order = Vec::new();
for (i, r) in runs.iter().enumerate() {
if r.y1 < r.y0 {
continue;
}
let key = (r.x >> 4, r.z >> 4);
by_chunk
.entry(key)
.or_insert_with(|| {
order.push(key);
Vec::new()
})
.push(i as u32);
}
let mut filter_memo: Vec<Memo> = Vec::new();
let mut tracked_memo = Memo(Vec::new());
let mut values: Vec<Option<Box<[u32; 4096]>>> = Vec::new();
let mut changed: Vec<bool> = Vec::new();
for key in order {
let Some(chunk) = self.chunks.get(&key) else {
continue;
};
let (min_y, sections) = (chunk.min_y(), chunk.sections.len());
let max_y = chunk.max_y();
let list = &by_chunk[&key];
changed.clear();
changed.resize(sections, false);
values.clear();
values.resize_with(sections, || None);
let mut any = 0u64;
for &i in list {
let r = runs[i as usize];
let (lx, lz) = (r.x & 15, r.z & 15);
let (y0, y1) = (r.y0.max(min_y), r.y1.min(max_y - 1));
let mut y = y0;
while y <= y1 {
let s = ((y - min_y) >> 4) as usize;
let top = (min_y + (s as i32 + 1) * 16 - 1).min(y1);
let vals = values[s].get_or_insert_with(|| {
let mut v = Box::new([0u32; 4096]);
chunk.sections[s].unpack(&mut v);
v
});
for yy in y..=top {
let at = index(lx, yy, lz);
let old = vals[at];
if old == r.state {
continue;
}
if let Some(f) = r.only {
let f = f as usize;
if filter_memo.len() <= f {
filter_memo.resize_with(f + 1, || Memo(Vec::new()));
}
if !filter_memo[f].get(old, |s| matches(f as u16, s)) {
continue;
}
}
vals[at] = r.state;
changed[s] = true;
any += 1;
if tracked_memo.get(old, &mut tracked)
|| tracked_memo.get(r.state, &mut tracked)
{
out.tracked.push(((r.x, yy, r.z), old, r.state));
}
}
y = top + 1;
}
}
if any == 0 {
continue;
}
out.changed += any;
out.chunks += 1;
let chunk =
std::sync::Arc::make_mut(self.chunks.get_mut(&key).expect("looked up above"));
for s in 0..sections {
if changed[s]
&& let Some(v) = &values[s]
{
chunk.replace_blocks(s, v);
}
}
self.revision = self.revision.wrapping_add(1);
self.modified.insert(key);
if self.block_log.is_some() {
self.bulk_log.insert(key);
}
// remeshed once (with the neighbours whose faces and light it touches), lit again whole
for (s, _) in changed.iter().enumerate().filter(|c| *c.1) {
for ds in -1..=1 {
let sy = s as i32 + ds;
if !(0..sections as i32).contains(&sy) {
continue;
}
for dz in -1..=1 {
for dx in -1..=1 {
self.dirty_sections.insert((key.0 + dx, sy, key.1 + dz));
}
}
}
}
for dz in -1..=1 {
for dx in -1..=1 {
self.dirty_light.insert((key.0 + dx, key.1 + dz));
}
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chunk::Chunk;
use horizon_world::registry::BlockStateId;
use std::sync::Arc;
fn world() -> World {
let mut w = World::new(BlockStateId(0), 0);
for cz in -1..=1 {
for cx in -1..=1 {
w.chunks.insert((cx, cz), Arc::new(Chunk::empty(cx, cz)));
}
}
// stone up to y 63
for z in -16..32 {
for x in -16..32 {
for y in -64..64 {
w.set(x, y, z, BlockStateId(1)).unwrap();
}
}
}
w.dirty_light.clear();
w.dirty_sections.clear();
w.block_log = Some(Vec::new());
w
}
fn run(x: i32, z: i32, y0: i32, y1: i32, state: u32, only: Option<u16>) -> Run {
Run {
x,
z,
y0,
y1,
state,
only,
}
}
#[test]
fn runs_change_their_blocks_in_order() {
let mut w = world();
let runs = [
run(3, 4, 10, 40, 0, None),
run(3, 4, 10, 12, 5, None),
// only where it is air: over the stone, and the three blocks of 5 stay
run(3, 4, 0, 80, 7, Some(0)),
run(-1, 20, -70, 400, 9, None),
];
let out = w.edit_runs(&runs, |_, s| s == 0, |_| false);
assert_eq!(w.block(3, 9, 4).0, 1);
assert_eq!(w.block(3, 11, 4).0, 5);
assert_eq!(w.block(3, 13, 4).0, 7);
assert_eq!(w.block(3, 70, 4).0, 7);
assert_eq!(w.block(3, 81, 4).0, 0);
assert_eq!(w.block(3, 0, 4).0, 1);
assert_eq!(w.block(-1, -64, 20).0, 9);
assert_eq!(w.block(-1, 319, 20).0, 9);
assert_eq!(w.block(0, 30, 20).0, 1);
// 31 dug, 3 set again, the air over them filled (28, and 17 over the stone), the whole of the other column
assert_eq!(out.changed, 31 + 3 + 45 + 384);
assert_eq!(out.chunks, 2);
assert!(w.bulk_log.contains(&(0, 0)) && w.bulk_log.contains(&(-1, 1)));
assert!(w.dirty_light.contains(&(1, 1)) && w.dirty_light.contains(&(-2, 2)));
assert!(w.dirty_sections.contains(&(1, 4, 1)));
// the per-block log is left to the blocks set one at a time
assert!(w.block_log.as_ref().unwrap().is_empty());
}
#[test]
fn unloaded_chunks_and_unchanged_blocks_are_left_out() {
let mut w = world();
let out = w.edit_runs(
&[run(100, 100, 0, 10, 0, None), run(0, 0, 0, 10, 1, None)],
|_, _| true,
|_| false,
);
assert_eq!(out.changed, 0);
assert_eq!(out.chunks, 0);
assert!(w.bulk_log.is_empty() && w.dirty_light.is_empty());
}
#[test]
fn tracked_states_are_reported() {
let mut w = world();
let out = w.edit_runs(&[run(1, 1, 60, 65, 3, None)], |_, _| true, |s| s == 3);
assert_eq!(out.tracked.len(), 6);
assert_eq!(out.tracked[0], ((1, 60, 1), 1, 3));
assert_eq!(out.tracked[5], ((1, 65, 1), 0, 3));
}
#[test]
fn heightmaps_follow_the_edit() {
let mut w = world();
let air = |s: u32| s == 0;
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 64);
w.edit_runs(&[run(5, 5, 20, 63, 0, None)], |_, _| true, |_| false);
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 20);
w.edit_runs(&[run(5, 5, 90, 90, 2, None)], |_, _| true, |_| false);
assert_eq!(w.world_surface(5, 5, |s| air(s.0)), 91);
}
#[test]
fn runs_follow_each_chunks_own_layout() {
use crate::chunk::ColumnLayout;
// a data pack's taller dimension: -128..=511
let tall = ColumnLayout::new(-128, 640).unwrap();
let mut w = World::new(BlockStateId(0), 0);
w.chunks
.insert((0, 0), Arc::new(Chunk::with_layout(0, 0, tall)));
let out = w.edit_runs(&[run(2, 3, -500, 900, 4, None)], |_, _| true, |_| false);
assert_eq!(out.changed, 640);
let chunk = &w.chunks[&(0, 0)];
assert_eq!(chunk.block(2, -128, 3).0, 4);
assert_eq!(chunk.block(2, -65, 3).0, 4);
assert_eq!(chunk.block(2, 320, 3).0, 4);
assert_eq!(chunk.block(2, 511, 3).0, 4);
assert!(w.dirty_sections.contains(&(0, 39, 0)));
assert!(!w.dirty_sections.contains(&(0, 40, 0)));
}
}
@@ -508,6 +508,15 @@ impl Chunk {
}
previous
}
/// Section `s`'s block states replaced whole (a bulk edit): its storage
/// compacted, and the heightmaps built again on their next read.
pub fn replace_blocks(&mut self, s: usize, values: &[u32; 4096]) {
let section = Arc::make_mut(&mut self.sections[s]);
section.blocks = Section::from_blocks(values);
section.compact();
self.surface = OnceLock::new();
self.motion_blocking = OnceLock::new();
}
/// `getHeight(Heightmap.Types.WORLD_SURFACE, x, z)` (`x`, `z` within the
/// chunk): one above the highest block that isn't `air`, or the column's
/// minimum Y.
@@ -199,8 +199,9 @@ where
}
impl SceneCollisions<'_, '_> {
/// Position contexts permit geometric movement probes without inventing a
/// player, inventory, effects or fluid-standing behavior; a lava walker
/// collides as the living entity it is, empty-handed.
/// player, inventory, effects or fluid-standing behavior; a lava walker or
/// a living entity in leather boots collides as the living entity it is,
/// empty-handed.
pub fn resolve(
&mut self,
bounds: Aabb,
@@ -236,7 +237,10 @@ impl SceneCollisions<'_, '_> {
}
fn context(&self, bounds: Aabb) -> CollisionContext<'static> {
let y = bounds.min_position().y;
if !self.subject.is_some_and(|s| s.lava_walker) {
if !self
.subject
.is_some_and(|s| s.lava_walker || s.leather_boots)
{
return CollisionContext::position(y);
}
CollisionContext::for_entity(
+1 -35
View File
@@ -195,14 +195,6 @@ fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
fn windows_processor_name(query: &str) -> Option<String> {
query.lines().find_map(|line| {
let (_, value) = line.split_once("REG_SZ")?;
let value = value.trim();
(!value.is_empty()).then(|| value.to_owned())
})
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
fn operating_system() -> String {
let arch = std::env::consts::ARCH;
@@ -244,17 +236,7 @@ pub fn cpu() -> String {
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
command_output(
"reg",
&[
"query",
r"HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0",
"/v",
"ProcessorNameString",
],
)
.and_then(|query| windows_processor_name(&query))
.or_else(|| std::env::var("PROCESSOR_IDENTIFIER").ok())
horizon_telemetry::system::windows_cpu()
} else {
std::env::var("PROCESSOR_IDENTIFIER").ok()
};
@@ -681,22 +663,6 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
mod tests {
use super::*;
#[test]
fn windows_registry_cpu_name_is_read_without_the_registry_columns() {
let query = r#"
HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0
ProcessorNameString REG_SZ 13th Gen Intel(R) Core(TM) i7-13700F
"#;
assert_eq!(
windows_processor_name(query).as_deref(),
Some("13th Gen Intel(R) Core(TM) i7-13700F")
);
assert_eq!(
windows_processor_name("ProcessorNameString REG_SZ "),
None
);
}
#[test]
fn a_release_reports_its_built_in_version() {
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
+63 -11
View File
@@ -428,32 +428,84 @@ pub enum Item {
/// On the Splitscreen settings page.
Splitscreen,
}
/// The Extras page: headers and items.
/// The Extras page: headers (language keys) and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
vec![
Err("Lighting"),
Err("horizon.extras.header.lighting"),
Ok(Fullbright),
Ok(DynamicLights),
Err("Third person"),
Err("horizon.extras.header.third_person"),
Ok(OwnNameTag),
Err("Particles"),
Err("horizon.extras.header.particles"),
Ok(HideBlockParticles),
Err("Inventory"),
Err("horizon.extras.header.inventory"),
Ok(MouseTweaks),
Ok(MouseTweaksWheel),
Ok(Sort),
Ok(SortKey),
Err("Chat"),
Err("horizon.extras.header.chat"),
Ok(ChatAnimation),
Ok(ChatIndicator),
Err("Multiplayer"),
Err("horizon.extras.header.multiplayer"),
Ok(AutoReconnect),
Err("Sound"),
Err("horizon.extras.header.sound"),
Ok(UiSoundReserve),
]
}
impl Item {
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
pub fn caption_key(self) -> &'static str {
match self {
Item::Fullbright => "horizon.extras.fullbright",
Item::DynamicLights => "horizon.extras.dynamic_lights",
Item::OwnNameTag => "horizon.extras.own_name_tag",
Item::HideBlockParticles => "horizon.extras.hide_block_particles",
Item::AutoReconnect => "horizon.extras.auto_reconnect",
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve",
Item::MouseTweaks => "horizon.extras.mouse_tweaks",
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel",
Item::Sort => "horizon.extras.sort",
Item::SortKey => "horizon.extras.sort_key",
Item::NoAi => "horizon.extras.no_ai",
Item::AgentControl => "horizon.extras.agent_control",
Item::DiscordPresence => "horizon.extras.discord_presence",
Item::Splitscreen => "horizon.extras.splitscreen",
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
}
}
/// The language key of the description shown as the row's tooltip.
pub fn description_key(self) -> &'static str {
match self {
Item::Fullbright => "horizon.extras.fullbright.tooltip",
Item::DynamicLights => "horizon.extras.dynamic_lights.tooltip",
Item::OwnNameTag => "horizon.extras.own_name_tag.tooltip",
Item::HideBlockParticles => "horizon.extras.hide_block_particles.tooltip",
Item::AutoReconnect => "horizon.extras.auto_reconnect.tooltip",
Item::UiSoundReserve => "horizon.extras.ui_sound_reserve.tooltip",
Item::MouseTweaks => "horizon.extras.mouse_tweaks.tooltip",
Item::MouseTweaksWheel => "horizon.extras.mouse_tweaks_wheel.tooltip",
Item::Sort => "horizon.extras.sort.tooltip",
Item::SortKey => "horizon.extras.sort_key.tooltip",
Item::NoAi => "horizon.extras.no_ai.tooltip",
Item::AgentControl => "horizon.extras.agent_control.tooltip",
Item::DiscordPresence => "horizon.extras.discord_presence.tooltip",
Item::Splitscreen => "horizon.extras.splitscreen.tooltip",
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
}
}
/// The caption in the current language (English when the key is missing).
pub fn caption_text(self) -> String {
crate::lang::get_or(self.caption_key(), self.caption())
}
/// The description in the current language (English when the key is missing).
pub fn description_text(self) -> String {
crate::lang::get_or(self.description_key(), self.description())
}
/// The English caption: the fallback of [`Item::caption_text`], and the text of
/// `horizon.extras.*` in en_us.json.
pub fn caption(self) -> &'static str {
match self {
Item::Fullbright => "Fullbright",
@@ -489,7 +541,7 @@ impl Item {
}
})
}
/// What the row says under its caption.
/// The English description: the fallback of [`Item::description_text`].
pub fn description(self) -> &'static str {
match self {
Item::Fullbright => "Show blocks and entities at full light, even in dark areas.",
@@ -581,8 +633,8 @@ impl Item {
match self {
Item::SortKey => crate::keybinds::display_name(&e.sort_key).plain(),
other => match other.switch(e) {
Some(true) => "On".into(),
_ => "Off".into(),
Some(true) => crate::lang::get_or("On", "On"),
_ => crate::lang::get_or("Off", "Off"),
},
}
}
+389 -188
View File
@@ -629,8 +629,9 @@ pub struct ScreenshotRequest {
pub struct Renderer {
instance: wgpu::Instance,
adapter: wgpu::Adapter,
/// The window's swapchain; `None` when rendering offscreen (`--offscreen`).
surface: Option<wgpu::Surface<'static>>,
/// The window's swapchain; `None` when rendering offscreen (`--offscreen`). Shared
/// with frame generation's present thread while it runs.
surface: Option<Arc<wgpu::Surface<'static>>>,
/// The surface `release_surface` set aside for the renderer that replaces
/// this one on the same device: reused, not created again, as a new
/// surface for the window can come back lost (Wayland on Mesa).
@@ -878,6 +879,10 @@ pub struct Renderer {
layout: crate::chunk::ColumnLayout,
/// Resolution scale, FXAA and TAA (Horizon settings).
world_post: world_post::Post,
/// Frame generation (Horizon setting).
frame_gen: frame_gen::FrameGen,
/// The entities' texture atlas (`entity_group`'s), for frame generation's motion.
entity_atlas: wgpu::TextureView,
/// HDR output (Horizon setting): `Some` while the swapchain is HDR.
hdr: Option<hdr::Hdr>,
/// Which pixels of each block sprite may shine in HDR output (terrain.wgsl's
@@ -1773,10 +1778,14 @@ impl Renderer {
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.
// 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))
@@ -1806,7 +1815,9 @@ impl Renderer {
} else {
let format = wgpu::TextureFormat::Bgra8Unorm;
let config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::COPY_DST,
format,
width: size.width.clamp(1, max_side),
height: size.height.clamp(1, max_side),
@@ -3427,7 +3438,7 @@ impl Renderer {
display_fresh: false,
instance,
adapter,
surface,
surface: surface.map(Arc::new),
spare_surface: std::sync::Mutex::new(None),
offscreen,
in_flight: std::collections::VecDeque::new(),
@@ -3581,6 +3592,8 @@ impl Renderer {
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
layout: crate::chunk::ColumnLayout::VANILLA,
world_post,
frame_gen: frame_gen::FrameGen::new(),
entity_atlas: atlas_view.clone(),
hdr: None,
emission_stats,
hdr_output,
@@ -3674,11 +3687,13 @@ impl Renderer {
/// Hand the window surface over to the renderer that replaces this one on
/// the same device (it takes `spare_surface`).
pub fn release_surface(&mut self) {
self.frame_gen.stop();
self.queue.wait();
*self
.spare_surface
.get_mut()
.unwrap_or_else(std::sync::PoisonError::into_inner) = self.surface.take();
.unwrap_or_else(std::sync::PoisonError::into_inner) =
self.surface.take().and_then(|s| Arc::try_unwrap(s).ok());
}
/// Reattach a window after surface loss or a failed renderer replacement.
pub fn recreate_surface(&mut self) -> Result<()> {
@@ -3694,6 +3709,7 @@ impl Renderer {
.get_mut()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.take();
self.frame_gen.stop();
self.surface = None;
let surface = match spare {
Some(surface) => surface,
@@ -3712,7 +3728,7 @@ impl Renderer {
);
}
self.surface_unconfigured = configured.is_err();
self.surface = Some(surface);
self.surface = Some(Arc::new(surface));
Ok(())
}
/// The world's depth, which occlusion culling's test and post effects may read.
@@ -4402,7 +4418,8 @@ impl Renderer {
}
/// Configures the window surface again. One that fails is still the one
/// wgpu configured before, which acquisition reports as a validation error.
fn configure_surface(&self) {
fn configure_surface(&mut self) {
self.frame_gen.stop();
// The submit thread may still present to the old swapchain.
self.queue.wait();
if let Some(surface) = &self.surface
@@ -4492,7 +4509,7 @@ impl Renderer {
// game never waits on the compositor for it.
self.display = if output.is_some() {
let (watch, now) =
DisplayWatch::new(&self.window, self.surface.as_ref(), &self.adapter);
DisplayWatch::new(&self.window, self.surface.as_deref(), &self.adapter);
crate::graphics::report(now);
self.display_fresh = true;
watch
@@ -4542,12 +4559,24 @@ impl Renderer {
if std::mem::take(&mut self.display_fresh) {
return true;
}
let Some(now) = self.display.changed(self.surface.as_ref(), &self.adapter) else {
let Some(now) = self.display.changed(self.surface.as_deref(), &self.adapter) else {
return false;
};
crate::graphics::report(now);
true
}
/// The share of real frames frame generation shows a made frame before (1 while it
/// keeps up), `None` while it isn't running.
pub fn frame_generation_share(&self) -> Option<f32> {
self.frame_gen.made_share
}
/// Frame generation on or off (FSR 3.1's frame interpolation).
pub fn set_frame_generation(&mut self, on: bool) {
if !on {
self.frame_gen.stop();
}
self.frame_gen.on = on;
}
/// `upscale`: output pixels per rendered pixel, for TAA to rebuild (1: native).
pub fn set_world_post(&mut self, scale: f32, antialias: bool, taa: bool, upscale: f32) {
let taa = taa && self.world_post.supports_taa();
@@ -4949,6 +4978,11 @@ impl Renderer {
}
}
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
// A lost device cannot accept uploads either. Leave recovery to the
// owner instead of submitting atlas patches to the invalid device.
if self.device_lost() {
return Err("the graphics device was lost".into());
}
let scaled = self.screenshot_size();
let rendered = match scaled {
Some(size) => self.draw_scaled_frame(frame, size),
@@ -4956,6 +4990,9 @@ impl Renderer {
};
// A frame that was not drawn still submits the section copies and
// atlas patches (glyphs, skins, maps), which the caller already took.
if self.device_lost() {
return Err("the graphics device was lost".into());
}
if !matches!(rendered, Ok(true)) && !frame.hud.patches.is_empty() {
let section_copies = self.section_copies.take();
let has_section_copies = section_copies.is_some();
@@ -4979,6 +5016,11 @@ impl Renderer {
// A scaled screenshot's frame never reached the window.
rendered.map(|drawn| drawn && scaled.is_none())
}
/// Whether the device's loss callback has fired. A surface reconfigure
/// alone cannot restore resources belonging to a lost device.
pub fn device_lost(&self) -> bool {
self.device_lost.load(std::sync::atomic::Ordering::Relaxed)
}
/// The frame size of the pending `scaled` screenshot when it is larger than the
/// window: the window's times the Resolution Scale (when above 100%) and
/// `screenshot_quality`, cut down to what one texture and one readback buffer hold,
@@ -5078,6 +5120,7 @@ impl Renderer {
if self.device_lost.load(std::sync::atomic::Ordering::Relaxed) {
return Err("the graphics device was lost".into());
}
self.frame_gen.stop();
if Self::configure(
&self.device,
surface,
@@ -5091,8 +5134,42 @@ impl Renderer {
}
self.surface_unconfigured = false;
}
// Frame generation: its present thread acquires and presents, this frame draws
// into frames of its own. Not over menus that blur the frame, a shader pack's
// own post passes, HDR or the title screen.
let fg = self.frame_gen.on
&& !self.frame_gen.paused
&& self.hdr.is_none()
&& !frame.title
&& frame.menu_blur.is_none()
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
&& self.native.as_ref().is_none_or(|n| n.post.is_none())
&& (self.surface.is_none()
|| self.config.usage.contains(wgpu::TextureUsages::COPY_DST));
if !fg {
self.frame_gen.stop();
}
let acquire = Instant::now();
let acquired = self.surface.as_ref().map(|s| s.get_current_texture());
let fg_frames = fg
.then(|| {
self.frame_gen.frames(
&self.device,
&self.queue,
self.surface.as_ref(),
self.offscreen.as_ref(),
&self.window,
&self.config,
)
})
.flatten();
// The present thread failed: this frame acquires as without frame generation.
let fg = fg_frames.is_some();
let mut fg_made = false;
let acquired = if fg {
None
} else {
self.surface.as_ref().map(|s| s.get_current_texture())
};
self.last_wait = acquire.elapsed();
let (surface_frame, reconfigure) = match acquired {
None => (None, false),
@@ -5128,9 +5205,11 @@ impl Renderer {
};
if surface_frame.is_some() {
self.acquire_validation_failures = 0;
self.frame_gen.paused = false;
}
let size = [self.config.width, self.config.height];
let target_texture = match (&surface_frame, &mut self.hdr) {
_ if let Some((real, _)) = &fg_frames => real.clone(),
(Some(_), Some(hdr)) => hdr.frame(&self.device, size),
// A screenshot of a swapchain without COPY_SRC (OpenGL) draws
// into a texture that has it, then onto the swapchain.
@@ -5197,6 +5276,8 @@ impl Renderer {
);
}
let view = views.primary().view;
// The hand's bob and view (`hand_globals`).
let hand_view = frame.bob * view;
let sun_angle = frame.sky.sun_angle.to_radians();
// Celestial bodies rotate around the X axis; angle 0 = noon.
let sun = Vec3::new(0.0, sun_angle.cos(), -sun_angle.sin());
@@ -6020,6 +6101,19 @@ impl Renderer {
&frame.lightning,
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;
if fg {
self.frame_gen
.halfway_hand(frame.hand, hand_view, &mut self.entity_batch.combined);
self.frame_gen.track_entities(
&self.device,
&self.entity_atlas,
frame.entities,
frame.entity_motion,
frame.camera,
);
}
let all_entities = &self.entity_batch.combined;
let hand_quads = (frame.hand.len() / 4) as u32;
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
@@ -6239,7 +6333,8 @@ impl Renderer {
let keep_depth = pack_reads_world
|| (taa && self.world_post.taa)
|| oit_on
|| native_render::render_needs().world_depth;
|| native_render::render_needs().world_depth
|| fg;
// The pack's program reads the vertices this frame's draws bind.
let pack_draws_terrain = pack_terrain && flat_frame == self.pack_terrain_flat;
// The shadow camera's lists, drawn by the pack's shadow programs.
@@ -6283,7 +6378,7 @@ impl Renderer {
let scaled = self.world_post.prepare(
&self.device,
[self.config.width, self.config.height],
pack_reads_world,
pack_reads_world || fg,
);
let (world_view, world_depth) = match &scaled {
Some((color, depth)) => (color, depth),
@@ -6299,6 +6394,8 @@ impl Renderer {
&& !pack_draws_hand
&& scaled.is_none()
&& self.hdr.is_none()
// Frame generation draws the hand over the real and the made frame after the world.
&& fg_frames.is_none()
&& !native_render::world_drawn_hooked();
if oit_on {
let size = self
@@ -7500,6 +7597,47 @@ impl Renderer {
history_reset,
native.timing.as_mut(),
);
} else if let (Some((_, generated)), Some((_, depth))) = (&fg_frames, &scaled) {
let world = self
.frame_gen
.world(&self.device, &self.world_post, &self.config);
self.world_post
.finish(&self.queue, &mut encoder, &world, self.own_timing.as_mut());
let camera = frame_gen::Camera {
jittered,
view_proj,
depth: depth.clone(),
render_size: self.world_post.size().unwrap_or(size),
jitter: self.world_post.jitter_pixels(),
reproject: self.frame_gen.camera_motion(view_proj, frame.camera),
near: NEAR_PLANE,
far: frame.depth_far,
fov_y: frame.fov_degrees.to_radians(),
};
let generated_view = generated.create_view(&wgpu::TextureViewDescriptor {
format: Some(self.config.format.remove_srgb_suffix()),
..Default::default()
});
fg_made = self.frame_gen.generate(
&self.device,
&self.queue,
&mut encoder,
&self.world_post,
&view_target,
&generated_view,
&camera,
);
if fg_made {
let overlay = Overlay {
hand_quads: hand_quads * u32::from(!pack_draws_hand),
first_hand_vertex: made_hand_vertex,
crosshair,
first_crosshair: debug_depth + debug_top,
world_depth: false,
real: false,
};
self.draw_overlay(&mut encoder, frame, &generated_view, generated, &overlay);
}
} else if scaled.is_some() {
self.world_post.finish(
&self.queue,
@@ -7532,178 +7670,16 @@ impl Renderer {
quad_indices: &self.indices,
});
}
if let Some(hdr) = &self.hdr {
hdr.keep_world(&mut encoder, self.own_timing.as_mut());
}
if hand_quads > 0 && !pack_draws_hand && !hand_in_world {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view_target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("First-person hand"),
timestamp_writes: self.own_timing.as_mut().and_then(|t| t.writes("Hand")),
color_attachments: &attachments,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Discard,
}),
stencil_ops: None,
}),
..Default::default()
});
self.draw_hand(&mut pass, hand_quads, world_quads + additive_quads);
drop(pass);
if let Some(hdr) = &self.hdr {
hdr.mark_hand(&mut encoder, self.own_timing.as_mut());
}
}
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
self.entity_outline.draw(
&mut encoder,
&view_target,
&self.entity_group,
&self.indices,
self.own_timing.as_mut(),
);
// The world icon grabs here: after the level and the hand, before
// the effects and the HUD draw over them (`tryTakeScreenshotIfNeeded`
// runs right after `renderLevel`).
let readback = self
.screenshot
.take_if(|request| request.size.is_some())
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture));
// GameRenderer.render: applyPostEffects, after the level and before the GUI.
if !frame.title
&& !frame.post_effects.effects.is_empty()
&& target_texture
.usage()
.contains(wgpu::TextureUsages::COPY_SRC)
{
let device = &self.device;
self.post_effects
.get_or_insert_with(|| crate::post_chain::PostEffects::new(device))
.apply(
&self.device,
&self.queue,
&mut encoder,
&target_texture,
&view_target,
[self.config.width, self.config.height],
// The scaled world pass drew its own depth buffer.
scaled.is_none().then_some(&self.depth),
&frame.post_effects,
self.own_timing.as_mut(),
);
}
if !frame.screen.is_empty() {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view_target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Screen effects"),
timestamp_writes: self
.own_timing
.as_mut()
.and_then(|t| t.writes("Screen effects")),
color_attachments: &attachments,
..Default::default()
});
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.screen_buffer.slice(..));
for (i, quad) in frame.screen.iter().enumerate() {
let blend = match quad.blend {
crate::screen_effects::Blend::Alpha => 0,
crate::screen_effects::Blend::Vignette => 4,
};
pass.set_pipeline(&self.screen_pipelines[blend + (quad.sampling & 3) as usize]);
pass.draw_indexed(0..6, (i * 4) as i32, 0..1);
}
}
// GameRenderer.render3dHud: the debug crosshair after the hand and
// the screen effects.
if crosshair > 0 {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &view_target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("3d crosshair"),
timestamp_writes: self.own_timing.as_mut().and_then(|t| t.writes("Crosshair")),
color_attachments: &attachments,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Discard,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&self.debug_lines_on_top);
pass.set_bind_group(0, &self.crosshair_group, &[]);
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
let first = debug_depth + debug_top;
pass.draw(0..4, first..first + crosshair);
}
let menu_blur = frame
.menu_blur
.filter(|_| self.config.usage.contains(wgpu::TextureUsages::COPY_SRC));
let blur_at = menu_blur.map_or(0, |_| frame.hud.blur_at);
self.hud.draw_range(
&mut encoder,
&view_target,
&self.depth,
frame.hud,
[self.config.width, self.config.height],
0..blur_at,
self.own_timing.as_mut(),
);
if let Some(radius) = menu_blur {
let format = self.config.format;
let device = &self.device;
self.menu_blur
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format))
.apply(
&self.device,
&self.queue,
&mut encoder,
&target_texture,
&view_target,
[self.config.width, self.config.height],
radius,
self.own_timing.as_mut(),
);
}
self.hud.draw_range(
&mut encoder,
&view_target,
&self.depth,
frame.hud,
[self.config.width, self.config.height],
blur_at..frame.hud.vertices.len() as u32,
self.own_timing.as_mut(),
);
let overlay = Overlay {
hand_quads: hand_quads * u32::from(!pack_draws_hand && !hand_in_world),
first_hand_vertex: ((world_quads + additive_quads) * 4) as i32,
crosshair,
first_crosshair: debug_depth + debug_top,
world_depth: scaled.is_none(),
real: true,
};
let readback =
self.draw_overlay(&mut encoder, frame, &view_target, &target_texture, &overlay);
if culling && let Some(cull) = &mut self.cull {
cull.end(&self.device, &mut encoder);
}
@@ -7714,10 +7690,15 @@ impl Renderer {
(&view_target, self.config.format.remove_srgb_suffix()),
);
}
// Under `HORIZON_FRAMEGEN_DEBUG=generated` a screenshot is the made frame.
let shown_texture = match &fg_frames {
Some((_, generated)) if fg_made && frame_gen::generated_only() => generated,
_ => &target_texture,
};
let readback = readback.or_else(|| {
self.screenshot
.take()
.and_then(|request| self.take_readback(request, &mut encoder, &target_texture))
.and_then(|request| self.take_readback(request, &mut encoder, shown_texture))
});
self.uploads.finish(&encoder);
if has_section_copies {
@@ -7751,7 +7732,9 @@ impl Renderer {
// Finished, submitted and presented on the submit thread when nothing after the
// submission needs this one: screenshots' readbacks, the error scopes (one per
// thread), the first frame's startup log, VSync's frame callback, a reconfigure.
// Frame generation presents its own frames (`frame_gen.present` below).
let (encoder, surface_frame, after) = if readback.is_none()
&& fg_frames.is_none()
&& hdr_readback.is_none()
&& checks.is_none()
&& !first_frame
@@ -7856,7 +7839,11 @@ impl Renderer {
if reconfigure {
self.configure_surface();
}
} else {
} else if let Some((real, generated)) = fg_frames {
self.frame_gen.present(real, generated, fg_made);
}
if self.surface.is_none() {
// No swapchain paces offscreen frames: keep two on the GPU at most.
self.in_flight.push_back(submission);
if !self.paced_elsewhere {
self.wait_in_flight();
@@ -7871,6 +7858,204 @@ impl Renderer {
Ok(true)
}
}
impl Renderer {
/// What draws over the world, on the real frame and frame generation's made one:
/// the hand, entity outlines, post effects, screen effects, the debug crosshair and
/// the HUD. Returns the world icon's readback, taken on the real frame.
fn draw_overlay(
&mut self,
encoder: &mut wgpu::CommandEncoder,
frame: &Frame,
view_target: &wgpu::TextureView,
target_texture: &wgpu::Texture,
overlay: &Overlay,
) -> Option<(ScreenshotRequest, wgpu::Buffer)> {
// Horizon's own pass timings (#5887) time the real frame's overlay only: a made frame's
// would count the same passes twice.
let mut timing = if overlay.real {
self.own_timing.take()
} else {
None
};
if let Some(hdr) = &self.hdr {
hdr.keep_world(encoder, timing.as_mut());
}
if overlay.hand_quads > 0 {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: view_target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("First-person hand"),
timestamp_writes: timing.as_mut().and_then(|t| t.writes("Hand")),
color_attachments: &attachments,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Discard,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&self.entity_pipeline);
pass.set_bind_group(0, &self.hand_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.draw_indexed(0..overlay.hand_quads * 6, overlay.first_hand_vertex, 0..1);
drop(pass);
if let Some(hdr) = &self.hdr {
hdr.mark_hand(encoder, timing.as_mut());
}
}
// GameRenderer.render: LevelRenderer.blitEntityOutline after the level and the hand.
self.entity_outline.draw(
encoder,
view_target,
&self.entity_group,
&self.indices,
timing.as_mut(),
);
// The world icon grabs here: after the level and the hand, before
// the effects and the HUD draw over them (`tryTakeScreenshotIfNeeded`
// runs right after `renderLevel`).
let readback = self
.screenshot
.take_if(|request| overlay.real && request.size.is_some())
.and_then(|request| self.take_readback(request, encoder, target_texture));
// GameRenderer.render: applyPostEffects, after the level and before the GUI.
if !frame.title
&& !frame.post_effects.effects.is_empty()
&& target_texture
.usage()
.contains(wgpu::TextureUsages::COPY_SRC)
{
let device = &self.device;
self.post_effects
.get_or_insert_with(|| crate::post_chain::PostEffects::new(device))
.apply(
&self.device,
&self.queue,
encoder,
target_texture,
view_target,
[self.config.width, self.config.height],
// The scaled world pass drew its own depth buffer.
overlay.world_depth.then_some(&self.depth),
&frame.post_effects,
timing.as_mut(),
);
}
if !frame.screen.is_empty() {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: view_target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Screen effects"),
timestamp_writes: timing.as_mut().and_then(|t| t.writes("Screen effects")),
color_attachments: &attachments,
..Default::default()
});
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.screen_buffer.slice(..));
for (i, quad) in frame.screen.iter().enumerate() {
let blend = match quad.blend {
crate::screen_effects::Blend::Alpha => 0,
crate::screen_effects::Blend::Vignette => 4,
};
pass.set_pipeline(&self.screen_pipelines[blend + (quad.sampling & 3) as usize]);
pass.draw_indexed(0..6, (i * 4) as i32, 0..1);
}
}
// GameRenderer.render3dHud: the debug crosshair after the hand and
// the screen effects.
if overlay.crosshair > 0 {
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: view_target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("3d crosshair"),
timestamp_writes: timing.as_mut().and_then(|t| t.writes("Crosshair")),
color_attachments: &attachments,
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(0.0),
store: wgpu::StoreOp::Discard,
}),
stencil_ops: None,
}),
..Default::default()
});
pass.set_pipeline(&self.debug_lines_on_top);
pass.set_bind_group(0, &self.crosshair_group, &[]);
pass.set_vertex_buffer(0, self.debug_line_buffer.slice(..));
let first = overlay.first_crosshair;
pass.draw(0..4, first..first + overlay.crosshair);
}
let menu_blur = frame
.menu_blur
.filter(|_| self.config.usage.contains(wgpu::TextureUsages::COPY_SRC));
let blur_at = menu_blur.map_or(0, |_| frame.hud.blur_at);
self.hud.draw_range(
encoder,
view_target,
&self.depth,
frame.hud,
[self.config.width, self.config.height],
0..blur_at,
timing.as_mut(),
);
if let Some(radius) = menu_blur {
let format = self.config.format;
let device = &self.device;
self.menu_blur
.get_or_insert_with(|| crate::menu_blur::MenuBlur::new(device, format))
.apply(
&self.device,
&self.queue,
encoder,
target_texture,
view_target,
[self.config.width, self.config.height],
radius,
timing.as_mut(),
);
}
self.hud.draw_range(
encoder,
view_target,
&self.depth,
frame.hud,
[self.config.width, self.config.height],
blur_at..frame.hud.vertices.len() as u32,
timing.as_mut(),
);
if overlay.real {
self.own_timing = timing;
}
readback
}
}
/// Out-of-memory and validation error scopes, popped together: the first error either caught.
struct GpuChecks {
memory: wgpu::ErrorScopeGuard,
@@ -8210,8 +8395,24 @@ fn camera_section(camera: DVec3, min_y: i32) -> SectionKey {
)
}
/// The near plane of the world's projection, in blocks.
const NEAR_PLANE: f32 = 0.05;
/// Counts and offsets `Renderer::draw_overlay` draws from this frame's buffers.
struct Overlay {
/// Zero when a shader pack draws the hand.
hand_quads: u32,
first_hand_vertex: i32,
crosshair: u32,
first_crosshair: u32,
/// The world drew into `depth` (post effects may read it), not a scaled target's.
world_depth: bool,
/// The real frame, not frame generation's made one: the world icon grabs it.
real: bool,
}
fn world_projection(fov_degrees: f32, aspect: f32, far: f32) -> Mat4 {
Mat4::perspective_rh(fov_degrees.to_radians(), aspect, far, 0.05)
Mat4::perspective_rh(fov_degrees.to_radians(), aspect, far, NEAR_PLANE)
}
/// `view_proj` mirrored about the horizontal plane at the camera-relative height `plane`:
@@ -1,11 +1,572 @@
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
//! two real ones. So far this holds its optical flow, its interpolation and the motion
//! vectors of what moves on its own; the setting that runs them comes next.
//! Frame generation (a Horizon setting, off by default): FSR 3.1's frame interpolation
//! makes a frame halfway between each two real ones. The world, without the hand and
//! HUD, is kept at the screen's size each frame; optical flow and the interpolation run
//! on it, the hand and HUD are drawn over the made frame as over the real one, and a
//! present thread shows the made frame, then the real one half a real frame later.
//!
//! The renderer draws into frames of its own while this is on; the present thread
//! copies each to the swapchain, which it alone acquires and presents.
// Unused until the renderer runs them.
#[allow(dead_code)]
mod interpolation;
#[allow(dead_code)]
mod motion;
#[allow(dead_code)]
mod optical_flow;
use glam::{DMat4, DVec3, Mat4};
use std::{
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
},
thread,
time::{Duration, Instant},
};
/// Frames to draw into: a pair being drawn, a pair waiting and a pair being shown.
const FRAMES: usize = 6;
/// `HORIZON_FRAMEGEN_DEBUG=generated`: show only made frames (AMD's debug view), so a
/// screenshot or an offscreen frame is the made one; `motion`: the same, each made frame
/// showing what moved on its own in blue (its motion vectors in red and green).
pub(super) fn generated_only() -> bool {
debug().is_some()
}
fn debug() -> Option<&'static str> {
static DEBUG: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
DEBUG
.get_or_init(|| {
std::env::var("HORIZON_FRAMEGEN_DEBUG")
.ok()
.filter(|v| v == "generated" || v == "motion")
})
.as_deref()
}
pub struct FrameGen {
pub on: bool,
/// The present thread failed to show a frame (or died): until the renderer has
/// acquired a frame itself, the normal path runs and handles the surface.
pub paused: bool,
gpu: Option<Gpu>,
presenter: Option<Presenter>,
/// When the last real frame was handed over, and the time between real frames.
last: Option<Instant>,
interval: Duration,
/// The last frame's unjittered `view_proj` and camera.
previous: Option<(DMat4, DVec3)>,
/// The last frame's hand vertices and the bob and view they were drawn with.
hand: Vec<crate::mesher::Vertex>,
hand_view: Mat4,
/// Offscreen, the frame the made frame is drawn into.
offscreen_generated: Option<wgpu::Texture>,
/// Motion vectors for what moves on its own, made on the first frame.
motion: Option<motion::ObjectMotion>,
/// Real frames and made ones handed to the screen since `shown_since`, and the share
/// of real frames that had a made frame over the last half second (the F3 screen's),
/// `None` while off.
real: u32,
made: u32,
shown_since: Instant,
pub made_share: Option<f32>,
}
/// What runs on the GPU for one screen size.
struct Gpu {
size: [u32; 2],
flow: optical_flow::OpticalFlow,
interpolation: interpolation::Interpolation,
/// This frame's world at the screen's size, and bindings that copy it or the
/// interpolation's output (`Post::finish_with`).
world: wgpu::TextureView,
world_group: wgpu::BindGroup,
output_group: wgpu::BindGroup,
/// The next interpolation has no previous frame.
reset: bool,
}
/// This frame's camera, for the interpolation.
pub struct Camera {
/// The world's projection this frame (with TAA's jitter) and without the jitter.
pub jittered: Mat4,
pub view_proj: Mat4,
pub depth: wgpu::TextureView,
pub render_size: [u32; 2],
pub jitter: [f32; 2],
/// This frame's unjittered clip space to the previous frame's, `None` without one.
pub reproject: Option<Mat4>,
pub near: f32,
pub far: f32,
pub fov_y: f32,
}
impl FrameGen {
pub fn new() -> Self {
Self {
on: false,
paused: false,
gpu: None,
presenter: None,
last: None,
interval: Duration::from_millis(16),
previous: None,
hand: Vec::new(),
hand_view: Mat4::IDENTITY,
offscreen_generated: None,
motion: None,
real: 0,
made: 0,
shown_since: Instant::now(),
made_share: None,
}
}
/// Ends frame generation's frames and present thread: before the surface is
/// reconfigured or the renderer acquires from it itself.
pub fn stop(&mut self) {
self.presenter = None;
self.made_share = None;
(self.real, self.made) = (0, 0);
self.last = None;
self.previous = None;
self.hand.clear();
if let Some(motion) = &mut self.motion {
motion.reset();
}
if let Some(gpu) = &mut self.gpu {
gpu.reset = true;
}
}
/// The hand for the made frame, appended to `out`: each vertex halfway between where
/// it was on screen last frame and where it is now (the hand swings and bobs on its
/// own, so the world's interpolation can't move it), in this frame's space
/// (`view`: this frame's bob and view). This frame's hand where the two differ in
/// shape (another item, the first frame).
pub fn halfway_hand(
&mut self,
hand: &[crate::mesher::Vertex],
view: Mat4,
out: &mut Vec<crate::mesher::Vertex>,
) {
let same = self.hand.len() == hand.len()
&& self
.hand
.iter()
.zip(hand)
.all(|(a, b)| a.layer == b.layer && a.uv == b.uv);
if same {
let back = view.inverse();
out.extend(self.hand.iter().zip(hand).map(|(before, now)| {
let then = self.hand_view.transform_point3(before.pos.into());
let mid = (then + view.transform_point3(now.pos.into())) * 0.5;
crate::mesher::Vertex {
pos: back.transform_point3(mid).into(),
..*now
}
}));
} else {
out.extend_from_slice(hand);
}
self.hand.clear();
self.hand.extend_from_slice(hand);
self.hand_view = view;
}
/// This frame's entity vertices (relative to `camera`) and the entity ids of their
/// spans (`Frame::entity_motion`), for their own motion vectors; `atlas`: the
/// entities' textures. Before `generate`, every frame.
pub fn track_entities(
&mut self,
device: &wgpu::Device,
atlas: &wgpu::TextureView,
entities: &[crate::mesher::Vertex],
spans: &[(u32, u64)],
camera: DVec3,
) {
self.motion
.get_or_insert_with(|| motion::ObjectMotion::new(device, atlas.clone()))
.track(entities, spans, camera);
}
/// This frame's unjittered clip space to the last frame's, from their `view_proj`s
/// (relative to `camera`); `None` on the first frame.
pub fn camera_motion(&mut self, view_proj: Mat4, camera: DVec3) -> Option<Mat4> {
let current = view_proj.as_dmat4();
let previous = self.previous.replace((current, camera));
previous.map(|(p, pc)| super::world_post::reprojection(p, pc, current, camera).as_mat4())
}
/// Frames to draw the real and the made frame into, from the present thread's
/// (started for this `config`'s surface) or, offscreen, the offscreen target and
/// one of its own. `None` once the present thread failed or died: it is stopped and
/// frame generation `paused`.
pub fn frames(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
surface: Option<&Arc<wgpu::Surface<'static>>>,
offscreen: Option<&wgpu::Texture>,
window: &Arc<winit::window::Window>,
config: &wgpu::SurfaceConfiguration,
) -> Option<(wgpu::Texture, wgpu::Texture)> {
let Some(surface) = surface else {
let real = offscreen.cloned().unwrap_or_else(|| frame(device, config));
if self
.offscreen_generated
.as_ref()
.is_none_or(|t| t.size() != real.size())
{
self.offscreen_generated = Some(frame(device, config));
}
let generated = self.offscreen_generated.clone().expect("made above");
return Some((real, generated));
};
if self.presenter.as_ref().is_some_and(|p| p.config != *config) {
self.presenter = None;
}
let presenter = self.presenter.get_or_insert_with(|| {
Presenter::start(device, queue, surface.clone(), window.clone(), config)
});
// A closed channel: the thread died.
let pair = if presenter.failed() {
None
} else {
presenter.free.recv().ok().zip(presenter.free.recv().ok())
};
if pair.is_none() {
self.stop();
self.paused = true;
}
pair
}
/// The world's target at the screen's size this frame, where `Post::finish` draws.
pub fn world(
&mut self,
device: &wgpu::Device,
post: &super::world_post::Post,
config: &wgpu::SurfaceConfiguration,
) -> wgpu::TextureView {
let size = [config.width, config.height];
if self.gpu.as_ref().is_none_or(|g| g.size != size) {
let world = device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame generation world"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: config.format.remove_srgb_suffix(),
// COPY_SRC: the interpolation keeps it as the previous frame.
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
})
.create_view(&Default::default());
let interpolation = interpolation::Interpolation::new(device, size);
self.gpu = Some(Gpu {
size,
flow: optical_flow::OpticalFlow::new(device, size),
world_group: post.bind(device, &world),
output_group: post.bind(device, interpolation.output()),
interpolation,
world,
reset: true,
});
}
self.gpu.as_ref().expect("made above").world.clone()
}
/// Copy the world (`world`) onto `screen` for the real frame, and draw the made
/// frame's world onto `generated`. Returns false when there is no made frame (the
/// first after a reset): `generated` then holds this frame's world too.
#[allow(clippy::too_many_arguments)]
pub fn generate(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
post: &super::world_post::Post,
screen: &wgpu::TextureView,
generated: &wgpu::TextureView,
camera: &Camera,
) -> bool {
let gpu = self.gpu.as_mut().expect("world() ran this frame");
post.finish_with(encoder, screen, &gpu.world_group, None);
let reset = gpu.reset || camera.reproject.is_none();
let reproject = camera.reproject.unwrap_or(Mat4::IDENTITY);
let object_motion = self
.motion
.as_mut()
.expect("track_entities() ran this frame")
.draw(
device,
queue,
encoder,
&camera.depth,
camera.render_size,
camera.jittered,
camera.view_proj,
reproject * camera.view_proj,
);
gpu.flow.dispatch(device, queue, encoder, &gpu.world, reset);
gpu.interpolation.dispatch(
device,
queue,
encoder,
&interpolation::Inputs {
color: &gpu.world,
depth: &camera.depth,
render_size: camera.render_size,
jitter: camera.jitter,
reproject,
object_motion: &object_motion,
near: camera.near,
far: camera.far,
fov_y: camera.fov_y,
reset,
flow_vectors: gpu.flow.vectors(),
scene_change: gpu.flow.scene_change(),
},
);
gpu.reset = false;
if debug() == Some("motion") {
post.finish_with(encoder, generated, &post.bind(device, &object_motion), None);
return true;
}
if reset {
post.finish_with(encoder, generated, &gpu.world_group, None);
} else {
post.finish_with(encoder, generated, &gpu.output_group, None);
}
!reset
}
/// Hand this frame's pair to the present thread, after the frame's submission: the
/// made frame (when `made`) goes first.
pub fn present(&mut self, real: wgpu::Texture, generated: wgpu::Texture, made: bool) {
let now = Instant::now();
if let Some(last) = self.last {
// A real frame's time, smoothed: the made frame shows for half of it.
let dt = (now - last).min(Duration::from_millis(250));
self.interval = self.interval.mul_f32(0.9) + dt.mul_f32(0.1);
}
self.last = Some(now);
self.real += 1;
self.made += u32::from(made && !generated_only());
if now - self.shown_since >= Duration::from_millis(500) {
self.made_share = Some(self.made as f32 / self.real as f32);
(self.real, self.made, self.shown_since) = (0, 0, now);
}
let Some(send) = self.presenter.as_ref().and_then(|p| p.send.as_ref()) else {
return;
};
let pair = if generated_only() {
Pair {
first: None,
second: generated,
spare: Some(real),
interval: self.interval,
}
} else if made {
Pair {
first: Some(generated),
second: real,
spare: None,
interval: self.interval,
}
} else {
Pair {
first: None,
second: real,
spare: Some(generated),
interval: self.interval,
}
};
let _ = send.send(pair);
}
}
/// A frame to draw into, like the swapchain's.
fn frame(device: &wgpu::Device, config: &wgpu::SurfaceConfiguration) -> wgpu::Texture {
device.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame generation frame"),
size: wgpu::Extent3d {
width: config.width,
height: config.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: config.format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC
| wgpu::TextureUsages::COPY_DST,
view_formats: &config.view_formats,
})
}
/// One real frame's frames for the present thread: `first` (the made frame) now,
/// `second` (the real one) half an `interval` later; `spare` goes straight back.
struct Pair {
first: Option<wgpu::Texture>,
second: wgpu::Texture,
spare: Option<wgpu::Texture>,
interval: Duration,
}
/// The thread that copies frames to the swapchain and presents them on time.
struct Presenter {
config: wgpu::SurfaceConfiguration,
send: Option<mpsc::SyncSender<Pair>>,
free: mpsc::Receiver<wgpu::Texture>,
/// The surface gave something other than a frame: the renderer takes it back.
failed: Arc<AtomicBool>,
thread: Option<thread::JoinHandle<()>>,
}
impl Presenter {
fn start(
device: &wgpu::Device,
queue: &wgpu::Queue,
surface: Arc<wgpu::Surface<'static>>,
window: Arc<winit::window::Window>,
config: &wgpu::SurfaceConfiguration,
) -> Self {
// One pair waiting at most: the renderer then waits as for a swapchain image.
let (send, receive) = mpsc::sync_channel::<Pair>(1);
let (give, free) = mpsc::channel();
for _ in 0..FRAMES {
let _ = give.send(frame(device, config));
}
let (device, queue) = (device.clone(), queue.clone());
let vsync = config.present_mode == wgpu::PresentMode::Fifo;
let failed = Arc::new(AtomicBool::new(false));
let failed_here = failed.clone();
let thread = thread::Builder::new()
.name("Frame generation present".into())
.spawn(move || {
let show = |texture: &wgpu::Texture| {
if failed_here.load(Ordering::Relaxed) {
return;
}
// Anything but a frame (outdated, lost, occluded...): the renderer
// stops this thread and acquires itself, handling it as it does
// without frame generation. A suboptimal frame still shows.
let target = match surface.get_current_texture() {
wgpu::CurrentSurfaceTexture::Success(t) => t,
wgpu::CurrentSurfaceTexture::Suboptimal(t) => {
failed_here.store(true, Ordering::Relaxed);
t
}
_ => {
failed_here.store(true, Ordering::Relaxed);
return;
}
};
let mut encoder = device.create_command_encoder(&Default::default());
encoder.copy_texture_to_texture(
texture.as_image_copy(),
target.texture.as_image_copy(),
target.texture.size(),
);
queue.submit([encoder.finish()]);
if vsync {
window.pre_present_notify();
}
queue.present(target);
};
while let Ok(pair) = receive.recv() {
if let Some(first) = pair.first {
let shown = Instant::now();
show(&first);
let _ = give.send(first);
// ponytail: sleep pacing; a spin for the last half millisecond
// if it shows uneven on some systems.
let due = shown + pair.interval / 2;
if let Some(wait) = due.checked_duration_since(Instant::now()) {
thread::sleep(wait);
}
}
show(&pair.second);
let _ = give.send(pair.second);
if let Some(spare) = pair.spare {
let _ = give.send(spare);
}
}
})
.expect("spawn the frame generation present thread");
Self {
config: config.clone(),
send: Some(send),
free,
failed,
thread: Some(thread),
}
}
/// The surface failed it or the thread died (a panic).
fn failed(&self) -> bool {
self.failed.load(Ordering::Relaxed) || self.thread.as_ref().is_none_or(|t| t.is_finished())
}
}
impl Drop for Presenter {
fn drop(&mut self) {
self.send = None;
if let Some(thread) = self.thread.take() {
let _ = thread.join();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mesher::Vertex;
use glam::Vec3;
fn vertex(pos: Vec3) -> Vertex {
Vertex {
pos: pos.into(),
..bytemuck::Zeroable::zeroed()
}
}
/// A swing lands halfway; a hand still on screen while the camera turns stays still;
/// another item shows as it is.
#[test]
fn the_made_frame_s_hand_is_halfway_on_screen() {
let mut fg = FrameGen::new();
let mut out = Vec::new();
fg.halfway_hand(
&[vertex(Vec3::new(0.0, 0.0, -1.0))],
Mat4::IDENTITY,
&mut out,
);
fg.halfway_hand(
&[vertex(Vec3::new(0.4, 0.0, -1.0))],
Mat4::IDENTITY,
&mut out,
);
assert!((Vec3::from(out[1].pos) - Vec3::new(0.2, 0.0, -1.0)).length() < 1e-6);
// The camera turned: the vertex turned with it, the same on screen.
let turned = Mat4::from_rotation_y(0.3);
let on_screen = Vec3::new(0.4, 0.0, -1.0);
let pos = turned.inverse().transform_point3(on_screen);
fg.halfway_hand(&[vertex(pos)], turned, &mut out);
assert!((Vec3::from(out[2].pos) - pos).length() < 1e-6);
let mut other = vertex(Vec3::ZERO);
other.uv = [0.5, 0.5];
fg.halfway_hand(&[other], turned, &mut out);
assert_eq!(out[3].pos, [0.0; 3]);
}
}
@@ -42,7 +42,7 @@ struct HorizonMaterialOut {
}
fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>, origin: vec3<f32>) -> HorizonMaterialOut {
let pos = position + origin;
let pos = horizon_material_displace(position + origin, uv, layer, color, light);
var out: HorizonMaterialOut;
out.clip = horizon_terrain_globals.view_proj * vec4(pos, 1.0);
out.uv = uv;
@@ -220,7 +220,7 @@ fn vanilla_terrain() -> String {
/// The function `name(layer)`, the value of each sprite layer in `table` and 0 for the rest:
/// `horizon_sprite_tags` (`MaterialInput.tags`) and `horizon_sprite_material`
/// (`MaterialInput.material`).
fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
pub(super) fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
let mut out = format!("fn {name}(layer: u32) -> u32 {{\n switch layer {{\n");
let mut table = table.to_vec();
table.sort_unstable_by_key(|&(layer, value)| (value, layer));
@@ -235,16 +235,47 @@ fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
out + " default: { return 0u; }\n }\n}\n"
}
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
/// `materials`.
/// Where a corner of terrain is drawn without a vertex stage.
pub(super) const DISPLACE_VANILLA: &str = "
fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>) -> vec3<f32> {
return pos;
}
";
/// The vertex stage's call, with its inputs from terrain's vertex.
pub(super) const DISPLACE_PACK: &str = "
fn horizon_material_displace(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>,
light: vec2<u32>) -> vec3<f32> {
let flags = u32(round(color.a * 255.0));
let high = flags >> 4u;
let fluid = high >= 8u && (high & 7u) < 6u;
let lit = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
return vertex(VertexInput(pos, uv, horizon_sprite_material(layer & 0x7fffu), lit,
vec4(color.rgb, 1.0), select(0u, high & 7u, fluid), u32(fluid)));
}
";
/// The surface target (the post stage's `scene_surface`): x the normal (two snorm16 in its
/// bits), y smoothness, z the pack's data, w the fragment's depth.
pub const SURFACE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba32Float;
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> String {
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
/// `materials`, and the vertex stage in its module when `vertex`.
fn shader_source(
source: &str,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
vertex: bool,
) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{ENTRIES}",
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{}{ENTRIES}",
vanilla_terrain(),
if vertex {
DISPLACE_PACK
} else {
DISPLACE_VANILLA
},
layer_table("horizon_sprite_tags", tags),
layer_table("horizon_sprite_material", materials)
)
@@ -255,8 +286,9 @@ fn module(
stage: &CheckedStage,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
vertex: bool,
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials);
let text = shader_source(&stage.source, tags, materials, vertex);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -270,9 +302,10 @@ impl NativeMaterial {
stage: &CheckedStage,
constants: &[(String, f64)],
fog: bool,
vertex: bool,
) -> Result<Self, String> {
let writes_surface = horizon_shader_api::stages::calls_set_surface(&stage.source);
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
let module = module(stage, shape.sprite_tags, shape.sprite_materials, vertex)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
"horizon_terrain_SPRITE_ARRAYS",
@@ -459,7 +492,7 @@ mod tests {
let tags = [(7, 1), (3, 1), (9, 2)];
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials).unwrap();
let module = module(&stage, &tags, &materials, false).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
@@ -468,7 +501,7 @@ mod tests {
.unwrap_or_else(|e| {
panic!(
"{}",
e.emit_to_string(&shader_source(&stage.source, &tags, &materials))
e.emit_to_string(&shader_source(&stage.source, &tags, &materials, false))
)
});
let entries: Vec<&str> = module
@@ -48,7 +48,17 @@ impl NativePack {
let shadow = report
.shadow
.as_ref()
.map(|s| NativeShadow::new(device, terrain, &frame, &s.solid_materials))
.map(|s| {
let vertex = report.stages.iter().find(|s| s.kind == StageKind::Vertex);
NativeShadow::new(
device,
terrain,
&frame,
&s.solid_materials,
vertex,
constants,
)
})
.transpose()?;
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
@@ -91,6 +101,7 @@ impl NativePack {
.iter()
.find(|s| s.kind == StageKind::Weather)
.map(|s| s.label.clone());
let has_vertex = report.stages.iter().any(|s| s.kind == StageKind::Vertex);
let (post, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
@@ -103,7 +114,9 @@ impl NativePack {
let material = others
.iter()
.find(|s| s.kind == StageKind::Material)
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants, fog))
.map(|stage| {
NativeMaterial::new(device, terrain, &frame, stage, constants, fog, has_vertex)
})
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
@@ -41,17 +41,21 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
}
@vertex fn horizon_shadow_vs(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>,
@location(2) layer: u32, @location(3) color: vec4<f32>, @location(4) light: vec2<u32>,
@location(5) origin: vec4<f32>) -> HorizonShadowOut {
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
let atlas = horizon_terrain_terrain_uv(uv, origin.w);
let pos = horizon_material_displace(position + origin.xyz, atlas, layer, color, light);
return horizon_shadow_vertex(pos, atlas, layer, color);
}
@vertex fn horizon_shadow_vs_flat(
@builtin(vertex_index) index: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32,
@location(3) color: vec4<f32>
@location(3) color: vec4<f32>, @location(4) light: vec2<u32>
) -> HorizonShadowOut {
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[index >> 8u]];
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
let atlas = horizon_terrain_terrain_uv(uv, origin.w);
let pos = horizon_material_displace(position + origin.xyz, atlas, layer, color, light);
return horizon_shadow_vertex(pos, atlas, layer, color);
}
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
@@ -415,12 +419,24 @@ fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
/// `solid` of `shadow_solid`.
fn shader_source(solid: &str) -> String {
fn shader_source(solid: &str, vertex: Option<&str>, materials: &[(u32, u32)]) -> String {
let (head, displace) = match vertex {
Some(stage) => (
stage.to_owned()
+ &super::native_material::layer_table("horizon_sprite_material", materials),
super::native_material::DISPLACE_PACK,
),
None => (
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
),
super::native_material::DISPLACE_VANILLA,
),
};
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}\
// Horizon's terrain (terrain.wgsl)\n{}{solid}{ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
"{head}// Horizon's terrain (terrain.wgsl)\n{}{solid}{displace}{ENTRIES}",
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX
@@ -434,14 +450,21 @@ impl NativeShadow {
shape: &TerrainShape<'_>,
frame: &super::native_post::NativeFrame,
solid_materials: &[u32],
vertex: Option<&horizon_shader_api::check::CheckedStage>,
constants: &[(String, f64)],
) -> Result<Self, String> {
let text = shader_source(&shadow_solid(shape.sprite_materials, solid_materials));
let text = shader_source(
&shadow_solid(shape.sprite_materials, solid_materials),
vertex.map(|s| s.source.as_str()),
shape.sprite_materials,
);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
let constants = [
let mut constants = super::native_post::present_constants(&module, constants);
constants.extend([
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
];
]);
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack shadow"),
@@ -593,7 +616,7 @@ mod tests {
#[test]
fn the_shadow_shader_validates() {
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]));
let text = shader_source(&shadow_solid(materials, &[7]), None, materials);
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
@@ -299,6 +299,16 @@ impl Post {
Mat4::from_translation(jitter.extend(0.0))
}
/// This frame's jitter in rendered pixels, y down (FSR's `jitterOffset`); zero
/// without TAA.
pub fn jitter_pixels(&self) -> [f32; 2] {
let Some(size) = self.size().filter(|_| self.taa_on) else {
return [0.0; 2];
};
let j = self.taa_this_frame.jitter;
[j[0] * size[0] as f32 * 0.5, -j[1] * size[1] as f32 * 0.5]
}
/// The world's colour and depth targets this frame, or `None` to draw
/// it straight to the screen.
/// `always`: a target even at scale 1 without anti-aliasing (a shader pack reads it).
@@ -507,7 +517,12 @@ impl Post {
/// This frame's clip space to the previous frame's, for camera-relative projections:
/// a point `camera - previous_camera` further from the previous camera than from this one.
fn reprojection(previous: DMat4, previous_camera: DVec3, current: DMat4, camera: DVec3) -> DMat4 {
pub(super) fn reprojection(
previous: DMat4,
previous_camera: DVec3,
current: DMat4,
camera: DVec3,
) -> DMat4 {
previous * DMat4::from_translation(camera - previous_camera) * current.inverse()
}
+37 -1
View File
@@ -287,6 +287,36 @@ impl FarDetail {
}
}
/// Frame generation: a frame made between each two real ones.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum FrameGeneration {
#[default]
Off,
/// AMD FidelityFX Super Resolution 3.1's frame interpolation, ported to WGSL.
Fsr31,
}
impl FrameGeneration {
pub const ALL: [FrameGeneration; 2] = [FrameGeneration::Off, FrameGeneration::Fsr31];
pub fn name(self) -> &'static str {
match self {
FrameGeneration::Off => "off",
FrameGeneration::Fsr31 => "fsr3.1",
}
}
pub fn label(self) -> &'static str {
match self {
FrameGeneration::Off => "Off",
FrameGeneration::Fsr31 => "FSR 3.1",
}
}
fn parse(s: &str) -> Self {
Self::ALL
.into_iter()
.find(|f| f.name() == s)
.unwrap_or_default()
}
}
/// What the section mesher does with leaf faces inside a canopy. Terrain
/// geometry only: entities, particles and block entities are untouched.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
@@ -542,6 +572,8 @@ pub struct Graphics {
pub upscaling: Upscaling,
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
pub screenshot_quality: u32,
/// Frame generation (Horizon setting), off by default.
pub frame_generation: FrameGeneration,
/// HDR output (HDR10, or scRGB), where the display path offers it.
pub hdr: bool,
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
@@ -604,6 +636,7 @@ impl Default for Graphics {
taa: false,
upscaling: Upscaling::Native,
screenshot_quality: 1,
frame_generation: FrameGeneration::Off,
hdr: false,
hdr_paper_white: 0,
hdr_peak: 0,
@@ -685,6 +718,7 @@ impl Graphics {
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
.unwrap_or(1)
}
"frameGeneration" => g.frame_generation = FrameGeneration::parse(value),
"hdr" => g.hdr = flag(g.hdr),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
@@ -722,7 +756,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nleafCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nframeGeneration:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\ntranslucentBatching:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -753,6 +787,7 @@ impl Graphics {
bool_text(self.taa),
self.upscaling.name(),
self.screenshot_quality,
self.frame_generation.name(),
bool_text(self.hdr),
self.hdr_paper_white,
self.hdr_peak,
@@ -903,6 +938,7 @@ mod tests {
taa: true,
upscaling: Upscaling::Balanced,
screenshot_quality: 4,
frame_generation: FrameGeneration::Fsr31,
hdr: true,
hdr_paper_white: 240,
hdr_peak: 1400,
@@ -3,8 +3,8 @@
//! input. How they look comes from `graphics_style`.
use crate::{
graphics::{
EntityCullingMode, FarDetail, GraphicsApi, LeafCullingMode, Overlay, Pacing, PresentMode,
Preset, Upscaling,
EntityCullingMode, FarDetail, FrameGeneration, GraphicsApi, LeafCullingMode, Overlay,
Pacing, PresentMode, Preset, Upscaling,
},
graphics_style::{Cost, STYLE},
hud::{HudAtlas, HudBatch},
@@ -46,6 +46,7 @@ pub enum H {
Antialiasing,
Upscaling,
ScreenshotQuality,
FrameGeneration,
Hdr,
/// Opens `Level::Hdr`.
HdrSettings,
@@ -301,6 +302,11 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"F2 screenshots at this many times the game's resolution. Higher takes longer.",
),
h(
H::FrameGeneration,
Medium,
"Shows a made-up frame between each two real ones: smoother, a little more lag.",
),
h(
H::HdrSettings,
None,
@@ -581,6 +587,7 @@ impl H {
H::ScreenshotQuality => {
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
}
H::FrameGeneration => labelled("Frame Generation", g.frame_generation.label()),
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
@@ -611,7 +618,7 @@ impl H {
),
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
H::HdrKey => {
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
}
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
@@ -704,6 +711,9 @@ impl H {
back,
)
}
H::FrameGeneration => {
g.frame_generation = step(&FrameGeneration::ALL, g.frame_generation, back)
}
H::Hdr => g.hdr = !g.hdr,
H::BatteryMode => g.battery = !g.battery,
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
@@ -829,6 +839,7 @@ impl Item {
| H::Pacing
| H::PresentMode
| H::Workers
| H::FrameGeneration
| H::BatteryMode
| H::ShadowCache
| H::GraphicsApi
@@ -1064,9 +1075,12 @@ impl GraphicsUi {
let mut label = item.label(s);
if item == Item::Horizon(H::HdrKey) && self.binding.is_some() {
// KeyBindsScreen: "> key <" while it waits.
label = Text::literal("Shortcut: > ")
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
.append(" <");
label = Text::translate(
"Shortcut: %s",
&[Text::literal("> ")
.append(crate::keybinds::display_name(&s.horizon.hdr_key))
.append(" <")],
);
}
let blocked = item.blocked(s);
let live = blocked.is_none();
@@ -18,6 +18,7 @@
//! or Escape leaves them as they were.
use crate::{
hud::{HudAtlas, HudBatch, rgba},
lang,
settings::Settings,
text::Text,
ui,
@@ -64,14 +65,19 @@ impl Step {
Step::PaperWhite => (10.0, 480.0, 10.0),
}
}
fn name(self) -> &'static str {
/// The English name, which is also its key in the language table.
fn name_key(self) -> &'static str {
match self {
Step::Black => "Black Level",
Step::Peak => "Peak Brightness",
Step::PaperWhite => "Paper White",
}
}
fn hint(self) -> &'static str {
fn name(self) -> String {
lang::get(self.name_key())
}
/// The English hint, which is also its key in the language table.
fn hint_key(self) -> &'static str {
match self {
Step::Black => "Lower it until the logo just disappears into the black.",
Step::Peak => "Raise it until the logo just disappears into the bright square.",
@@ -139,10 +145,16 @@ impl Calibration {
}
fn label(&self) -> Text {
let v = self.values[self.step];
Text::literal(match self.current() {
Step::Black => format!("Black Level: {v:.2} nits"),
step => format!("{}: {v:.0} nits", step.name()),
})
let step = self.current();
let nits = match step {
Step::Black => format!("{v:.2}"),
_ => format!("{v:.0}"),
};
Text::literal(format!(
"{}: {}",
step.name(),
lang::tr("%s nits", &[&nits])
))
}
/// The step, its nits and where the controls start (a share of the height), for
/// HDR output's test picture.
@@ -160,14 +172,12 @@ impl Calibration {
batch.fill(atlas, 0.0, 0.0, w, h, rgba(0, 0, 0, 255));
batch.fill(atlas, 0.0, l.top, w, h - l.top, rgba(24, 24, 24, 255));
let step = self.current();
let title = Text::literal(format!(
"HDR Calibration {}/{}: {}",
self.step + 1,
STEPS.len(),
step.name()
let title = Text::literal(lang::tr(
"HDR Calibration %s/%s: %s",
&[&(self.step + 1), &STEPS.len(), &step.name()],
));
ui::centered_text(batch, atlas, &title, 0.0, l.top + 6.0, w, u32::MAX);
let hint = Text::literal(step.hint());
let hint = Text::literal(lang::get(step.hint_key()));
ui::centered_text(
batch,
atlas,
@@ -192,10 +202,12 @@ impl Calibration {
self.dragging,
);
let last = self.step + 1 == STEPS.len();
let save = lang::get_or("selectWorld.edit.save", "Save");
let next = lang::get("Next");
for (rect, label, active) in [
(l.cancel, "Cancel", true),
(l.back, "Back", self.step > 0),
(l.next, if last { "Save" } else { "Next" }, true),
(l.cancel, lang::get_or("gui.cancel", "Cancel"), true),
(l.back, lang::get_or("gui.back", "Back"), self.step > 0),
(l.next, if last { save } else { next }, true),
] {
let hover = over(cursor, rect) && active;
ui::button(
+90 -21
View File
@@ -120,6 +120,7 @@ pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
/// A PNG in memory as RGBA pixels.
pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(data));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![0; reader.output_buffer_size().ok_or("image too large")?];
@@ -214,6 +215,28 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
}
(name.clone(), bw, bh, out)
}
/// Folders of `assets/minecraft/textures/` that [`HudAtlas::pack`] walks for
/// the full atlas. A pack may add others through `atlases/blocks.json`.
const WALKED_TEXTURE_FOLDERS: &[&str] = &[
"gui",
"item",
"block",
"font",
"environment",
"misc",
"painting",
"entity",
"particle",
"mob_effect",
"map",
];
fn walked_minecraft_texture(name: &str) -> bool {
WALKED_TEXTURE_FOLDERS
.iter()
.any(|folder| name.starts_with(folder) && name.as_bytes().get(folder.len()) == Some(&b'/'))
}
/// Sprites the atlas definitions (`atlases/*.json`) add beyond the texture
/// files already packed: generated ones (`paletted_permutations` such as the
/// armour trim sprites, `unstitch` regions) and files listed under another id,
@@ -241,11 +264,13 @@ fn atlas_sprites(root: &Path, packed: &[PackedImage]) -> (Vec<PackedImage>, Vec<
if have.contains(name.as_str()) {
continue;
}
// A vanilla file is packed under its own name already: only a
// renamed one is new. Other namespaces' files (content mods) are
// never in the walked directories.
// A file under a walked folder is packed under its own name already:
// only a renamed one is new. Other namespaces' files (content mods)
// are never in those folders, nor are extra directories a pack adds
// to the blocks atlas (`custom/...`).
if let SpriteOrigin::File(texture) = &origin
&& texture.strip_prefix("minecraft:") == Some(name.as_str())
&& walked_minecraft_texture(name.as_str())
{
continue;
}
@@ -348,24 +373,7 @@ impl HudAtlas {
/// The atlas of a resource root that layers packs over `defaults` (the
/// game's own resources), whose textures give the sprites' GUI sizes.
pub fn build_over(root: &Path, defaults: &Path) -> Result<Self> {
let mut atlas = Self::pack(
root,
defaults,
&[
"gui",
"item",
"block",
"font",
"environment",
"misc",
"painting",
"entity",
"particle",
"mob_effect",
"map",
],
true,
)?;
let mut atlas = Self::pack(root, defaults, WALKED_TEXTURE_FOLDERS, true)?;
atlas.text_shader = pack_text::load(root, defaults);
Ok(atlas)
}
@@ -2151,6 +2159,40 @@ impl HudRenderer {
mod tests {
use super::*;
#[test]
fn decode_bytes_ignores_crc_checksum_error() {
let mut encoded = Vec::new();
{
let mut encoder = png::Encoder::new(&mut encoded, 2, 2);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[
255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255,
])
.unwrap();
writer.finish().unwrap();
}
let idat_pos = encoded
.windows(4)
.position(|w| w == b"IDAT")
.expect("IDAT chunk");
let len = u32::from_be_bytes([
encoded[idat_pos - 4],
encoded[idat_pos - 3],
encoded[idat_pos - 2],
encoded[idat_pos - 1],
]) as usize;
let crc_pos = idat_pos + 4 + len;
encoded[crc_pos] ^= 0xFF;
let (w, h, rgba) = decode_bytes(&encoded).expect("should decode despite bad CRC");
assert_eq!(w, 2);
assert_eq!(h, 2);
assert_eq!(rgba.len(), 16);
}
#[test]
fn clip_since_keeps_the_visible_part_of_a_straddling_triangle() {
let mut batch = HudBatch::new([320, 240]);
@@ -2363,6 +2405,33 @@ mod tests {
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 255, 0, 255]);
}
#[test]
fn a_pack_directory_outside_the_walked_folders_still_packs() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-custom-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let atlases = root.join("assets/minecraft/atlases");
let custom = root.join("assets/minecraft/textures/custom");
fs::create_dir_all(&atlases).unwrap();
fs::create_dir_all(&custom).unwrap();
fs::write(
atlases.join("blocks.json"),
r#"{"sources":[{"type":"directory","source":"custom","prefix":"custom/"}]}"#,
)
.unwrap();
write_strip(
&custom.join("test.png"),
16,
16,
&[[40, 180, 80, 255]],
None,
);
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["custom/test"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (16, 16));
assert_eq!(texel_at(&atlas, rect, 0, 0), [40, 180, 80, 255]);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn another_namespaces_animated_item_texture_packs_its_first_frame_and_animates() {
let root =
std::env::temp_dir().join(format!("horizon-atlas-ns-anim-{}", std::process::id()));
+1
View File
@@ -8,6 +8,7 @@ pub mod auth;
pub mod ban_notice;
pub mod bidi;
pub mod biome;
pub mod block_edit;
pub mod block_entities;
pub mod block_entity_anim;
pub mod block_entity_render;
+60 -13
View File
@@ -592,18 +592,21 @@ pub fn apply_relighting(chunk: &mut Chunk, lights: Vec<Light>, changed: &mut Lig
changed.keyed(chunk.layout());
for (sy, (section, light)) in chunk.sections.iter_mut().zip(lights).enumerate() {
let old = &section.light;
if let (Light::Uniform(a), Light::Uniform(b)) = (old, &light)
&& a == b
{
continue;
}
let mut classes = 0;
for i in 0..4096 {
if old.get(i) != light.get(i) {
let (x, y, z) = (i as i32 & 15, (i >> 8) as i32, (i >> 4) as i32 & 15);
classes |= 1 << LightChanges::class(x, y, z);
let classes = match (old, &light) {
(Light::Uniform(a), Light::Uniform(b)) if a == b => continue,
// (most sections of a chunk relit for a neighbour's edit keep their light: one comparison of the whole)
(Light::Full(a), Light::Full(b)) => LightChanges::classes_differing(a, b),
_ => {
let mut classes = 0;
for i in 0..4096 {
if old.get(i) != light.get(i) {
let (x, y, z) = (i as i32 & 15, (i >> 8) as i32, (i >> 4) as i32 & 15);
classes |= 1 << LightChanges::class(x, y, z);
}
}
classes
}
}
};
if classes != 0 {
changed.add((chunk.x, sy as i32, chunk.z), classes);
Arc::make_mut(section).light = light;
@@ -847,7 +850,7 @@ pub struct LightChanges {
impl LightChanges {
/// Changes in chunks of `layout`: what the relighter or a light job adds
/// after this (they all come from one level).
fn keyed(&mut self, layout: ColumnLayout) {
pub fn keyed(&mut self, layout: ColumnLayout) {
debug_assert!(self.sections.is_empty() || self.layout == layout);
self.layout = layout;
}
@@ -858,7 +861,7 @@ impl LightChanges {
*self.sections.entry(key).or_default() |= 1 << Self::class(x, y, z);
}
/// Cells of section `key` (x, section index, z) in the given classes.
fn add(&mut self, key: Pos, classes: u32) {
pub fn add(&mut self, key: Pos, classes: u32) {
*self.sections.entry(key).or_default() |= classes;
}
/// The class of a cell in its section: per axis 0 on the low face, 1
@@ -871,6 +874,25 @@ impl LightChanges {
};
axis(x) + 3 * axis(y) + 9 * axis(z)
}
/// The classes (bit `class`) of the cells, in a section's index order (`y << 8 | z << 4 | x`), where `a` and
/// `b` differ.
pub fn classes_differing<T: PartialEq>(a: &[T; 4096], b: &[T; 4096]) -> u32 {
let mut classes = 0;
// (a row of 16 along x at a time: the rows alike, most of them, cost one comparison)
let (rows_a, rows_b) = (a.as_chunks::<16>().0, b.as_chunks::<16>().0);
for (row, (ra, rb)) in rows_a.iter().zip(rows_b).enumerate() {
if ra == rb {
continue;
}
let (y, z) = ((row >> 4) as i32, (row & 15) as i32);
for x in 0..16 {
if ra[x] != rb[x] {
classes |= 1 << Self::class(x as i32, y, z);
}
}
}
classes
}
pub fn is_empty(&self) -> bool {
self.sections.is_empty()
}
@@ -1156,6 +1178,31 @@ mod tests {
use super::*;
use crate::chunk::{HEIGHT, MIN_Y, SECTIONS};
#[test]
fn changed_cells_dirty_only_the_sections_within_a_block() {
let dirty = |cells: &[(usize, usize, usize)]| {
let a = [0u8; 4096];
let mut b = a;
for &(x, y, z) in cells {
b[y << 8 | z << 4 | x] = 1;
}
let mut changes = LightChanges::default();
changes.add((0, 5, 0), LightChanges::classes_differing(&a, &b));
let mut out = Vec::new();
changes.dirty_sections(|p| out.push(p));
out.sort_unstable();
out
};
assert_eq!(dirty(&[]), Vec::<Pos>::new());
assert_eq!(dirty(&[(5, 5, 5), (9, 1, 14)]), vec![(0, 5, 0)]);
assert_eq!(dirty(&[(0, 5, 5)]), vec![(-1, 5, 0), (0, 5, 0)]);
assert_eq!(
dirty(&[(15, 15, 5)]),
vec![(0, 5, 0), (0, 6, 0), (1, 5, 0), (1, 6, 0)]
);
assert_eq!(dirty(&[(0, 0, 0)]).len(), 8);
}
const AIR: u32 = 0;
const STONE: u32 = 1;
const TORCH: u32 = 2;
+10 -2
View File
@@ -1102,6 +1102,8 @@ pub struct Mesher<'a> {
pub smooth_lighting: bool,
/// The `cutoutLeaves` option (fancy graphics); off makes leaves opaque.
pub cutout_leaves: bool,
/// Sprite layers a shader pack moves into the translucent pass, by layer.
pub translucent_layers: &'a [bool],
/// What the worker does with leaf faces inside a canopy.
pub leaf_culling: LeafCullingMode,
}
@@ -1310,7 +1312,13 @@ impl Mesher<'_> {
if self.tables.is_animated(face.layer as u16) {
out.note_sprite_layer(face.layer as u16);
}
let target = if face.translucent {
let translucent = face.translucent
|| self
.translucent_layers
.get(face.layer as usize)
.copied()
.unwrap_or(false);
let target = if translucent {
&mut out.translucent
} else {
&mut out.solid
@@ -1318,7 +1326,7 @@ impl Mesher<'_> {
// Opaque leaves (forceOpaque), quads over opaque texels and leaves a
// leaf-culling mode proved internal draw in the solid layer: no alpha
// cutout. All four vertices of the quad share the layer.
let layer = if face.opaque && !face.translucent
let layer = if face.opaque && !translucent
|| !self.cutout_leaves && state.leaves
|| interior_leaf
{
@@ -81,6 +81,7 @@ fn mesh(
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: mode,
};
mesher.mesh(&region, sy)
@@ -81,6 +81,7 @@ fn a_shader_pack_lights_the_faces_itself() {
blend_radius: 0,
smooth_lighting: false,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
let mut colours: Vec<u32> = mesher
@@ -157,6 +158,7 @@ fn reused_mesh_matches_fresh_geometry_across_sections_and_resource_reload() {
blend_radius: 2,
smooth_lighting: reload == 0,
cutout_leaves: reload == 0,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
for (case, (center, east)) in [
+21 -3
View File
@@ -1801,7 +1801,7 @@ fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
};
let mut tip = None;
if let Act::Extra(item) = act {
tip = Some(Text::literal(item.description()));
tip = Some(Text::literal(item.description_text()));
}
if let Act::Push(page) = act
&& let Some(text) = horizon_page_tooltip(*page)
@@ -1976,7 +1976,7 @@ fn pairs(opts: &[Opt]) -> Vec<Row> {
impl OptionsUi {
/// A switch of `horizon-extras.txt` as the Extras page draws it: "Caption: On".
fn extra_button(&self, s: &Settings, item: crate::extras::Item) -> Widget {
let label = format!("{}: {}", item.caption(), item.value(&s.extras));
let label = format!("{}: {}", item.caption_text(), item.value(&s.extras));
button(0.0, 0.0, 150.0, Text::literal(label), Act::Extra(item))
}
fn settings_search_active(&self) -> bool {
@@ -2274,7 +2274,7 @@ impl OptionsUi {
// Each section's switches two to a row, as vanilla's
// OptionsList.addSmall lays out an options screen.
let extra = |item: crate::extras::Item| {
let value = format!("{}: {}", item.caption(), item.value(&s.extras));
let value = format!("{}: {}", item.caption_text(), item.value(&s.extras));
let label = if item.key_name().is_some() && self.selected_key == item.key_name()
{
Text::literal(format!("> {value} <")).with_color(text::YELLOW)
@@ -4941,6 +4941,24 @@ impl OptionsUi {
mod tests {
use super::*;
/// The window allows the input method only while `typing` says a search
/// box has the focus (`Game::text_field_focused`), so a page without one
/// must not claim the keys: the movement and menu keys keep working there.
#[test]
fn only_a_focused_search_box_takes_text() {
let mut ui = OptionsUi::default();
assert!(!ui.typing(), "the main page has no text box");
ui.show(Page::Language);
assert!(ui.typing(), "the language list's search takes the keys");
ui.show(Page::Video);
assert!(!ui.typing(), "the settings search is not focused");
ui.settings_search.focused = true;
assert!(ui.typing(), "the settings search, once focused, does");
ui.settings_search.focused = false;
ui.show(Page::Packs);
assert!(!ui.typing(), "the pack screen's search is not open yet");
}
#[test]
fn accessibility_sliders_and_toggles_survive_options_round_trip() {
let mut s = Settings::default();
@@ -926,6 +926,8 @@ impl PostEffects {
let mut decoder = png::Decoder::new(std::io::BufReader::new(
std::fs::File::open(&file).map_err(|e| format!("{}: {e}", file.display()))?,
));
// As the other resource-pack images: stb_image never checks chunk CRCs.
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().map_err(|e| e.to_string())?;
let mut data = vec![0; reader.output_buffer_size().ok_or("a huge image")?];
@@ -827,6 +827,14 @@ impl TextField {
);
}
}
if self.focused {
// `TextInputManager.setTextInputArea`, in GUI pixels: the caret
// the input method's composition and its candidate window go to.
// The chat and in-bed chat boxes and the pack screen's search are
// `TextField`s; the other fields are an `EditBox` or a multiline
// box, which report their own.
crate::preedit::report_caret(cursor_x as f32, ty as f32, 10.0);
}
}
}
@@ -13,6 +13,11 @@ use crate::{
ui,
};
/// A string of this page: the language table's, or the English text.
fn tr(key: &str, english: &str) -> String {
lang::get_or(key, english)
}
/// Leave room for navigation, creation and cancellation in a 240-pixel menu.
const SHOWN: usize = 4;
pub mod store;
@@ -132,7 +137,12 @@ impl SplitProfiles {
};
if let Some(name) = &self.forgetting {
return vec![
button(120.0, "Forget".into(), self.editable(name), Target::Forget),
button(
120.0,
tr("horizon.splitscreen.profiles.forget", "Forget"),
self.editable(name),
Target::Forget,
),
button(
144.0,
lang::get_or("gui.cancel", "Cancel"),
@@ -147,7 +157,12 @@ impl SplitProfiles {
let ok = valid_name(name) && !self.names.iter().any(|n| n.eq_ignore_ascii_case(name));
let top = field_top(h);
return vec![
button(top + 25.0, "Create".into(), ok, Target::Create),
button(
top + 25.0,
tr("horizon.splitscreen.profiles.create", "Create"),
ok,
Target::Create,
),
button(
top + 50.0,
lang::get_or("gui.back", "Back"),
@@ -166,7 +181,7 @@ impl SplitProfiles {
{
let mut label = name.clone();
if self.current.as_ref() == Some(name) {
label.push_str(" - you");
label.push_str(&tr("horizon.splitscreen.profiles.you", " - you"));
}
let y = 44.0 + (i % SHOWN) as f32 * 24.0;
buttons.push((
@@ -177,7 +192,8 @@ impl SplitProfiles {
));
buttons.push((
[x + 180.0, y, 20.0, 20.0],
format!("Forget {name}"),
tr("horizon.splitscreen.profiles.forget_named", "Forget %s")
.replacen("%s", name, 1),
self.editable(name),
Target::Remove(i),
));
@@ -186,19 +202,24 @@ impl SplitProfiles {
if self.names.len() > SHOWN {
buttons.push((
[x, y, 98.0, 20.0],
"Previous".into(),
tr("horizon.splitscreen.profiles.previous", "Previous"),
self.page() > 0,
Target::Previous,
));
buttons.push((
[x + 102.0, y, 98.0, 20.0],
"Next".into(),
tr("horizon.splitscreen.profiles.next", "Next"),
(self.page() + 1) * SHOWN < self.names.len(),
Target::Next,
));
y += 24.0;
}
buttons.push(button(y, "New Profile...".into(), true, Target::New));
buttons.push(button(
y,
tr("horizon.splitscreen.profiles.new_button", "New Profile..."),
true,
Target::New,
));
let cancel = lang::get_or("gui.cancel", "Cancel");
buttons.push(button(y + 24.0, cancel, true, Target::Cancel));
buttons
@@ -220,30 +241,71 @@ impl SplitProfiles {
};
let buttons = self.buttons(w, h);
if let Some(name) = &self.forgetting {
STYLE.title(batch, atlas, &Text::literal("Forget profile?"));
STYLE.title(
batch,
atlas,
&Text::literal(tr(
"horizon.splitscreen.profiles.forget_title",
"Forget profile?",
)),
);
line(batch, name, 52.0, u32::MAX);
line(batch, "It leaves this instance's list.", 76.0, grey);
line(batch, "World progress and settings are kept.", 90.0, grey);
line(
batch,
&tr(
"horizon.splitscreen.profiles.forget_leaves",
"It leaves this instance's list.",
),
76.0,
grey,
);
line(
batch,
&tr(
"horizon.splitscreen.profiles.forget_kept",
"World progress and settings are kept.",
),
90.0,
grey,
);
if let Some(why) = &self.refused {
line(batch, why, 176.0, rgba(255, 85, 85, 255));
}
} else if let Some(field) = &mut self.new {
STYLE.title(batch, atlas, &Text::literal("New Profile"));
STYLE.title(
batch,
atlas,
&Text::literal(tr("horizon.splitscreen.profiles.new_title", "New Profile")),
);
let (x, top) = ((w / 2.0).floor() - 100.0, field_top(h));
let label = "Name (3 to 16 letters, digits, _)";
batch.text(atlas, label, x, top - 14.0, grey, true);
let label = tr(
"horizon.splitscreen.profiles.name_hint",
"Name (3 to 16 letters, digits, _)",
);
batch.text(atlas, &label, x, top - 14.0, grey, true);
(field.x, field.y, field.width, field.height) = (x, top, 200.0, 20.0);
field.draw(batch, atlas, None);
} else {
let title = match self.current {
Some(_) => "Switch profile",
None => "Who's playing?",
Some(_) => tr(
"horizon.splitscreen.profiles.switch_title",
"Switch profile",
),
None => tr("horizon.pages.split_profiles", "Who's playing?"),
};
STYLE.title(batch, atlas, &Text::literal(title));
self.draw_bumpers(batch, atlas, style);
// Under Cancel: where offline profiles live.
let y = buttons.last().map_or(0.0, |b| b.0[1] + 30.0);
line(batch, "Offline profiles belong to this instance", y, grey);
line(
batch,
&tr(
"horizon.splitscreen.profiles.offline_note",
"Offline profiles belong to this instance",
),
y,
grey,
);
// Why the last pick can't play, wrapped to the view (a quarter of a window is narrow).
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
let why = self.refused.as_deref().unwrap_or_default();
@@ -308,8 +370,12 @@ impl SplitProfiles {
fn draw_bumpers(&self, batch: &mut HudBatch, atlas: &HudAtlas, style: GlyphStyle) {
let width = |text: &str| HudBatch::text_width(atlas, text).ceil();
let words = match &self.place {
Some(place) => format!("Move where you play: {place}"),
None => "Move where you play".to_owned(),
Some(place) => tr(
"horizon.splitscreen.profiles.move_place",
"Move where you play: %s",
)
.replacen("%s", place, 1),
None => tr("horizon.splitscreen.profiles.move", "Move where you play"),
};
let keys = [PadButton::LeftBumper, PadButton::RightBumper];
let key_width = |b: PadButton| width(b.label(style)) + 4.0;
@@ -528,6 +528,7 @@ impl Sprites {
/// A PNG's width, height and pixels as RGBA.
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![
+182 -2
View File
@@ -197,7 +197,7 @@ impl<G: ChunkGenerator> Saves for G {
}
}
/// Per-job mesher inputs that change with the dimension or the options.
#[derive(Clone, Copy)]
#[derive(Clone)]
struct MeshSettings {
nether: bool,
shader_pack_lighting: bool,
@@ -205,6 +205,7 @@ struct MeshSettings {
blend_radius: i32,
smooth_lighting: bool,
cutout_leaves: bool,
translucent_layers: Arc<[bool]>,
leaf_culling: LeafCullingMode,
}
enum Job {
@@ -440,6 +441,9 @@ pub struct Streaming {
/// The `ao` and `cutoutLeaves` options.
pub smooth_lighting: bool,
pub cutout_leaves: bool,
/// Sprite layers a shader pack draws in the translucent pass (`[[materials]]
/// translucent`), by layer; empty without one.
pub translucent_layers: Arc<[bool]>,
/// What the mesher does with leaf faces inside a canopy (`LeafCullingMode`).
pub leaf_culling: LeafCullingMode,
/// Incremental relighting of block edits on lit chunks (see `light::relight`).
@@ -776,6 +780,7 @@ impl Streaming {
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,
@@ -858,6 +863,7 @@ impl Streaming {
biome_blend: 2,
smooth_lighting: true,
cutout_leaves: true,
translucent_layers: Arc::from([]),
leaf_culling: LeafCullingMode::Off,
light_tables,
light_log: Vec::new(),
@@ -910,9 +916,56 @@ impl Streaming {
}
}
}
/// A resident column a server sent again whole (it changed it in bulk):
/// its new blocks taken, the sections that did not change kept as they
/// were, and its light and its neighbours' worked out again here, off
/// the game thread, as light is for the blocks a server changes one at a
/// time; the sections that changed remesh (with theirs) once lit. Until
/// then the sections that changed show the light the packet carried (a
/// Horizon server sends the light from before its relight, then the
/// new light; another server's is already right, which matters where
/// the neighbours this light needs aren't all here).
pub fn replace_remote(&mut self, world: &mut World, mut chunk: Chunk) {
let key = (chunk.x, chunk.z);
let Some(old) = world.chunks.get(&key).cloned() else {
return self.insert_remote(world, chunk);
};
if !self.lit.contains(&key) {
return self.insert_remote(world, chunk);
}
// The cells whose blocks changed, by section: they remesh with the neighbours within a block of them (as
// vanilla's `setBlocksDirty`), not every section round each section changed (which, for the few blocks a
// section often changes in a bulk edit, is mostly sections that did not).
let mut cells = crate::light::LightChanges::default();
cells.keyed(world.layout());
let kept = keep_unchanged_sections(&old, &mut chunk, &mut cells);
chunk.light_loaded = old.light_loaded;
chunk.lit = true;
world.chunks.insert(key, Arc::new(chunk));
world.revision = world.revision.wrapping_add(1);
// A section with new biomes and the same blocks remeshes now (its tint), with its neighbours, whose tint
// blends across the border: above and below too, as a block's tint can sample the biome quart of the
// section next to it (`BiomeManager.getBiome`'s zoom reaches a quart beyond the block).
Self::light_updated(world, key.0, key.1, &kept.biomes);
// A section with the same blocks and new light takes the packet's light (vanilla's
// `handleLevelChunkWithLight` always applies it) and remeshes as a light update would.
Self::light_updated(world, key.0, key.1, &kept.light);
if !kept.blocks {
return;
}
for dz in -1..=1 {
for dx in -1..=1 {
world.dirty_light.insert((key.0 + dx, key.1 + dz));
}
}
cells.dirty_sections(|section| {
world.dirty_sections.insert(section);
});
}
/// A server's light update for a resident column: the sections whose
/// light changed and their neighbours remesh
/// (`setSectionDirtyWithNeighbors`).
/// (`setSectionDirtyWithNeighbors`). A resent column's sections with new
/// biomes remesh the same way.
pub fn light_updated(world: &mut World, x: i32, z: i32, sections: &[usize]) {
for &sy in sections {
for dz in -1..=1 {
@@ -1718,6 +1771,7 @@ impl Streaming {
blend_radius: self.biome_blend,
smooth_lighting: self.smooth_lighting,
cutout_leaves: self.cutout_leaves,
translucent_layers: self.translucent_layers.clone(),
leaf_culling: self.leaf_culling,
},
),
@@ -2370,10 +2424,136 @@ fn nearest_first<T: Ord>(candidates: &mut Vec<T>, room: usize) {
}
candidates.sort_unstable();
}
/// What `keep_unchanged_sections` found changed.
#[derive(Debug, Default, PartialEq)]
struct Resent {
/// Any section's blocks changed (in a way light or the mesher sees).
blocks: bool,
/// The sections whose blocks did not change but whose biomes did.
biomes: Vec<usize>,
/// The sections whose blocks did not change but whose light the packet changes.
light: Vec<usize>,
}
/// `new`, a resident column `old` sent again, takes `old`'s sections whose
/// blocks it holds unchanged, with `new`'s biomes and light; the sections whose blocks
/// changed keep `old`'s light (until lit again) and their cells' classes go in
/// `cells`.
fn keep_unchanged_sections(
old: &Chunk,
new: &mut Chunk,
cells: &mut crate::light::LightChanges,
) -> Resent {
let (mut a, mut b) = (Box::new([0u32; 4096]), Box::new([0u32; 4096]));
let mut out = Resent::default();
let (x, z) = (new.x, new.z);
for s in 0..new.sections.len().min(old.sections.len()) {
// (sections stored alike hold the same blocks: most of a column sent again, found without unpacking)
let same = old.sections[s].blocks.same_storage(&new.sections[s].blocks) || {
old.sections[s].unpack(&mut a);
new.sections[s].unpack(&mut b);
let classes = crate::light::LightChanges::classes_differing(&a, &b);
if classes != 0 {
cells.add((x, s as i32, z), classes);
}
classes == 0
};
if !same {
out.blocks = true;
continue;
}
let biomes = new.sections[s].biomes.clone();
let changed = !same_values(&old.sections[s].biomes, &biomes);
let light = (!same_light(&old.sections[s].light, &new.sections[s].light))
.then(|| new.sections[s].light.clone());
new.sections[s] = old.sections[s].clone();
if changed {
Arc::make_mut(&mut new.sections[s]).biomes = biomes;
out.biomes.push(s);
}
if let Some(light) = light {
Arc::make_mut(&mut new.sections[s]).light = light;
out.light.push(s);
}
}
out
}
/// Whether two sections' packed light (4,096 cells) holds the same values.
fn same_light(a: &crate::chunk::Light, b: &crate::chunk::Light) -> bool {
use crate::chunk::Light;
match (a, b) {
(Light::Uniform(x), Light::Uniform(y)) => x == y,
(Light::Full(x), Light::Full(y)) => x == y,
(Light::Uniform(v), Light::Full(f)) | (Light::Full(f), Light::Uniform(v)) => {
f.iter().all(|c| c == v)
}
}
}
/// Whether two biome containers (64 cells) hold the same values.
fn same_values(
a: &horizon_core::paletted::PalettedContainer,
b: &horizon_core::paletted::PalettedContainer,
) -> bool {
if a.same_storage(b) {
return true;
}
let (mut x, mut y) = ([0u32; 64], [0u32; 64]);
a.unpack(&mut x);
b.unpack(&mut y);
x == y
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_resent_column_keeps_its_sections_but_takes_new_biomes() {
let mut old = Chunk::empty(2, -3);
old.set(1, 10, 1, 1);
old.set(1, 40, 1, 1);
old.set_biome_cell(0, 0, 0, 1);
let mut new = old.clone();
// a new biome under the same blocks (section 0), and a block changed (section 6)
new.set_biome_cell(0, 0, 0, 3);
new.set(1, 40, 1, 2);
let mut cells = crate::light::LightChanges::default();
cells.keyed(crate::chunk::ColumnLayout::VANILLA);
let kept = keep_unchanged_sections(&old, &mut new, &mut cells);
assert_eq!(
kept,
Resent {
blocks: true,
biomes: vec![0],
light: vec![],
}
);
assert_eq!(new.biome(0, -64, 0), 3);
assert_eq!(new.block(1, -54, 1).0, 0);
assert_eq!(new.block(1, 40, 1).0, 2);
// the sections alike are shared with the old column
assert!(Arc::ptr_eq(&new.sections[1], &old.sections[1]));
assert!(!Arc::ptr_eq(&new.sections[0], &old.sections[0]));
// nothing changed: nothing to do
let mut same = old.clone();
let kept = keep_unchanged_sections(&old, &mut same, &mut cells);
assert_eq!(kept, Resent::default());
assert!(Arc::ptr_eq(&same.sections[0], &old.sections[0]));
// the same blocks under new light: the packet's light, as vanilla applies it
let mut lit = old.clone();
Arc::make_mut(&mut lit.sections[2]).light = crate::chunk::Light::Uniform(0xF0);
Arc::make_mut(&mut old.sections[2]).light = crate::chunk::Light::Uniform(0x50);
let kept = keep_unchanged_sections(&old, &mut lit, &mut cells);
assert_eq!(
kept,
Resent {
light: vec![2],
..Resent::default()
}
);
assert_eq!(lit.sections[2].light.get(0), 0xF0);
assert!(Arc::ptr_eq(&lit.sections[1], &old.sections[1]));
}
#[test]
fn mesh_pool_reuses_capacity_with_clean_geometry_state() {
let mut mesh = SectionMesh::default();
@@ -100,6 +100,9 @@ pub struct World {
/// for `ServerLevel.sendBlockUpdated`'s path recomputing; kept only while
/// the mobs of this world tick (`game/creatures.rs`).
pub path_writes: Option<Vec<((i32, i32, i32), u32, u32)>>,
/// The chunks changed in bulk (`edit_runs`) since the log was last
/// drained, kept while `block_log` is: sent to the players whole.
pub bulk_log: FxHashSet<(i32, i32)>,
/// The sections whose light changed since the log was last drained, as
/// a bit per section of each column, kept while `block_log` is
/// (`ChunkHolder.sectionLightChanged`).
@@ -208,6 +211,7 @@ impl World {
weather: Weather::default(),
block_log: None,
path_writes: None,
bulk_log: Default::default(),
light_log: None,
moving_piston_fallback: Vec::new(),
moving_pistons: Vec::new(),
@@ -0,0 +1,293 @@
{
"horizon.options.controller": "Controller",
"horizon.options.vibration": "Vibration",
"horizon.options.adaptive_triggers": "Adaptive Trigger",
"horizon.options.lightbar": "Lightbar",
"horizon.options.look_sensitivity": "Blickempfindlichkeit",
"horizon.options.invert_x": "Blick-X-Achse umkehren",
"horizon.options.invert_y": "Blick-Y-Achse umkehren",
"horizon.options.sneak_button": "Schleichtaste",
"horizon.options.move_deadzone": "Totzone der Bewegung",
"horizon.options.look_deadzone": "Totzone des Blicks",
"horizon.options.button_glyphs": "Tastensymbole",
"horizon.options.menu_navigation": "Menünavigation",
"horizon.options.on_screen_keyboard": "Bildschirmtastatur",
"horizon.options.chat_delay": "Chatverzögerung",
"horizon.options.lightbar.health": "Gesundheit",
"horizon.options.lightbar.dimension": "Dimension",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Zeiger",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Diese Funktion ist in Horizon noch nicht verfügbar.",
"horizon.options.search": "Einstellungen durchsuchen …",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Shader-Pakete",
"horizon.pages.shader_options": "Shader-Optionen",
"horizon.pages.controller": "Controller-Einstellungen",
"horizon.pages.controller_binds": "Controller-Tastenbelegung",
"horizon.pages.extras": "Horizon-Extras",
"horizon.pages.settings": "Horizon-Einstellungen",
"horizon.pages.agent": "KI-Agent",
"horizon.pages.split_profiles": "Wer spielt?",
"horizon.pages.settings.tooltip": "Horizons eigene Einstellungen: Grafik, Controller, Extras und Mods.",
"horizon.pages.graphics.tooltip": "Grafik, einfach: Jede Einstellung zeigt, was sie kostet.",
"horizon.pages.graphics_advanced.tooltip": "Alle Grafikeinstellungen, auch die von Horizon, mit den Kosten jeder einzelnen.",
"horizon.pages.controller.tooltip": "Controller: ihre Tasten, Sticks, Vibration und Menünavigation.",
"horizon.pages.extras.tooltip": "Optionale Zusätze, alle standardmäßig aus: Das Spiel bleibt Vanilla, bis du einen einschaltest.",
"horizon.pages.agent.tooltip": "Lass deinen eigenen KI-Agenten spielen: horizon-ctl und MCP-Clients auf diesem Computer.",
"horizon.pages.split_profiles.tooltip": "Wer spielt: ein Profil, das in diesem Spielordner gespeichert ist.",
"horizon.pages.shader_packs.tooltip": "Shader-Pakete aus dem Ordner shaderpacks: die von Horizon selbst und die im Format shaders/. Es läuft immer nur eines.",
"horizon.pages.mods.tooltip": "Mods für Horizon aus dem Ordner horizon-mods. Jede läuft in einer Sandbox und bleibt aus, bis du sie einschaltest.",
"horizon.pages.open.graphics": "Grafik …",
"horizon.pages.open.graphics_advanced": "Erweiterte Grafik …",
"horizon.pages.open.shader_packs": "Shader-Pakete …",
"horizon.pages.open.controller": "Controller-Einstellungen …",
"horizon.pages.open.extras": "Extras …",
"horizon.pages.open.mods": "Mods …",
"horizon.pages.open.agent": "KI-Agent …",
"horizon.pages.open.horizon_graphics": "Horizon-Grafik …",
"horizon.pages.open.controller_binds": "Controller-Belegung …",
"horizon.pages.open.horizon_settings": "Horizon-Einstellungen …",
"horizon.mods.empty": "Keine Mods: Lege .hmod-Dateien in den Ordner horizon-mods",
"horizon.mods.stopped": " (angehalten)",
"horizon.mods.not_built": " (nicht gebaut)",
"horizon.mods.dev": " (Entwicklung)",
"horizon.mods.settings": "Einstellungen",
"horizon.mods.open_folder": "Modordner öffnen",
"horizon.shader_packs.empty": "Keine Shader-Pakete: Lege sie in den Ordner shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Shader-Optionen …",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Optionen des Shader-Pakets werden gelesen …",
"horizon.agent.copy_details": "Verbindungsdaten kopieren",
"horizon.multiplayer.join": "Beitreten",
"horizon.multiplayer.cancel_reconnecting": "Neuverbindung abbrechen",
"horizon.multiplayer.join_friend": "Freund beitreten",
"horizon.multiplayer.invite_code": "Einladungscode",
"horizon.multiplayer.relay": "Relay (optional)",
"Preset": "Preset",
"LOD Distance": "LOD-Weite",
"Far Detail": "Ferndetails",
"Cave Culling": "Höhlen-Culling",
"Entity Culling": "Objekt-Culling",
"Chunk Updates": "Chunk-Updates",
"Upload Budget": "Upload-Budget",
"Sky": "Himmel",
"Fog": "Nebel",
"Weather": "Wetter",
"Animated Textures": "Animierte Texturen",
"Present Mode": "Ausgabemodus",
"Frame Pacing": "Bildtakt",
"Worker Threads": "Arbeitsthreads",
"Resolution Scale": "Auflösungsskalierung",
"Anti-aliasing": "Kantenglättung",
"Upscaling": "Upscaling",
"HDR Settings...": "HDR-Einstellungen …",
"Paper White": "Papierweiß",
"Peak Brightness": "Spitzenhelligkeit",
"Black Level": "Schwarzwert",
"UI Brightness": "GUI-Helligkeit",
"Battery Mode": "Akkumodus",
"Graphics API": "Grafik-API",
"Performance Overlay": "Leistungsanzeige",
"Full Detail Distance": "Detailweite",
"On": "An",
"Off": "Aus",
"Unlimited": "Unbegrenzt",
"Auto": "Auto",
"Custom": "Benutzerdefiniert",
"Performance": "Leistung",
"Balanced": "Ausgewogen",
"Quality": "Qualität",
"Ultra": "Ultra",
"Ultra Performance": "Ultra-Leistung",
"Native": "Nativ",
"Full": "Voll",
"Reduced": "Reduziert",
"Efficient": "Sparsam",
"Precise": "Präzise",
"Auto (VSync)": "Auto (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Immediate",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS und Diagramm",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Keine Kosten",
"Low cost": "Geringe Kosten",
"Medium cost": "Mittlere Kosten",
"High cost": "Hohe Kosten",
"Saves time": "Spart Zeit",
"Rendering": "Rendering",
"Display": "Anzeige",
"Preferences": "Präferenzen",
"Overlay": "Einblendung",
"HDR": "HDR",
"Graphics": "Grafik",
"Graphics: Advanced": "Grafik: Erweitert",
"Graphics: HDR": "Grafik: HDR",
"Advanced...": "Erweitert …",
"Simple...": "Einfach …",
"Done": "Fertig",
"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.",
"%s chunks": "%s Chunks",
"%s per frame": "%s pro Bild",
"%s MiB/frame": "%s MiB/Bild",
"%s KiB/frame": "%s KiB/Bild",
"%s nits": "%s Nits",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s Nits)",
"%.2f nits": "%.2f Nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Gelände mit vollen Details. Die LOD-Entfernung reicht weiter.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Grobes fernes Gelände in lokalen Welten. Integrierte GPUs: max. 128.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduziert: fernes Gelände gröber, schneller auf dieser GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Reduziert: fernes Gelände gröber, für GPUs, die sonst bremsen.",
"Field of view. Wider shows more terrain at once.": "Sichtfeld. Ein größeres zeigt mehr Gelände auf einmal.",
"Caps frames per second; a cap saves power and heat.": "Begrenzt die Bilder pro Sekunde; spart Strom und Wärme.",
"Waits for the display's refresh: no tearing, a little latency.": "Wartet auf den Bildaufbau: kein Tearing, etwas Latenz.",
"Size of menus and the HUD.": "Größe der Menüs und des HUDs.",
"Sets the options below together. Changing one makes it Custom.": "Stellt Optionen darunter ein. Änderung = „Benutzerdefiniert“.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lava, Feuer, Sonne heller als Weiß. Braucht HDR-Bildschirm.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Das dunkelste Schwarz deines Bildschirms. 0 für OLED; LCDs leuchten ein wenig.",
"How bright highlights get. Auto: the peak your system reports.": "Wie hell Lichter werden. Auto: der Spitzenwert, den dein System meldet.",
"How bright white paper and menus look. Auto: your desktop's white.": "Wie hell weißes Papier und Menüs aussehen. Auto: das Weiß deines Desktops.",
"How bright the HUD and menus are, against paper white.": "Wie hell HUD und Menüs im Vergleich zu Papierweiß sind.",
"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.",
"Blends grass and water colours across biomes when building chunks.": "Mischt Gras- und Wasserfarben über Biome hinweg beim Chunk-Aufbau.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Wolkenschicht: aus, flach (schnell) oder 3D (schön).",
"How far clouds reach, in chunks.": "Wie weit Wolken reichen, in Chunks.",
"Draws the sky, sun, moon and stars.": "Zeichnet Himmel, Sonne, Mond und Sterne.",
"Distance fog. Off shows the edge of the loaded world.": "Nebel in der Ferne. Aus zeigt den Rand der geladenen Welt.",
"Draws rain and snow.": "Zeichnet Regen und Schnee.",
"How far rain and snow fall around you.": "Wie weit um dich herum Regen und Schnee fallen.",
"How many particles effects spawn.": "Wie viele Partikel Effekte erzeugen.",
"Round shadows under mobs and items.": "Runde Schatten unter Kreaturen und Gegenständen.",
"Leaves with see-through gaps. Off draws them solid.": "Laub mit durchsichtigen Lücken. Aus zeichnet es massiv.",
"Sorts water and glass more exactly behind each other.": "Sortiert Wasser und Glas hintereinander genauer.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Welche Chunk-Neuaufbauten auf das Bild warten: keine, nahe oder deine.",
"Animates water, lava, fire and other moving textures.": "Animiert Wasser, Lava, Feuer und andere bewegte Texturen.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Weltpixel pro Bildschirmpixel. Unter 100 % ist es auf der GPU schneller.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Glättet Kanten. FXAA: weicher. TAA: am glattesten, evtl. Schlieren.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendert die Welt kleiner und baut sie mit TAA wieder auf. Schneller, weicher.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "HDR-Ausgabe auf HDR-Bildschirm: Lava, Feuer, Sonne heller.",
"How frames reach the screen while VSync is off.": "Wie Bilder auf den Bildschirm gelangen, wenn VSync aus ist.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Sparsam schläft bis zum nächsten Bild; Präzise wartet aktiv, wie in Vanilla.",
"Fills the screen (borderless).": "Füllt den Bildschirm (randlos).",
"Brightens dark places.": "Hellt dunkle Orte auf.",
"Lowers the frame rate when minimised, or idle too.": "Senkt die Bildrate bei Minimierung oder auch bei Inaktivität.",
"Sways the view while walking.": "Lässt die Ansicht beim Gehen schwanken.",
"Fades newly loaded chunks in.": "Blendet neu geladene Chunks ein.",
"Darkens the screen's edges.": "Dunkelt die Bildschirmränder ab.",
"Shows when the world saves.": "Zeigt an, wann die Welt gespeichert wird.",
"Shows attack cooldown.": "Zeigt die Angriffsabklingzeit an.",
"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.",
"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 …",
"horizon.loading.opening_world": "Welt wird geöffnet",
"horizon.loading.cancelling_world": "Wird abgebrochen",
"horizon.title.not_affiliated": "Nicht mit Mojang oder Microsoft verbunden",
"horizon.extras.header.lighting": "Beleuchtung",
"horizon.extras.header.third_person": "Dritte Person",
"horizon.extras.header.inventory": "Inventar",
"horizon.extras.header.multiplayer": "Mehrspieler",
"horizon.extras.fullbright": "Maximale Helligkeit",
"horizon.extras.dynamic_lights": "Dynamisches Licht",
"horizon.extras.own_name_tag": "Eigenes Namensschild",
"horizon.extras.auto_reconnect": "Neu verbinden",
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "Mausrad-Verschieben",
"horizon.extras.sort": "Sortier-Schaltflächen",
"horizon.extras.sort_key": "Sortiertaste",
"horizon.extras.no_ai": "Keine KI",
"horizon.extras.agent_control": "Agentensteuerung",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.fullbright.tooltip": "Zeigt Blöcke und Objekte bei vollem Licht, selbst an dunklen Orten.",
"horizon.extras.dynamic_lights.tooltip": "Fackeln und andere Lichtquellen, die gehalten, fallen gelassen werden oder brennen, beleuchten die Blöcke um sich herum.",
"horizon.extras.own_name_tag.tooltip": "Zeigt deinen Namen in der dritten Person über deinem Kopf an, so wie andere Spieler ihn sehen. Versteckt, wenn der Server die Namen deines Teams verbirgt oder du unsichtbar bist.",
"horizon.extras.auto_reconnect.tooltip": "Verbindet nach einem Verbindungsabbruch oder Neustart erneut mit dem Server, nie nach einem Rauswurf oder Bann.",
"horizon.extras.mouse_tweaks.tooltip": "Mit Rechtsklick ziehen legt je einen pro Feld ab, mit Linksklick ziehen sammelt den gehaltenen Gegenstand, Umschalt + Linksklick ziehen verschiebt jedes Feld.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Nach unten schickt einen Gegenstand des Feldes auf die andere Seite, nach oben holt einen zurück.",
"horizon.extras.sort.tooltip": "Sortiert Inventar, Truhen, Fässer, Endertruhen und Shulker-Kisten in der Reihenfolge der Kreativ-Tabs und fasst Stapel zusammen.",
"horizon.extras.sort_key.tooltip": "Sortiert den Bereich unter dem Mauszeiger. Klicke, dann drücke eine Taste; Esc hebt die Belegung auf.",
"horizon.extras.no_ai.tooltip": "Blendet alle KI-Funktionen aus und schaltet sie ab: Agentensteuerung, horizon-ctl und die MCP-Server. Auch den Schalter des Launchers.",
"horizon.extras.agent_control.tooltip": "Dein eigener KI-Agent kann dieses Spiel über horizon-ctl oder einen MCP-Client spielen, nur auf diesem Computer.",
"horizon.extras.discord_presence.tooltip": "Zeigt „Spielt Horizon“ in deinem Discord-Profil an, solange Discord läuft: Menüs, Einzel- oder Mehrspielermodus, die Dimension und die Spielzeit. Nie den Namen einer Welt oder die Adresse eines Servers.",
"Calibrate...": "Kalibrieren …",
"Calibrate Anyway...": "Trotzdem kalibrieren …",
"Use Automatic Setup": "Automatische Einrichtung verwenden",
"Shortcut: %s": "Tastenkürzel: %s",
"Next": "Weiter",
"HDR Calibration %s/%s: %s": "HDR-Kalibrierung %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Stellt die drei Werte darunter im Vollbild ein, jeweils mit einem Testbild.",
"A key that opens this screen from the game. Not bound at first.": "Eine Taste, die diesen Bildschirm im Spiel öffnet. Zunächst nicht belegt.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Bildschirm meldete keine Helligkeit: zum Einrichten kalibrieren.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Bereits eingerichtet. Nur kalibrieren, wenn HDR falsch aussieht.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Stellt Spitzenhelligkeit und Papierweiß des Bildschirms wieder her.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Für den Bildschirm eingerichtet. Auto lassen, wenn es passt.",
"Lower it until the logo just disappears into the black.": "Verringere den Wert, bis das Logo gerade im Schwarz verschwindet.",
"Raise it until the logo just disappears into the bright square.": "Erhöhe den Wert, bis das Logo gerade im hellen Quadrat verschwindet.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Wähle ein Weiß, das bei deinem Licht lange angenehm ist.",
"horizon.splitscreen.profiles.forget": "Entfernen",
"horizon.splitscreen.profiles.create": "Erstellen",
"horizon.splitscreen.profiles.you": " - du",
"horizon.splitscreen.profiles.forget_named": "%s entfernen",
"horizon.splitscreen.profiles.previous": "Zurück",
"horizon.splitscreen.profiles.next": "Weiter",
"horizon.splitscreen.profiles.new_button": "Neues Profil …",
"horizon.splitscreen.profiles.forget_title": "Profil entfernen?",
"horizon.splitscreen.profiles.forget_leaves": "Es verschwindet aus der Liste dieser Instanz.",
"horizon.splitscreen.profiles.forget_kept": "Weltfortschritt und Einstellungen bleiben erhalten.",
"horizon.splitscreen.profiles.new_title": "Neues Profil",
"horizon.splitscreen.profiles.name_hint": "Name (3 bis 16 Buchstaben, Ziffern, _)",
"horizon.splitscreen.profiles.switch_title": "Profil wechseln",
"horizon.splitscreen.profiles.offline_note": "Offline-Profile gehören zu dieser Instanz",
"horizon.splitscreen.profiles.move": "Spielort wechseln",
"horizon.splitscreen.profiles.move_place": "Spielort wechseln: %s",
"horizon.friends.title": "Mit Freunden spielen",
"horizon.friends.hint": "Freunde fügen den Code nach dem Freunde-Knopf des Titelbildschirms ein, bei „Direktverbindung“ oder auf der Spielen-Seite des Launchers.",
"horizon.friends.copied": "Kopiert",
"horizon.friends.copy_code": "Code kopieren",
"horizon.friends.stop_sharing": "Teilen beenden",
"horizon.friends.none_yet": "Noch keine Freunde hier.",
"horizon.friends.here_now": "Gerade hier: %s",
"horizon.friends.share_intro": "Teile diese Welt mit einem Einladungscode: Sie wird im LAN geöffnet, und Freunde im Internet treten mit dem Code bei. Es ist kein Server von uns beteiligt.",
"horizon.friends.start_sharing": "Teilen starten",
"horizon.friends.troubleshooting": "Fehlerbehebung …",
"horizon.friends.stopped": "Das Teilen des Einladungscodes wurde beendet.",
"horizon.friends.short_code_changed": "Jemand hat immer wieder einen falschen Kurzcode versucht, deshalb wurde er geändert.",
"horizon.friends.wrong_code": "Jemand hat einen falschen Code versucht.",
"horizon.friends.code_ready": "Dein Einladungscode ist bereit: Spielmenü, Mit Freunden spielen. Kopiere ihn und schicke ihn an deine Freunde.",
"horizon.friends.share_failed": "Die Welt konnte nicht geteilt werden: %s",
"horizon.friends.finding_route": "Es wird ermittelt, wie Freunde dich erreichen können …",
"horizon.osk.shift": "Umschalt",
"horizon.osk.space": "Leertaste",
"horizon.osk.delete": "Löschen",
"horizon.osk.type": "Eingeben",
"horizon.osk.symbols": "Symbole",
"horizon.osk.close": "Schließen",
"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 …"
}
@@ -213,5 +213,98 @@
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay..."
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay...",
"horizon.title.not_affiliated": "Not affiliated with Mojang or Microsoft",
"horizon.extras.header.lighting": "Lighting",
"horizon.extras.header.third_person": "Third person",
"horizon.extras.header.particles": "Particles",
"horizon.extras.header.inventory": "Inventory",
"horizon.extras.header.chat": "Chat",
"horizon.extras.header.multiplayer": "Multiplayer",
"horizon.extras.header.sound": "Sound",
"horizon.extras.fullbright": "Fullbright",
"horizon.extras.dynamic_lights": "Dynamic lights",
"horizon.extras.own_name_tag": "Own name tag",
"horizon.extras.hide_block_particles": "Hide block-break particles",
"horizon.extras.auto_reconnect": "Auto-reconnect",
"horizon.extras.ui_sound_reserve": "Keep channels for UI sounds",
"horizon.extras.mouse_tweaks": "Mouse Tweaks controls",
"horizon.extras.mouse_tweaks_wheel": "Scroll wheel moves items",
"horizon.extras.sort": "Sort buttons",
"horizon.extras.sort_key": "Sort key",
"horizon.extras.no_ai": "No AI",
"horizon.extras.agent_control": "Agent Control",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.splitscreen": "Splitscreen",
"horizon.extras.chat_animation": "Smooth chat",
"horizon.extras.chat_indicator": "Message indicator",
"horizon.extras.fullbright.tooltip": "Show blocks and entities at full light, even in dark areas.",
"horizon.extras.dynamic_lights.tooltip": "Torches and other light sources held, dropped or on fire light the blocks around them.",
"horizon.extras.own_name_tag.tooltip": "Shows your name above your head in third person, as other players see it. Hidden when the server hides your team's names or you're invisible.",
"horizon.extras.hide_block_particles.tooltip": "Hides the debris particles that burst from a broken block and crumble off the one you mine. Other particles stay.",
"horizon.extras.auto_reconnect.tooltip": "Rejoins a server after a lost connection or a restart, never after a kick or ban.",
"horizon.extras.ui_sound_reserve.tooltip": "Sounds in the world leave 16 of the 247 sound channels free, so a flood of them (a big explosion) can't silence menu clicks.",
"horizon.extras.mouse_tweaks.tooltip": "Right-drag places one per slot, left-drag gathers the held item, Shift + left-drag moves every slot.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Down sends one item of the slot to the other side, up brings one back.",
"horizon.extras.sort.tooltip": "Sorts the inventory, chests, barrels, ender chests and shulker boxes in creative tab order, merging stacks.",
"horizon.extras.sort_key.tooltip": "Sorts the part under the cursor. Click, then press a key; Escape unbinds it.",
"horizon.extras.no_ai.tooltip": "Hides and turns off every AI feature: agent control, horizon-ctl and the MCP servers. The launcher's switch too.",
"horizon.extras.agent_control.tooltip": "Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only.",
"horizon.extras.discord_presence.tooltip": "Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address.",
"Calibrate...": "Calibrate...",
"Calibrate Anyway...": "Calibrate Anyway...",
"Use Automatic Setup": "Use Automatic Setup",
"Shortcut: %s": "Shortcut: %s",
"Next": "Next",
"HDR Calibration %s/%s: %s": "HDR Calibration %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Sets the three below full screen, one test picture at a time.",
"A key that opens this screen from the game. Not bound at first.": "A key that opens this screen from the game. Not bound at first.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Your display didn't say how bright it gets: calibrate to set it up.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Already set up for your display. Only calibrate if HDR looks wrong.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Puts Peak Brightness and Paper White back to your display's setup.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Set up for your display. Leave on Auto unless it looks wrong.",
"Lower it until the logo just disappears into the black.": "Lower it until the logo just disappears into the black.",
"Raise it until the logo just disappears into the bright square.": "Raise it until the logo just disappears into the bright square.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Make the card a white that's comfortable to look at for a long time, in the light you play in.",
"horizon.splitscreen.profiles.forget": "Forget",
"horizon.splitscreen.profiles.create": "Create",
"horizon.splitscreen.profiles.you": " - you",
"horizon.splitscreen.profiles.forget_named": "Forget %s",
"horizon.splitscreen.profiles.previous": "Previous",
"horizon.splitscreen.profiles.next": "Next",
"horizon.splitscreen.profiles.new_button": "New Profile...",
"horizon.splitscreen.profiles.forget_title": "Forget profile?",
"horizon.splitscreen.profiles.forget_leaves": "It leaves this instance's list.",
"horizon.splitscreen.profiles.forget_kept": "World progress and settings are kept.",
"horizon.splitscreen.profiles.new_title": "New Profile",
"horizon.splitscreen.profiles.name_hint": "Name (3 to 16 letters, digits, _)",
"horizon.splitscreen.profiles.switch_title": "Switch profile",
"horizon.splitscreen.profiles.offline_note": "Offline profiles belong to this instance",
"horizon.splitscreen.profiles.move": "Move where you play",
"horizon.splitscreen.profiles.move_place": "Move where you play: %s",
"horizon.friends.title": "Play with Friends",
"horizon.friends.hint": "Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
"horizon.friends.copied": "Copied",
"horizon.friends.copy_code": "Copy Code",
"horizon.friends.stop_sharing": "Stop Sharing",
"horizon.friends.none_yet": "No friends here yet.",
"horizon.friends.here_now": "Here now: %s",
"horizon.friends.share_intro": "Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
"horizon.friends.start_sharing": "Start Sharing",
"horizon.friends.troubleshooting": "Troubleshooting...",
"horizon.friends.stopped": "Stopped sharing the invite code.",
"horizon.friends.short_code_changed": "Someone kept trying a wrong short code, so it changed.",
"horizon.friends.wrong_code": "Someone tried a wrong code.",
"horizon.friends.code_ready": "Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
"horizon.friends.share_failed": "Couldn't share the world: %s",
"horizon.friends.finding_route": "Finding how friends can reach you…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Space",
"horizon.osk.delete": "Delete",
"horizon.osk.type": "Type",
"horizon.osk.symbols": "Symbols",
"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."
}
@@ -0,0 +1,293 @@
{
"horizon.options.controller": "Manette",
"horizon.options.vibration": "Vibrations",
"horizon.options.adaptive_triggers": "Gâchettes adaptatives",
"horizon.options.lightbar": "Barre lumineuse",
"horizon.options.look_sensitivity": "Sensibilité de la vue",
"horizon.options.invert_x": "Inverser l'axe X de la vue",
"horizon.options.invert_y": "Inverser l'axe Y de la vue",
"horizon.options.sneak_button": "Bouton pour s'accroupir",
"horizon.options.move_deadzone": "Zone morte du déplacement",
"horizon.options.look_deadzone": "Zone morte de la vue",
"horizon.options.button_glyphs": "Symboles des boutons",
"horizon.options.menu_navigation": "Navigation dans les menus",
"horizon.options.on_screen_keyboard": "Clavier à l'écran",
"horizon.options.chat_delay": "Délai du tchat",
"horizon.options.lightbar.health": "Santé",
"horizon.options.lightbar.dimension": "Dimension",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Focus",
"horizon.options.menu_pointer": "Pointeur",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Cette fonction n'est pas encore disponible dans Horizon.",
"horizon.options.search": "Rechercher une option...",
"horizon.pages.mods": "Mods",
"horizon.pages.shader_packs": "Packs de shaders",
"horizon.pages.shader_options": "Options des shaders",
"horizon.pages.controller": "Options de la manette",
"horizon.pages.controller_binds": "Assignation des boutons de la manette",
"horizon.pages.extras": "Extras Horizon",
"horizon.pages.settings": "Options Horizon",
"horizon.pages.agent": "Agent IA",
"horizon.pages.split_profiles": "Qui joue ?",
"horizon.pages.settings.tooltip": "Les options propres à Horizon : graphismes, manettes, extras et mods.",
"horizon.pages.graphics.tooltip": "Les graphismes, simplement : chaque réglage indique son coût.",
"horizon.pages.graphics_advanced.tooltip": "Tous les réglages graphiques, y compris ceux d'Horizon, avec le coût de chacun.",
"horizon.pages.controller.tooltip": "Les manettes : leurs boutons, leurs sticks, les vibrations et la navigation dans les menus.",
"horizon.pages.extras.tooltip": "Ajouts facultatifs, tous désactivés par défaut : le jeu reste vanilla tant que vous n'en activez pas un.",
"horizon.pages.agent.tooltip": "Laissez votre propre agent IA jouer : horizon-ctl et les clients MCP sur cet ordinateur.",
"horizon.pages.split_profiles.tooltip": "Qui joue : un profil conservé dans ce dossier de jeu.",
"horizon.pages.shader_packs.tooltip": "Packs de shaders du dossier shaderpacks : ceux d'Horizon et ceux au format shaders/. Un seul fonctionne à la fois.",
"horizon.pages.mods.tooltip": "Mods conçus pour Horizon, du dossier horizon-mods. Chacun s'exécute dans un bac à sable et reste désactivé tant que vous ne l'activez pas.",
"horizon.pages.open.graphics": "Graphismes...",
"horizon.pages.open.graphics_advanced": "Graphismes avancés...",
"horizon.pages.open.shader_packs": "Packs de shaders...",
"horizon.pages.open.controller": "Options de la manette...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "Agent IA...",
"horizon.pages.open.horizon_graphics": "Graphismes Horizon...",
"horizon.pages.open.controller_binds": "Boutons manette...",
"horizon.pages.open.horizon_settings": "Options Horizon...",
"horizon.mods.empty": "Aucun mod : placez des fichiers .hmod dans le dossier horizon-mods",
"horizon.mods.stopped": " (arrêté)",
"horizon.mods.not_built": " (non compilé)",
"horizon.mods.dev": " (dév.)",
"horizon.mods.settings": "Options",
"horizon.mods.open_folder": "Ouvrir le dossier des mods",
"horizon.shader_packs.empty": "Aucun pack de shaders : placez-les dans le dossier shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Options des shaders...",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Lecture des options du pack de shaders...",
"horizon.agent.copy_details": "Copier les détails de connexion",
"horizon.multiplayer.join": "Rejoindre",
"horizon.multiplayer.cancel_reconnecting": "Annuler la reconnexion",
"horizon.multiplayer.join_friend": "Rejoindre un ami",
"horizon.multiplayer.invite_code": "Code d'invitation",
"horizon.multiplayer.relay": "Relais (facultatif)",
"Preset": "Préréglage",
"LOD Distance": "Distance LOD",
"Far Detail": "Détails lointains",
"Cave Culling": "Occlusion des grottes",
"Entity Culling": "Occlusion des entités",
"Chunk Updates": "MàJ chunks",
"Upload Budget": "Transfert",
"Sky": "Ciel",
"Fog": "Brouillard",
"Weather": "Météo",
"Animated Textures": "Textures animées",
"Present Mode": "Présentation",
"Frame Pacing": "Rythme des images",
"Worker Threads": "Threads",
"Resolution Scale": "Échelle de résolution",
"Anti-aliasing": "Anticrénelage",
"Upscaling": "Upscaling",
"HDR Settings...": "Options HDR...",
"Paper White": "Point blanc",
"Peak Brightness": "Luminosité max.",
"Black Level": "Niveau de noir",
"UI Brightness": "Luminosité UI",
"Battery Mode": "Mode batterie",
"Graphics API": "API graphique",
"Performance Overlay": "Infos de perfs",
"Full Detail Distance": "Dist. détail",
"On": "Oui",
"Off": "Non",
"Unlimited": "Illimité",
"Auto": "Auto",
"Custom": "Personnalisé",
"Performance": "Performances",
"Balanced": "Équilibré",
"Quality": "Qualité",
"Ultra": "Ultra",
"Ultra Performance": "Ultra perfs",
"Native": "Native",
"Full": "Complet",
"Reduced": "Réduit",
"Efficient": "Économe",
"Precise": "Précis",
"Auto (VSync)": "Auto (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Immediate",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS et graphique",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Aucun coût",
"Low cost": "Coût faible",
"Medium cost": "Coût moyen",
"High cost": "Coût élevé",
"Saves time": "Fait gagner du temps",
"Rendering": "Rendu",
"Display": "Affichage",
"Preferences": "Préférences",
"Overlay": "Surimpression",
"HDR": "HDR",
"Graphics": "Graphismes",
"Graphics: Advanced": "Graphismes : avancés",
"Graphics: HDR": "Graphismes : HDR",
"Advanced...": "Avancé...",
"Simple...": "Simple...",
"Done": "Terminé",
"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.",
"%s chunks": "%s chunks",
"%s per frame": "%s par image",
"%s MiB/frame": "%s MiB/image",
"%s KiB/frame": "%s KiB/image",
"%s nits": "%s nits",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s nits)",
"%.2f nits": "%.2f nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Terrain en détail complet. La distance LOD va plus loin.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terrain lointain simplifié, mondes locaux. GPU intégrés : 128 max.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Réduit : terrain lointain simplifié, plus rapide sur ce GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Réduit : terrain simplifié, pour les GPU qui ralentissent.",
"Field of view. Wider shows more terrain at once.": "Champ de vision. Plus large, il montre plus de terrain à la fois.",
"Caps frames per second; a cap saves power and heat.": "Limite les images par seconde ; économise énergie et chaleur.",
"Waits for the display's refresh: no tearing, a little latency.": "Attend l'écran : pas de déchirure, un peu de latence.",
"Size of menus and the HUD.": "Taille des menus et de l'interface (HUD).",
"Sets the options below together. Changing one makes it Custom.": "Règle les options ci-dessous. Modifier une : « Personnalisé ».",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lave, feu et soleil plus vifs que le blanc. Écran HDR requis.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Noir le plus profond de l'écran. 0 pour OLED, les LCD brillent.",
"How bright highlights get. Auto: the peak your system reports.": "La luminosité des hautes lumières. Auto : le pic indiqué par votre système.",
"How bright white paper and menus look. Auto: your desktop's white.": "Luminosité du blanc et des menus. Auto : blanc du bureau.",
"How bright the HUD and menus are, against paper white.": "La luminosité du HUD et des menus, par rapport au blanc papier.",
"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.",
"Blends grass and water colours across biomes when building chunks.": "Fond les couleurs d'herbe et d'eau entre les biomes.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Couche de nuages : désactivée, plate (rapide) ou 3D (détaillée).",
"How far clouds reach, in chunks.": "Jusqu'où s'étendent les nuages, en chunks.",
"Draws the sky, sun, moon and stars.": "Dessine le ciel, le soleil, la lune et les étoiles.",
"Distance fog. Off shows the edge of the loaded world.": "Brouillard lointain. Désactivé, il montre le bord du monde chargé.",
"Draws rain and snow.": "Dessine la pluie et la neige.",
"How far rain and snow fall around you.": "Jusqu'où la pluie et la neige tombent autour de vous.",
"How many particles effects spawn.": "Combien de particules les effets génèrent.",
"Round shadows under mobs and items.": "Ombres rondes sous les créatures et les objets.",
"Leaves with see-through gaps. Off draws them solid.": "Feuilles avec des trous transparents. Désactivé, elles sont pleines.",
"Sorts water and glass more exactly behind each other.": "Trie plus précisément l'eau et le verre les uns derrière les autres.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Quels chunks attendent l'image : aucun, proches ou les vôtres.",
"Animates water, lava, fire and other moving textures.": "Anime l'eau, la lave, le feu et les autres textures mobiles.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Pixels du monde par pixel d'écran. Sous 100 %, GPU plus rapide.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Lisse les bords. FXAA : doux. TAA : lisse, traînées possibles.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Rendu réduit, reconstruit par TAA. Plus rapide, plus doux.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Sortie HDR sur écran HDR : lave, feu et soleil plus vifs.",
"How frames reach the screen while VSync is off.": "Comment les images arrivent à l'écran sans synchro verticale.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Économe attend l'image suivante ; Précis tourne en boucle.",
"Fills the screen (borderless).": "Remplit l'écran (sans bordure).",
"Brightens dark places.": "Éclaircit les endroits sombres.",
"Lowers the frame rate when minimised, or idle too.": "Réduit les FPS si la fenêtre est réduite, ou aussi inactive.",
"Sways the view while walking.": "Fait osciller la vue pendant la marche.",
"Fades newly loaded chunks in.": "Fait apparaître en fondu les chunks nouvellement chargés.",
"Darkens the screen's edges.": "Assombrit les bords de l'écran.",
"Shows when the world saves.": "Indique quand le monde est sauvegardé.",
"Shows attack cooldown.": "Affiche le délai de recharge de l'attaque.",
"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.",
"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...",
"horizon.loading.opening_world": "Ouverture du monde",
"horizon.loading.cancelling_world": "Annulation en cours",
"horizon.title.not_affiliated": "Non affilié à Mojang ou à Microsoft",
"horizon.extras.header.lighting": "Éclairage",
"horizon.extras.header.third_person": "Troisième personne",
"horizon.extras.header.inventory": "Inventaire",
"horizon.extras.header.multiplayer": "Multijoueur",
"horizon.extras.fullbright": "Pleine luminosité",
"horizon.extras.dynamic_lights": "Lumières dynamiques",
"horizon.extras.own_name_tag": "Mon pseudo",
"horizon.extras.auto_reconnect": "Reconnexion auto",
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "Déplacer à la molette",
"horizon.extras.sort": "Boutons de tri",
"horizon.extras.sort_key": "Touche de tri",
"horizon.extras.no_ai": "Sans IA",
"horizon.extras.agent_control": "Contrôle par agent",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.fullbright.tooltip": "Affiche les blocs et les entités en pleine lumière, même dans les zones sombres.",
"horizon.extras.dynamic_lights.tooltip": "Les torches et autres sources de lumière tenues en main, jetées ou en feu éclairent les blocs autour d'elles.",
"horizon.extras.own_name_tag.tooltip": "Affiche votre nom au-dessus de votre tête à la troisième personne, comme les autres joueurs le voient. Masqué quand le serveur masque les noms de votre équipe ou que vous êtes invisible.",
"horizon.extras.auto_reconnect.tooltip": "Rejoint de nouveau un serveur après une perte de connexion ou un redémarrage, jamais après une expulsion ou un bannissement.",
"horizon.extras.mouse_tweaks.tooltip": "Glisser avec le clic droit en place un par case, glisser avec le clic gauche rassemble l'objet tenu, Maj + glisser avec le clic gauche déplace chaque case.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Vers le bas, envoie un objet de la case de l'autre côté ; vers le haut, en ramène un.",
"horizon.extras.sort.tooltip": "Trie l'inventaire, les coffres, les tonneaux, les coffres de l'Ender et les boîtes de Shulker selon l'ordre des onglets du mode créatif, en fusionnant les piles.",
"horizon.extras.sort_key.tooltip": "Trie la zone sous le curseur. Cliquez, puis appuyez sur une touche ; Échap retire l'assignation.",
"horizon.extras.no_ai.tooltip": "Masque et désactive toutes les fonctions d'IA : contrôle par agent, horizon-ctl et les serveurs MCP. Le commutateur du lanceur aussi.",
"horizon.extras.agent_control.tooltip": "Votre propre agent IA peut jouer à ce jeu via horizon-ctl ou un client MCP, uniquement sur cet ordinateur.",
"horizon.extras.discord_presence.tooltip": "Affiche « Joue à Horizon » sur votre profil Discord tant que Discord fonctionne : menus, solo ou multijoueur, la dimension et le temps de jeu. Jamais le nom d'un monde ni l'adresse d'un serveur.",
"Calibrate...": "Calibrer...",
"Calibrate Anyway...": "Calibrer quand même...",
"Use Automatic Setup": "Utiliser la configuration automatique",
"Shortcut: %s": "Raccourci : %s",
"Next": "Suivant",
"HDR Calibration %s/%s: %s": "Calibrage HDR %s/%s : %s",
"Sets the three below full screen, one test picture at a time.": "Règle les trois valeurs ci-dessous en plein écran, une image test à la fois.",
"A key that opens this screen from the game. Not bound at first.": "Une touche qui ouvre cet écran depuis le jeu. Non assignée au départ.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Votre écran n'a pas indiqué sa luminosité : calibrez-le.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Déjà configuré. Calibrez seulement si le HDR semble incorrect.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Rétablit Luminosité max. et Point blanc de votre écran.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Configuré pour votre écran. Laissez Auto si tout va bien.",
"Lower it until the logo just disappears into the black.": "Baissez-le jusqu'à ce que le logo disparaisse tout juste dans le noir.",
"Raise it until the logo just disappears into the bright square.": "Augmentez jusqu'à ce que le logo se fonde dans le carré clair.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Un blanc agréable à regarder longtemps, selon votre éclairage.",
"horizon.splitscreen.profiles.forget": "Oublier",
"horizon.splitscreen.profiles.create": "Créer",
"horizon.splitscreen.profiles.you": " - vous",
"horizon.splitscreen.profiles.forget_named": "Oublier %s",
"horizon.splitscreen.profiles.previous": "Précédent",
"horizon.splitscreen.profiles.next": "Suivant",
"horizon.splitscreen.profiles.new_button": "Nouveau profil...",
"horizon.splitscreen.profiles.forget_title": "Oublier le profil ?",
"horizon.splitscreen.profiles.forget_leaves": "Il quitte la liste de cette instance.",
"horizon.splitscreen.profiles.forget_kept": "La progression des mondes et les options sont conservées.",
"horizon.splitscreen.profiles.new_title": "Nouveau profil",
"horizon.splitscreen.profiles.name_hint": "Nom (3 à 16 lettres, chiffres ou _)",
"horizon.splitscreen.profiles.switch_title": "Changer de profil",
"horizon.splitscreen.profiles.offline_note": "Les profils hors ligne appartiennent à cette instance",
"horizon.splitscreen.profiles.move": "Changer d'emplacement de jeu",
"horizon.splitscreen.profiles.move_place": "Changer d'emplacement de jeu : %s",
"horizon.friends.title": "Jouer avec des amis",
"horizon.friends.hint": "Vos amis collent le code après le bouton Amis de l'écran titre, dans « Connexion rapide » ou sur la page Jouer du lanceur.",
"horizon.friends.copied": "Copié",
"horizon.friends.copy_code": "Copier le code",
"horizon.friends.stop_sharing": "Arrêter le partage",
"horizon.friends.none_yet": "Aucun ami pour l'instant.",
"horizon.friends.here_now": "Présents : %s",
"horizon.friends.share_intro": "Partagez ce monde avec un code d'invitation : il s'ouvre au LAN, et vos amis sur internet le rejoignent avec le code. Aucun serveur de notre part n'est impliqué.",
"horizon.friends.start_sharing": "Commencer le partage",
"horizon.friends.troubleshooting": "Dépannage...",
"horizon.friends.stopped": "Le partage du code d'invitation est arrêté.",
"horizon.friends.short_code_changed": "Quelqu'un a essayé plusieurs fois un mauvais code court, il a donc été changé.",
"horizon.friends.wrong_code": "Quelqu'un a essayé un mauvais code.",
"horizon.friends.code_ready": "Votre code d'invitation est prêt : Menu du jeu, Jouer avec des amis. Copiez-le et envoyez-le à vos amis.",
"horizon.friends.share_failed": "Impossible de partager le monde : %s",
"horizon.friends.finding_route": "Recherche de la manière dont vos amis peuvent vous joindre…",
"horizon.osk.shift": "Maj.",
"horizon.osk.space": "Espace",
"horizon.osk.delete": "Effacer",
"horizon.osk.type": "Saisir",
"horizon.osk.symbols": "Symboles",
"horizon.osk.close": "Fermer",
"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..."
}
@@ -12,6 +12,197 @@
"horizon.options.button_glyphs": "Symbole przycisków",
"horizon.options.menu_navigation": "Nawigacja po menu",
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa",
"horizon.options.chat_delay": "Opóźnienie czatu",
"horizon.options.lightbar.health": "Zdrowie",
"horizon.options.lightbar.dimension": "Wymiar",
"horizon.options.glyphs.auto": "Auto",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Fokus",
"horizon.options.menu_pointer": "Wskaźnik",
"horizon.options.keyboard.auto": "Auto",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Ta funkcja nie jest jeszcze dostępna w Horizon.",
"horizon.options.search": "Szukaj ustawień…",
"horizon.pages.mods": "Mody",
"horizon.pages.shader_packs": "Paczki shaderów",
"horizon.pages.shader_options": "Ustawienia shaderów",
"horizon.pages.controller": "Ustawienia kontrolera",
"horizon.pages.controller_binds": "Przypisane przyciski kontrolera",
"horizon.pages.extras": "Dodatki Horizon",
"horizon.pages.settings": "Ustawienia Horizon",
"horizon.pages.agent": "Agent AI",
"horizon.pages.split_profiles": "Kto gra?",
"horizon.pages.settings.tooltip": "Własne ustawienia Horizon: grafika, kontrolery, dodatki i mody.",
"horizon.pages.graphics.tooltip": "Grafika w prostej formie: każde ustawienie podaje swój koszt.",
"horizon.pages.graphics_advanced.tooltip": "Wszystkie ustawienia grafiki, także te z Horizon, wraz z kosztem każdego z nich.",
"horizon.pages.controller.tooltip": "Kontrolery: ich przyciski, drążki, wibracje i nawigacja po menu.",
"horizon.pages.extras.tooltip": "Opcjonalne dodatki, wszystkie domyślnie wyłączone: gra pozostaje vanilla, dopóki nie włączysz któregoś.",
"horizon.pages.agent.tooltip": "Pozwól własnemu agentowi AI grać: horizon-ctl i klienci MCP na tym komputerze.",
"horizon.pages.split_profiles.tooltip": "Kto gra: profil przechowywany w tym folderze gry.",
"horizon.pages.shader_packs.tooltip": "Paczki shaderów z folderu shaderpacks: własne paczki Horizon i te w formacie shaders/. Naraz działa jedna.",
"horizon.pages.mods.tooltip": "Mody stworzone dla Horizon, z folderu horizon-mods. Każdy działa w piaskownicy i pozostaje wyłączony, dopóki go nie włączysz.",
"horizon.pages.open.graphics": "Grafika…",
"horizon.pages.open.graphics_advanced": "Zaawansowana grafika…",
"horizon.pages.open.shader_packs": "Paczki shaderów…",
"horizon.pages.open.controller": "Ustawienia kontrolera…",
"horizon.pages.open.extras": "Dodatki…",
"horizon.pages.open.mods": "Mody…",
"horizon.pages.open.agent": "Agent AI…",
"horizon.pages.open.horizon_graphics": "Grafika Horizon…",
"horizon.pages.open.controller_binds": "Przyciski kontrolera…",
"horizon.pages.open.horizon_settings": "Ustawienia Horizon…",
"horizon.mods.empty": "Brak modów: umieść pliki .hmod w folderze horizon-mods",
"horizon.mods.stopped": " (zatrzymany)",
"horizon.mods.not_built": " (niezbudowany)",
"horizon.mods.dev": " (dev)",
"horizon.mods.settings": "Ustawienia",
"horizon.mods.open_folder": "Otwórz folder z modami",
"horizon.shader_packs.empty": "Brak paczek shaderów: umieść je w folderze shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Ustawienia shaderów…",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Wczytywanie ustawień paczki shaderów…",
"horizon.agent.copy_details": "Kopiuj dane połączenia",
"horizon.multiplayer.join": "Dołącz",
"horizon.multiplayer.cancel_reconnecting": "Anuluj ponowne łączenie",
"horizon.multiplayer.join_friend": "Dołącz do znajomego",
"horizon.multiplayer.invite_code": "Kod zaproszenia",
"horizon.multiplayer.relay": "Przekaźnik (opcjonalnie)",
"Preset": "Profil",
"LOD Distance": "Zasięg LOD",
"Far Detail": "Dalekie detale",
"Cave Culling": "Pomijanie jaskiń",
"Entity Culling": "Pomijanie bytów",
"Chunk Updates": "Aktual. chunków",
"Upload Budget": "Wysyłka",
"Sky": "Niebo",
"Fog": "Mgła",
"Weather": "Pogoda",
"Animated Textures": "Animowane tekstury",
"Present Mode": "Prezentacja",
"Frame Pacing": "Tempo klatek",
"Worker Threads": "Wątki robocze",
"Resolution Scale": "Skala rozdzielczości",
"Anti-aliasing": "Antyaliasing",
"Upscaling": "Skalowanie",
"HDR Settings...": "Ustawienia HDR…",
"Paper White": "Punkt bieli",
"Peak Brightness": "Jasność maks.",
"Black Level": "Poziom czerni",
"UI Brightness": "Jasność interfejsu",
"Battery Mode": "Tryb baterii",
"Graphics API": "API grafiki",
"Performance Overlay": "Nakładka wydajności",
"Full Detail Distance": "Zasięg detali",
"On": "Wł.",
"Off": "Wył.",
"Unlimited": "Bez limitu",
"Auto": "Auto",
"Custom": "Własne",
"Performance": "Wydajność",
"Balanced": "Zrównoważone",
"Quality": "Jakość",
"Ultra": "Ultra",
"Ultra Performance": "Ultra wydajność",
"Native": "Natywna",
"Full": "Pełne",
"Reduced": "Ograniczone",
"Efficient": "Oszczędne",
"Precise": "Dokładne",
"Auto (VSync)": "Auto (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Immediate",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS i wykres",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Bez kosztu",
"Low cost": "Niski koszt",
"Medium cost": "Średni koszt",
"High cost": "Wysoki koszt",
"Saves time": "Oszczędza czas",
"Rendering": "Renderowanie",
"Display": "Wyświetlanie",
"Preferences": "Preferencje",
"Overlay": "Nakładka",
"HDR": "HDR",
"Graphics": "Grafika",
"Graphics: Advanced": "Grafika: zaawansowane",
"Graphics: HDR": "Grafika: HDR",
"Advanced...": "Zaawansowane…",
"Simple...": "Proste…",
"Done": "Gotowe",
"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ć.",
"%s chunks": "%s chunków",
"%s per frame": "%s na klatkę",
"%s MiB/frame": "%s MiB/klatka",
"%s KiB/frame": "%s KiB/klatka",
"%s nits": "%s nitów",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s nitów)",
"%.2f nits": "%.2f nitów",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Zasięg terenu w pełnych szczegółach. Zasięg LOD sięga dalej.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Uproszczony odległy teren w światach lokalnych. Zintegr. GPU: do 128.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Ograniczone: odległy teren rysowany prościej, szybciej na tym GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Ograniczone: odległy teren prościej, dla GPU, które zwalniają.",
"Field of view. Wider shows more terrain at once.": "Pole widzenia. Szersze pokazuje naraz więcej terenu.",
"Caps frames per second; a cap saves power and heat.": "Ogranicza klatki na sekundę; limit oszczędza energię i ciepło.",
"Waits for the display's refresh: no tearing, a little latency.": "Czeka na odświeżenie ekranu: bez rozrywania obrazu, z niewielkim opóźnieniem.",
"Size of menus and the HUD.": "Rozmiar menu i interfejsu HUD.",
"Sets the options below together. Changing one makes it Custom.": "Ustawia razem poniższe opcje. Zmiana jednej zmienia na „Własne”.",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Lawa, ogień i słońce świecą jaśniej niż biel. Wymaga ekranu HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Najciemniejsza czerń twojego wyświetlacza. 0 dla OLED; LCD trochę się świecą.",
"How bright highlights get. Auto: the peak your system reports.": "Jak jasne mogą być światła. Auto: szczyt zgłaszany przez system.",
"How bright white paper and menus look. Auto: your desktop's white.": "Jak jasny wygląda biały papier i menu. Auto: biel twojego pulpitu.",
"How bright the HUD and menus are, against paper white.": "Jak jasne są HUD i menu w stosunku do bieli papieru.",
"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.",
"Blends grass and water colours across biomes when building chunks.": "Przenika kolory trawy i wody między biomami podczas budowania chunków.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Warstwa chmur: wyłączona, płaska (szybka) lub 3D (dokładna).",
"How far clouds reach, in chunks.": "Jak daleko sięgają chmury, w chunkach.",
"Draws the sky, sun, moon and stars.": "Rysuje niebo, słońce, księżyc i gwiazdy.",
"Distance fog. Off shows the edge of the loaded world.": "Mgła w oddali. Wyłączona pokazuje krawędź załadowanego świata.",
"Draws rain and snow.": "Rysuje deszcz i śnieg.",
"How far rain and snow fall around you.": "Jak daleko wokół ciebie pada deszcz i śnieg.",
"How many particles effects spawn.": "Ile cząstek tworzą efekty.",
"Round shadows under mobs and items.": "Okrągłe cienie pod stworzeniami i przedmiotami.",
"Leaves with see-through gaps. Off draws them solid.": "Liście z prześwitami. Wyłączone rysuje je jako pełne.",
"Sorts water and glass more exactly behind each other.": "Dokładniej sortuje wodę i szkło jedno za drugim.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Które przebudowy chunków czekają na klatkę: żadne, pobliskie lub twoje.",
"Animates water, lava, fire and other moving textures.": "Animuje wodę, lawę, ogień i inne ruchome tekstury.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Piksele świata na piksel ekranu. Poniżej 100% GPU działa szybciej.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Wygładza krawędzie. FXAA: miękko. TAA: najgładziej, możliwe smugi.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Renderuje mniejszy świat, odtwarza przez TAA. Szybciej, miękko.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Wyjście HDR na ekranie HDR: lawa, ogień i słońce świecą jaśniej.",
"How frames reach the screen while VSync is off.": "Jak klatki trafiają na ekran, gdy VSync jest wyłączony.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "Oszczędne usypia do następnej klatki; Dokładne czeka aktywnie.",
"Fills the screen (borderless).": "Wypełnia ekran (bez ramki).",
"Brightens dark places.": "Rozjaśnia ciemne miejsca.",
"Lowers the frame rate when minimised, or idle too.": "Obniża liczbę klatek po zminimalizowaniu, a także podczas bezczynności.",
"Sways the view while walking.": "Kołysze widokiem podczas chodzenia.",
"Fades newly loaded chunks in.": "Płynnie pokazuje nowo załadowane chunki.",
"Darkens the screen's edges.": "Przyciemnia krawędzie ekranu.",
"Shows when the world saves.": "Pokazuje, kiedy świat się zapisuje.",
"Shows attack cooldown.": "Pokazuje czas odnowienia ataku.",
"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.",
"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…",
"horizon.loading.opening_world": "Otwieranie świata",
"horizon.loading.cancelling_world": "Anulowanie",
@@ -19,10 +210,84 @@
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…",
"horizon.pages.shader_options": "Ustawienia shaderów",
"horizon.pages.split_profiles": "Kto gra?",
"horizon.pages.split_profiles.tooltip": "Kto gra: profil przechowywany w tym folderze gry.",
"horizon.shader_options.open": "Ustawienia shaderów…",
"horizon.shader_options.profile": "Profil",
"horizon.shader_options.loading": "Wczytywanie ustawień paczki shaderów…"
"horizon.title.not_affiliated": "Niepowiązane z Mojang ani Microsoft",
"horizon.extras.header.lighting": "Oświetlenie",
"horizon.extras.header.third_person": "Widok z trzeciej osoby",
"horizon.extras.header.inventory": "Ekwipunek",
"horizon.extras.header.multiplayer": "Tryb wieloosobowy",
"horizon.extras.fullbright": "Pełna jasność",
"horizon.extras.dynamic_lights": "Dynamiczne światła",
"horizon.extras.own_name_tag": "Własny nick",
"horizon.extras.auto_reconnect": "Ponowne łączenie",
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "Przenoszenie kółkiem",
"horizon.extras.sort": "Przyciski sortowania",
"horizon.extras.sort_key": "Klawisz sort.",
"horizon.extras.no_ai": "Bez AI",
"horizon.extras.agent_control": "Sterowanie agentem",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.fullbright.tooltip": "Pokazuje bloki i byty w pełnym oświetleniu, nawet w ciemnych miejscach.",
"horizon.extras.dynamic_lights.tooltip": "Pochodnie i inne źródła światła trzymane w dłoni, upuszczone lub płonące oświetlają otaczające bloki.",
"horizon.extras.own_name_tag.tooltip": "Pokazuje twój nick nad głową w widoku z trzeciej osoby, tak jak widzą go inni gracze. Ukryty, gdy serwer ukrywa nazwy twojej drużyny lub jesteś niewidzialny.",
"horizon.extras.auto_reconnect.tooltip": "Ponownie dołącza do serwera po utracie połączenia lub restarcie, nigdy po wyrzuceniu lub banie.",
"horizon.extras.mouse_tweaks.tooltip": "Przeciąganie prawym przyciskiem układa po jednym na pole, lewym zbiera trzymany przedmiot, Shift + lewy przycisk przenosi każde pole.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Przewinięcie w dół wysyła jeden przedmiot z pola na drugą stronę, w górę przywraca jeden.",
"horizon.extras.sort.tooltip": "Sortuje ekwipunek, skrzynie, beczki, skrzynie Endu i shulkerowe skrzynie w kolejności kart trybu kreatywnego, łącząc stosy.",
"horizon.extras.sort_key.tooltip": "Sortuje część pod kursorem. Kliknij, a potem naciśnij klawisz; Escape usuwa przypisanie.",
"horizon.extras.no_ai.tooltip": "Ukrywa i wyłącza wszystkie funkcje AI: sterowanie agentem, horizon-ctl i serwery MCP. Także przełącznik w launcherze.",
"horizon.extras.agent_control.tooltip": "Twój własny agent AI może grać w tę grę przez horizon-ctl lub klienta MCP, tylko na tym komputerze.",
"horizon.extras.discord_presence.tooltip": "Pokazuje „Gra w Horizon” w twoim profilu Discord, gdy Discord jest uruchomiony: menu, tryb jednoosobowy lub wieloosobowy, wymiar i czas gry. Nigdy nazwy świata ani adresu serwera.",
"Calibrate...": "Kalibruj…",
"Calibrate Anyway...": "Kalibruj mimo to…",
"Use Automatic Setup": "Użyj automatycznej konfiguracji",
"Shortcut: %s": "Skrót: %s",
"Next": "Dalej",
"HDR Calibration %s/%s: %s": "Kalibracja HDR %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Ustawia trzy wartości poniżej na pełnym ekranie, na obrazie testowym.",
"A key that opens this screen from the game. Not bound at first.": "Klawisz otwierający ten ekran z poziomu gry. Na początku nieprzypisany.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Wyświetlacz nie podał jasności: skalibruj go, aby go skonfigurować.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Już skonfigurowane. Kalibruj tylko, gdy HDR wygląda źle.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Przywraca Jasność maks. i Punkt bieli do ustawień wyświetlacza.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Skonfigurowane dla twojego wyświetlacza. Zostaw Auto, chyba że wygląda źle.",
"Lower it until the logo just disappears into the black.": "Zmniejszaj, aż logo właśnie zniknie w czerni.",
"Raise it until the logo just disappears into the bright square.": "Zwiększaj, aż logo właśnie zniknie w jasnym kwadracie.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Ustaw biel, na którą wygodnie patrzeć długo, w twoim oświetleniu.",
"horizon.splitscreen.profiles.forget": "Zapomnij",
"horizon.splitscreen.profiles.create": "Utwórz",
"horizon.splitscreen.profiles.you": " - ty",
"horizon.splitscreen.profiles.forget_named": "Zapomnij %s",
"horizon.splitscreen.profiles.previous": "Poprzednia",
"horizon.splitscreen.profiles.next": "Dalej",
"horizon.splitscreen.profiles.new_button": "Nowy profil…",
"horizon.splitscreen.profiles.forget_title": "Zapomnieć profil?",
"horizon.splitscreen.profiles.forget_leaves": "Znika z listy tej instancji.",
"horizon.splitscreen.profiles.forget_kept": "Postępy w świecie i ustawienia zostają zachowane.",
"horizon.splitscreen.profiles.new_title": "Nowy profil",
"horizon.splitscreen.profiles.name_hint": "Nazwa (3–16 liter, cyfr lub _)",
"horizon.splitscreen.profiles.switch_title": "Zmień profil",
"horizon.splitscreen.profiles.offline_note": "Profile offline należą do tej instancji",
"horizon.splitscreen.profiles.move": "Zmień miejsce gry",
"horizon.splitscreen.profiles.move_place": "Zmień miejsce gry: %s",
"horizon.friends.title": "Graj ze znajomymi",
"horizon.friends.hint": "Znajomi wklejają kod po kliknięciu przycisku Znajomi na ekranie tytułowym, w Połączeniu bezpośrednim lub na stronie Graj w launcherze.",
"horizon.friends.copied": "Skopiowano",
"horizon.friends.copy_code": "Kopiuj kod",
"horizon.friends.stop_sharing": "Przestań udostępniać",
"horizon.friends.none_yet": "Nie ma jeszcze żadnych znajomych.",
"horizon.friends.here_now": "Obecnie: %s",
"horizon.friends.share_intro": "Udostępnij ten świat za pomocą kodu zaproszenia: otwiera się w sieci LAN, a znajomi przez internet dołączają za pomocą kodu. Nie korzystamy z żadnego naszego serwera.",
"horizon.friends.start_sharing": "Zacznij udostępniać",
"horizon.friends.troubleshooting": "Rozwiązywanie problemów…",
"horizon.friends.stopped": "Przestano udostępniać kod zaproszenia.",
"horizon.friends.short_code_changed": "Ktoś wciąż wpisywał zły krótki kod, więc został zmieniony.",
"horizon.friends.wrong_code": "Ktoś wpisał zły kod.",
"horizon.friends.code_ready": "Twój kod zaproszenia jest gotowy: Menu gry, Graj ze znajomymi. Skopiuj go i wyślij znajomym.",
"horizon.friends.share_failed": "Nie udało się udostępnić świata: %s",
"horizon.friends.finding_route": "Sprawdzanie, jak znajomi mogą się z tobą połączyć…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Spacja",
"horizon.osk.delete": "Usuń",
"horizon.osk.type": "Wpisz",
"horizon.osk.symbols": "Symbole",
"horizon.osk.close": "Zamknij"
}
@@ -12,6 +12,197 @@
"horizon.options.button_glyphs": "Значки кнопок",
"horizon.options.menu_navigation": "Навігація в меню",
"horizon.options.on_screen_keyboard": "Екранна клавіатура",
"horizon.options.chat_delay": "Затримка чату",
"horizon.options.lightbar.health": "Здоров’я",
"horizon.options.lightbar.dimension": "Вимір",
"horizon.options.glyphs.auto": "Авто",
"horizon.options.glyphs.xbox": "Xbox",
"horizon.options.glyphs.playstation": "PlayStation",
"horizon.options.glyphs.nintendo": "Nintendo",
"horizon.options.menu_focus": "Фокус",
"horizon.options.menu_pointer": "Курсор",
"horizon.options.keyboard.auto": "Авто",
"horizon.options.keyboard.horizon": "Horizon",
"horizon.options.keyboard.steam": "Steam",
"horizon.options.unavailable": "Ця функція ще недоступна в Horizon.",
"horizon.options.search": "Шукати налаштування…",
"horizon.pages.mods": "Моди",
"horizon.pages.shader_packs": "Пакети шейдерів",
"horizon.pages.shader_options": "Налаштування шейдерів",
"horizon.pages.controller": "Налаштування геймпада",
"horizon.pages.controller_binds": "Призначення кнопок геймпада",
"horizon.pages.extras": "Додатки Horizon",
"horizon.pages.settings": "Налаштування Horizon",
"horizon.pages.agent": "ШІ-агент",
"horizon.pages.split_profiles": "Хто грає?",
"horizon.pages.settings.tooltip": "Власні налаштування Horizon: графіка, геймпади, додатки та моди.",
"horizon.pages.graphics.tooltip": "Графіка просто: кожен параметр показує, чого він коштує.",
"horizon.pages.graphics_advanced.tooltip": "Усі параметри графіки, зокрема з Horizon, із зазначенням витрат кожного.",
"horizon.pages.controller.tooltip": "Геймпади: їхні кнопки, стіки, вібрація та навігація в меню.",
"horizon.pages.extras.tooltip": "Необов’язкові додатки, усі типово вимкнені: гра лишається ванільною, доки ви не ввімкнете якийсь.",
"horizon.pages.agent.tooltip": "Дозвольте власному ШІ-агентові грати: horizon-ctl і MCP-клієнти на цьому комп’ютері.",
"horizon.pages.split_profiles.tooltip": "Хто грає: профіль, що зберігається в цій теці гри.",
"horizon.pages.shader_packs.tooltip": "Пакети шейдерів із теки shaderpacks: власні пакети Horizon і пакети у форматі shaders/. Одночасно працює один.",
"horizon.pages.mods.tooltip": "Моди, створені для Horizon, із теки horizon-mods. Кожен працює в ізольованому середовищі й залишається вимкненим, доки ви його не ввімкнете.",
"horizon.pages.open.graphics": "Графіка…",
"horizon.pages.open.graphics_advanced": "Розширена графіка…",
"horizon.pages.open.shader_packs": "Пакети шейдерів…",
"horizon.pages.open.controller": "Налаштування геймпада…",
"horizon.pages.open.extras": "Додатки…",
"horizon.pages.open.mods": "Моди…",
"horizon.pages.open.agent": "ШІ-агент…",
"horizon.pages.open.horizon_graphics": "Графіка Horizon…",
"horizon.pages.open.controller_binds": "Кнопки геймпада…",
"horizon.pages.open.horizon_settings": "Налаштування Horizon…",
"horizon.mods.empty": "Немає модів: покладіть файли .hmod у теку horizon-mods",
"horizon.mods.stopped": " (зупинено)",
"horizon.mods.not_built": " (не зібрано)",
"horizon.mods.dev": " (розробка)",
"horizon.mods.settings": "Налаштування",
"horizon.mods.open_folder": "Відкрити теку з модами",
"horizon.shader_packs.empty": "Немає пакетів шейдерів: покладіть їх у теку shaderpacks",
"horizon.shader_packs.kind_horizon": " (Horizon)",
"horizon.shader_options.open": "Налаштування шейдерів…",
"horizon.shader_options.profile": "Профіль",
"horizon.shader_options.loading": "Читання налаштувань набору шейдерів…",
"horizon.agent.copy_details": "Копіювати дані з’єднання",
"horizon.multiplayer.join": "Приєднатися",
"horizon.multiplayer.cancel_reconnecting": "Скасувати перепідключення",
"horizon.multiplayer.join_friend": "Приєднатися до друга",
"horizon.multiplayer.invite_code": "Код запрошення",
"horizon.multiplayer.relay": "Ретранслятор (необов’язково)",
"Preset": "Шаблон",
"LOD Distance": "Дальність LOD",
"Far Detail": "Деталі вдалині",
"Cave Culling": "Відсікання печер",
"Entity Culling": "Відсік. сутностей",
"Chunk Updates": "Оновл. чанків",
"Upload Budget": "Вивантаження",
"Sky": "Небо",
"Fog": "Туман",
"Weather": "Погода",
"Animated Textures": "Анімовані текстури",
"Present Mode": "Виведення",
"Frame Pacing": "Темп кадрів",
"Worker Threads": "Робочі потоки",
"Resolution Scale": "Масштаб зображення",
"Anti-aliasing": "Згладжування",
"Upscaling": "Апскейлінг",
"HDR Settings...": "Налаштування HDR…",
"Paper White": "Біла точка",
"Peak Brightness": "Пік яскравості",
"Black Level": "Рівень чорного",
"UI Brightness": "Яскравість UI",
"Battery Mode": "Режим батареї",
"Graphics API": "API графіки",
"Performance Overlay": "Накладка швидкодії",
"Full Detail Distance": "Дальність деталей",
"On": "Увімк.",
"Off": "Вимк.",
"Unlimited": "Без обмежень",
"Auto": "Авто",
"Custom": "Власний",
"Performance": "Продуктивність",
"Balanced": "Збалансований",
"Quality": "Якість",
"Ultra": "Ультра",
"Ultra Performance": "Ультрапродукт.",
"Native": "Нативна",
"Full": "Повна",
"Reduced": "Знижена",
"Efficient": "Економний",
"Precise": "Точний",
"Auto (VSync)": "Авто (VSync)",
"FIFO": "FIFO",
"Mailbox": "Mailbox",
"Immediate": "Immediate",
"Vulkan": "Vulkan",
"DirectX 12": "DirectX 12",
"OpenGL": "OpenGL",
"FPS and Graph": "FPS і графік",
"FPS": "FPS",
"TAA": "TAA",
"FXAA": "FXAA",
"No cost": "Без витрат",
"Low cost": "Низькі витрати",
"Medium cost": "Середні витрати",
"High cost": "Високі витрати",
"Saves time": "Заощаджує час",
"Rendering": "Промальовування",
"Display": "Дисплей",
"Preferences": "Уподобання",
"Overlay": "Накладка",
"HDR": "HDR",
"Graphics": "Графіка",
"Graphics: Advanced": "Графіка: розширені",
"Graphics: HDR": "Графіка: HDR",
"Advanced...": "Розширені…",
"Simple...": "Прості…",
"Done": "Готово",
"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, щоб користуватися цим.",
"%s chunks": "%s чанків",
"%s per frame": "%s на кадр",
"%s MiB/frame": "%s MiB/кадр",
"%s KiB/frame": "%s KiB/кадр",
"%s nits": "%s ніт",
"Auto (%s)": "Авто (%s)",
"Auto (%s nits)": "Авто (%s ніт)",
"%.2f nits": "%.2f ніт",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Дальність повних деталей. Дальність LOD сягає далі.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Спрощена далека місцевість локальних світів. Інтегр. GPU: до 128.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Знижена: далека місцевість простіша, швидше на цьому GPU.",
"Reduced draws far terrain coarser, for GPUs it slows down.": "Знижена: простіша далека місцевість для повільних GPU.",
"Field of view. Wider shows more terrain at once.": "Поле зору. Ширше показує більше місцевості одночасно.",
"Caps frames per second; a cap saves power and heat.": "Обмежує к/с; обмеження заощаджує енергію й зменшує нагрів.",
"Waits for the display's refresh: no tearing, a little latency.": "Чекає на оновлення дисплея: без розривів, невелика затримка.",
"Size of menus and the HUD.": "Розмір меню та HUD.",
"Sets the options below together. Changing one makes it Custom.": "Задає параметри нижче разом. Зміна одного дає «Власний».",
"Lava, fire and sunlight shine brighter than white. Needs an HDR display.": "Лава, вогонь і сонце світліші за білий. Потрібен HDR.",
"Your display's darkest black. 0 for OLED; LCDs glow a little.": "Найтемніший чорний вашого дисплея. 0 для OLED; LCD трохи світяться.",
"How bright highlights get. Auto: the peak your system reports.": "Наскільки яскравими бувають відблиски. Авто: пік, який повідомляє система.",
"How bright white paper and menus look. Auto: your desktop's white.": "Яскравість білого паперу та меню. Авто: білий вашого стола.",
"How bright the HUD and menus are, against paper white.": "Наскільки яскраві HUD і меню відносно білого паперу.",
"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.": "Менші копії текстур блоків для віддаленої місцевості: менше мерехтіння.",
"Blends grass and water colours across biomes when building chunks.": "Змішує кольори трави та води між біомами під час побудови чанків.",
"Cloud layer: off, flat (fast) or 3D (fancy).": "Шар хмар: вимкнено, пласкі (швидкі) або 3D (красиві).",
"How far clouds reach, in chunks.": "Як далеко сягають хмари, у чанках.",
"Draws the sky, sun, moon and stars.": "Малює небо, сонце, місяць і зірки.",
"Distance fog. Off shows the edge of the loaded world.": "Туман на відстані. Вимкнений показує межу завантаженого світу.",
"Draws rain and snow.": "Малює дощ і сніг.",
"How far rain and snow fall around you.": "Як далеко навколо вас падають дощ і сніг.",
"How many particles effects spawn.": "Скільки частинок створюють ефекти.",
"Round shadows under mobs and items.": "Круглі тіні під мобами та предметами.",
"Leaves with see-through gaps. Off draws them solid.": "Листя з просвітами. Вимкнено — малюється суцільним.",
"Sorts water and glass more exactly behind each other.": "Точніше сортує воду та скло одне за одним.",
"Which chunk rebuilds wait for the frame: none, nearby or yours.": "Які перебудови чанків чекають на кадр: жодні, найближчі чи ваші.",
"Animates water, lava, fire and other moving textures.": "Анімує воду, лаву, вогонь та інші рухомі текстури.",
"World pixels per screen pixel. Below 100% is faster on the GPU.": "Пікселі світу на піксель екрана. Нижче 100% — швидше для GPU.",
"Smooths jagged edges. FXAA: softer. TAA: smoothest, moving things may trail.": "Згладжує краї. FXAA: м’якше. TAA: найгладше, можливі шлейфи.",
"Renders the world smaller and rebuilds it with TAA. Faster, softer.": "Менший рендер, відновлення через TAA. Швидше, м’якше.",
"HDR output on an HDR display: lava, fire and sunlight shine brighter.": "Вивід HDR на HDR-дисплеї: лава, вогонь і сонце яскравіші.",
"How frames reach the screen while VSync is off.": "Як кадри потрапляють на екран, коли VSync вимкнено.",
"Efficient sleeps until the next frame; Precise spins, like vanilla.": "«Економний» спить до наступного кадру; «Точний» чекає активно.",
"Fills the screen (borderless).": "Заповнює екран (без рамки).",
"Brightens dark places.": "Освітлює темні місця.",
"Lowers the frame rate when minimised, or idle too.": "Знижує частоту кадрів при згортанні, а також при бездіяльності.",
"Sways the view while walking.": "Розхитує вид під час ходьби.",
"Fades newly loaded chunks in.": "Плавно показує щойно завантажені чанки.",
"Darkens the screen's edges.": "Затемнює краї екрана.",
"Shows when the world saves.": "Показує, коли світ зберігається.",
"Shows attack cooldown.": "Показує перезарядку атаки.",
"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). Після рестарту.",
"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": "Шукаємо світ друга…",
"horizon.loading.opening_world": "Відкриття світу",
"horizon.loading.cancelling_world": "Скасування",
@@ -19,10 +210,84 @@
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…",
"horizon.pages.shader_options": "Налаштування шейдерів",
"horizon.pages.split_profiles": "Хто грає?",
"horizon.pages.split_profiles.tooltip": "Хто грає: профіль, що зберігається в цій теці гри.",
"horizon.shader_options.open": "Налаштування шейдерів…",
"horizon.shader_options.profile": "Профіль",
"horizon.shader_options.loading": "Читання налаштувань набору шейдерів…"
"horizon.title.not_affiliated": "Не пов’язано з Mojang чи Microsoft",
"horizon.extras.header.lighting": "Освітлення",
"horizon.extras.header.third_person": "Вигляд від третьої особи",
"horizon.extras.header.inventory": "Інвентар",
"horizon.extras.header.multiplayer": "Гра в мережі",
"horizon.extras.fullbright": "Повна яскравість",
"horizon.extras.dynamic_lights": "Динамічне світло",
"horizon.extras.own_name_tag": "Власний нік",
"horizon.extras.auto_reconnect": "Перепідключення",
"horizon.extras.mouse_tweaks": "Mouse Tweaks",
"horizon.extras.mouse_tweaks_wheel": "Перенос коліщатком",
"horizon.extras.sort": "Кнопки сортування",
"horizon.extras.sort_key": "Сортувати",
"horizon.extras.no_ai": "Без ШІ",
"horizon.extras.agent_control": "Керування агентом",
"horizon.extras.discord_presence": "Discord Rich Presence",
"horizon.extras.fullbright.tooltip": "Показує блоки та сутності при повному освітленні, навіть у темних місцях.",
"horizon.extras.dynamic_lights.tooltip": "Смолоскипи та інші джерела світла, які тримають у руках, викинуті чи палаючі, освітлюють блоки навколо.",
"horizon.extras.own_name_tag.tooltip": "Показує ваше ім’я над головою від третьої особи, як його бачать інші гравці. Приховано, коли сервер ховає імена вашої команди або ви невидимі.",
"horizon.extras.auto_reconnect.tooltip": "Знову приєднується до сервера після втрати з’єднання чи перезапуску, але ніколи після вигнання чи блокування.",
"horizon.extras.mouse_tweaks.tooltip": "Перетягування ПКМ кладе по одному в комірку, ЛКМ збирає утримуваний предмет, Shift + ЛКМ переміщує кожну комірку.",
"horizon.extras.mouse_tweaks_wheel.tooltip": "Прокрутка вниз надсилає один предмет із комірки на інший бік, вгору — повертає один назад.",
"horizon.extras.sort.tooltip": "Сортує інвентар, скрині, діжки, скрині Енду та шалкерові коробки в порядку вкладок творчого режиму, об’єднуючи стеки.",
"horizon.extras.sort_key.tooltip": "Сортує частину під курсором. Натисніть, а потім клавішу; Escape скасовує призначення.",
"horizon.extras.no_ai.tooltip": "Приховує та вимикає всі функції ШІ: керування агентом, horizon-ctl і MCP-сервери. Також перемикач у лаунчері.",
"horizon.extras.agent_control.tooltip": "Ваш власний ШІ-агент може грати в цю гру через horizon-ctl або MCP-клієнт, лише на цьому комп’ютері.",
"horizon.extras.discord_presence.tooltip": "Показує «Грає в Horizon» у вашому профілі Discord, поки Discord запущено: меню, одиночна чи мережева гра, вимір і час гри. Ніколи не назву світу чи адресу сервера.",
"Calibrate...": "Калібрувати…",
"Calibrate Anyway...": "Все одно калібрувати…",
"Use Automatic Setup": "Використати автоналаштування",
"Shortcut: %s": "Гаряча клавіша: %s",
"Next": "Далі",
"HDR Calibration %s/%s: %s": "Калібрування HDR %s/%s: %s",
"Sets the three below full screen, one test picture at a time.": "Три значення нижче налаштовуються на повному екрані.",
"A key that opens this screen from the game. Not bound at first.": "Клавіша, що відкриває цей екран із гри. Спершу не призначена.",
"Your display didn't say how bright it gets: calibrate to set it up.": "Дисплей не повідомив яскравість: відкалібруйте його.",
"Already set up for your display. Only calibrate if HDR looks wrong.": "Уже налаштовано. Калібруйте, лише якщо HDR виглядає погано.",
"Puts Peak Brightness and Paper White back to your display's setup.": "Повертає Пік яскравості й Білу точку до налаштувань дисплея.",
"Set up for your display. Leave on Auto unless it looks wrong.": "Налаштовано для дисплея. Залишайте «Авто», якщо все гаразд.",
"Lower it until the logo just disappears into the black.": "Зменшуйте, доки логотип не зникне в чорному.",
"Raise it until the logo just disappears into the bright square.": "Збільшуйте, доки логотип не зникне в яскравому квадраті.",
"Make the card a white that's comfortable to look at for a long time, in the light you play in.": "Білий, на який комфортно довго дивитися за вашого світла.",
"horizon.splitscreen.profiles.forget": "Забути",
"horizon.splitscreen.profiles.create": "Створити",
"horizon.splitscreen.profiles.you": " - ви",
"horizon.splitscreen.profiles.forget_named": "Забути %s",
"horizon.splitscreen.profiles.previous": "Назад",
"horizon.splitscreen.profiles.next": "Далі",
"horizon.splitscreen.profiles.new_button": "Новий профіль…",
"horizon.splitscreen.profiles.forget_title": "Забути профіль?",
"horizon.splitscreen.profiles.forget_leaves": "Він зникне зі списку цього екземпляра.",
"horizon.splitscreen.profiles.forget_kept": "Прогрес у світі та налаштування зберігаються.",
"horizon.splitscreen.profiles.new_title": "Новий профіль",
"horizon.splitscreen.profiles.name_hint": "Ім’я (3–16 літер, цифр або _)",
"horizon.splitscreen.profiles.switch_title": "Змінити профіль",
"horizon.splitscreen.profiles.offline_note": "Офлайн-профілі належать цьому екземпляру",
"horizon.splitscreen.profiles.move": "Змінити місце гри",
"horizon.splitscreen.profiles.move_place": "Змінити місце гри: %s",
"horizon.friends.title": "Грати з друзями",
"horizon.friends.hint": "Друзі вставляють код після кнопки «Друзі» на головному екрані, у «Пряме з’єднання» або на сторінці «Грати» в лаунчері.",
"horizon.friends.copied": "Скопійовано",
"horizon.friends.copy_code": "Копіювати код",
"horizon.friends.stop_sharing": "Припинити поширення",
"horizon.friends.none_yet": "Друзів тут поки немає.",
"horizon.friends.here_now": "Зараз тут: %s",
"horizon.friends.share_intro": "Поділіться цим світом за допомогою коду запрошення: він відкривається для LAN, а друзі з інтернету приєднуються за кодом. Жоден наш сервер не залучається.",
"horizon.friends.start_sharing": "Почати поширення",
"horizon.friends.troubleshooting": "Усунення несправностей…",
"horizon.friends.stopped": "Поширення коду запрошення припинено.",
"horizon.friends.short_code_changed": "Хтось раз за разом вводив неправильний короткий код, тож його змінено.",
"horizon.friends.wrong_code": "Хтось ввів неправильний код.",
"horizon.friends.code_ready": "Ваш код запрошення готовий: Ігрове меню, Грати з друзями. Скопіюйте його та надішліть друзям.",
"horizon.friends.share_failed": "Не вдалося поділитися світом: %s",
"horizon.friends.finding_route": "З’ясовуємо, як друзі можуть до вас під’єднатися…",
"horizon.osk.shift": "Shift",
"horizon.osk.space": "Пробіл",
"horizon.osk.delete": "Видалити",
"horizon.osk.type": "Ввести",
"horizon.osk.symbols": "Символи",
"horizon.osk.close": "Закрити"
}
@@ -140,6 +140,7 @@ fn main() -> Result<(), Error> {
blend_radius: 2,
smooth_lighting: true,
cutout_leaves: true,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
assets: &context.assets,
render: &context.render,
@@ -138,6 +138,7 @@ fn scene(
blend_radius,
smooth_lighting,
cutout_leaves,
translucent_layers: &[],
leaf_culling: LeafCullingMode::Off,
};
let section_y = int(&row["section_y"])?;
+2 -2
View File
@@ -32,8 +32,8 @@ use horizon_world::{
use std::{collections::HashSet, fmt};
/// `EntityType.ARROW` (and tipped/spectral arrows): 0.5 x 0.5, eyes at 0.13.
const WIDTH: f32 = 0.5;
const HEIGHT: f32 = 0.5;
pub const WIDTH: f32 = 0.5;
pub const HEIGHT: f32 = 0.5;
const EYE_HEIGHT: f32 = 0.13;
/// `AbstractArrow.Pickup`.
+63
View File
@@ -325,10 +325,73 @@ pub fn interact(
}
}
/// What `interact` would answer for `held`, changing nothing: a client's
/// `useItemOn`, where every interaction checks the same conditions and acts
/// only `if (!level.isClientSide())`.
pub fn outcome(
cauldron: &BlockState,
under_water: bool,
removes_dye: bool,
held: &ItemStack,
) -> Outcome {
struct Dry(Option<ItemStack>);
impl Player for Dry {
fn creative(&self) -> bool {
false
}
fn hand(&mut self) -> &mut Option<ItemStack> {
&mut self.0
}
fn add(&mut self, _: &mut ItemStack) -> bool {
true
}
fn contains(&self, _: &ItemStack) -> bool {
false
}
fn drop_stack(&mut self, _: ItemStack) {}
}
interact(
cauldron,
under_water,
removes_dye,
&mut Dry(Some(held.clone())),
)
.outcome
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_client_sees_the_bucket_taken_without_acting() {
let empty = state("minecraft:cauldron", None);
let water = |l| state("minecraft:water_cauldron", Some(l));
let stack = |id| ItemStack::new(id, 1);
let outcome = |c: &BlockState, under_water, id| outcome(c, under_water, false, &stack(id));
assert_eq!(
outcome(&empty, false, "minecraft:water_bucket"),
Outcome::Success
);
assert_eq!(
outcome(&empty, false, "minecraft:lava_bucket"),
Outcome::Success
);
// fillLavaInteraction under water: CONSUME, the bucket kept.
assert_eq!(
outcome(&empty, true, "minecraft:lava_bucket"),
Outcome::Consume
);
assert_eq!(
outcome(&water(3), false, "minecraft:bucket"),
Outcome::Success
);
// fillBucket needs a full cauldron.
assert_eq!(outcome(&water(2), false, "minecraft:bucket"), Outcome::Pass);
assert_eq!(outcome(&empty, false, "minecraft:bucket"), Outcome::Pass);
assert_eq!(outcome(&empty, false, "minecraft:stone"), Outcome::Pass);
}
#[test]
fn a_burning_entity_lowers_water_and_melts_powder_snow() {
let water = |l| state("minecraft:water_cauldron", Some(l));
+73 -10
View File
@@ -278,6 +278,16 @@ impl ServerTickRateManager {
const SPRINT_PER_FRAME: i64 = 40;
const SAMPLES: usize = 100;
/// A step command's broadcast value and the ticks still owed to a new client.
/// `ServerTickRateManager.updateStepTicks` sends the former immediately;
/// `ServerTickRateManager.updateJoiningPlayer` sends the latter on a join.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct StepState {
pub revision: u32,
pub requested: i32,
pub remaining: i32,
}
/// The game's clock: `DeltaTracker.Timer` pacing driven by the
/// `ServerTickRateManager` the `/tick` command controls, over a virtual
/// millisecond clock built from the frame times it is fed. The integrated
@@ -300,6 +310,7 @@ pub struct FixedStep {
/// How many times `/tick step` started or stopped a step
/// (`ServerTickRateManager.updateStepTicks` broadcasts each one).
step_changes: u32,
step_requested: i32,
/// In-frame ticks dropped since the last overload warning (`drop_ticks`).
dropped: u32,
/// When the last overload warning was due, on the frame clock.
@@ -320,6 +331,7 @@ impl Default for FixedStep {
tick_count: 0,
client: false,
step_changes: 0,
step_requested: 0,
dropped: 0,
last_overload_warning: std::time::Duration::ZERO,
}
@@ -488,18 +500,29 @@ impl FixedStep {
let started = self
.manager
.step_game_if_paused(ticks.min(i32::MAX as u32) as i32);
self.step_changes += u32::from(started);
if started {
self.step_changes += 1;
self.step_requested = self.manager.base.frozen_ticks_to_run();
}
started
}
pub fn stop_step(&mut self) -> bool {
let stopped = self.manager.stop_stepping();
self.step_changes += u32::from(stopped);
if stopped {
self.step_changes += 1;
self.step_requested = 0;
}
stopped
}
/// What `ClientboundTickingStepPacket` carries (`frozenTicksToRun`), and
/// a count that changes each time `updateStepTicks` would broadcast it.
pub fn step_state(&self) -> (u32, i32) {
(self.step_changes, self.manager.base.frozen_ticks_to_run())
/// Preserve the command's count until the network pass, even if a world
/// tick has already consumed it.
/// New arrivals instead receive the current remaining ticks.
pub fn step_state(&self) -> StepState {
StepState {
revision: self.step_changes,
requested: self.step_requested,
remaining: self.manager.base.frozen_ticks_to_run(),
}
}
/// Record how long a tick took (`MinecraftServer.tickServer` bookkeeping
/// and `endTickWork`).
@@ -607,15 +630,55 @@ mod tests {
fn only_a_started_or_stopped_step_is_broadcast() {
let mut clock = FixedStep::default();
assert!(!clock.start_step(5), "not frozen");
assert_eq!(clock.step_state(), (0, 0));
assert_eq!(clock.step_state(), StepState::default());
clock.set_frozen(true);
assert!(clock.start_step(5));
assert_eq!(clock.step_state(), (1, 5));
assert_eq!(
clock.step_state(),
StepState {
revision: 1,
requested: 5,
remaining: 5
}
);
clock.world_runs();
assert_eq!(clock.step_state(), (1, 4), "a step tick is not broadcast");
assert_eq!(
clock.step_state(),
StepState {
revision: 1,
requested: 5,
remaining: 4
},
"consuming a tick preserves the command broadcast"
);
assert!(clock.stop_step());
assert!(!clock.stop_step());
assert_eq!(clock.step_state(), (2, 0));
assert_eq!(
clock.step_state(),
StepState {
revision: 2,
requested: 0,
remaining: 0
}
);
}
#[test]
fn a_consumed_single_step_still_advances_the_client_once() {
let mut server = FixedStep::default();
server.set_frozen(true);
assert!(server.start_step(1));
assert!(server.world_runs());
assert!(!server.world_runs());
let state = server.step_state();
assert_eq!(state.remaining, 0, "a late join has no pending ticks");
let mut client = FixedStep::client();
client.set_frozen(true);
client.set_frozen_ticks_to_run(state.requested);
assert!(
client.world_runs(),
"the connected client runs the command's tick"
);
assert!(!client.world_runs(), "then remains frozen");
}
#[test]
fn a_servers_tick_rate_only_slows_its_client_down() {
+117 -25
View File
@@ -896,31 +896,6 @@ fn towards_closest_space(
_ => Vec3::new(v.x, v.y, push),
}
}
/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`:
/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box,
/// adding their counts and keeping the smaller age.
pub fn merge_orbs(orbs: &mut Vec<ExperienceOrb>) {
let mut removed = vec![false; orbs.len()];
for i in 0..orbs.len() {
if removed[i] || orbs[i].ticks % 20 != 1 {
continue;
}
let area = orbs[i].bounds().inflate(Vec3::new(0.5, 0.5, 0.5));
for j in 0..orbs.len() {
if j != i
&& !removed[j]
&& orbs[j].can_merge(orbs[i].id, orbs[i].value)
&& orbs[j].bounds().intersects(area)
{
orbs[i].count += orbs[j].count;
orbs[i].age = orbs[i].age.min(orbs[j].age);
removed[j] = true;
}
}
}
let mut removed = removed.into_iter();
orbs.retain(|_| !removed.next().unwrap_or(false));
}
/// Player experience (Player.giveExperiencePoints / getXpNeededForNextLevel).
#[derive(Clone, Copy, Debug, Default)]
pub struct Experience {
@@ -1316,6 +1291,12 @@ impl ArmorStand {
Vec3::new(p.x + w, p.y + h, p.z + w),
)
}
/// Leather boots on its feet: it walks on powder snow (`PowderSnowBlock.canEntityWalkOnPowderSnow`).
fn feet_are_leather_boots(&self) -> bool {
self.equipment[2]
.as_ref()
.is_some_and(|s| &*s.item == "minecraft:leather_boots")
}
/// LivingEntity.travel (armor stands have no other movement): clamp tiny
/// velocity components (`LivingEntity.aiStep`), move by the velocity (a
/// landing stops the fall, `Block.updateEntityMovementAfterFallOn`, and a
@@ -1331,9 +1312,16 @@ impl ArmorStand {
in_water: bool,
push: &mut dyn FnMut(Aabb, &mut Vec3),
) {
self.previous = self.position;
if self.marker {
return;
}
scene.subject =
self.feet_are_leather_boots()
.then_some(crate::collisions::PowderSnowSubject {
leather_boots: true,
..Default::default()
});
let v = self.velocity;
let wanted = Vec3::new(
if v.x.abs() < 0.003 { 0.0 } else { v.x },
@@ -2291,3 +2279,107 @@ mod entity_collision_tests {
println!("scan {scan:?}, grid {binned:?}");
}
}
#[cfg(test)]
mod armor_stand_motion_tests {
use super::*;
#[test]
fn a_new_stand_starts_with_matching_previous() {
let at = Vec3::new(1.0, 2.0, 3.0);
let stand = ArmorStand::new(1, at, 90.0);
assert_eq!(stand.previous, at);
assert_eq!(stand.position, at);
}
fn tick_stand(
stand: &mut ArmorStand,
world: &World,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
) {
let mut scene = SceneCollisions {
world,
binding: collision,
subject: None,
};
stand.tick(&mut scene, facts, false, &mut |_, _| {});
}
#[test]
fn a_tick_keeps_the_previous_position_for_interpolation() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let world = World::test_scene(&assets, &render, 3).unwrap();
let start = Vec3::new(20.5, 20.0, 20.5);
let mut stand = ArmorStand::new(1, start, 0.0);
stand.on_ground = false;
tick_stand(&mut stand, &world, &facts, &collision);
assert_eq!(stand.previous, start);
assert!(
stand.position.y < start.y,
"an airborne stand must fall, y={}",
stand.position.y
);
let mid = stand.previous.y + (stand.position.y - stand.previous.y) * 0.5;
assert!(
mid < start.y && mid > stand.position.y,
"alpha 0.5 must sit between previous and current"
);
}
#[test]
fn leather_boots_walk_on_powder_snow() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let facts = BlockFacts::load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let snow = assets
.registry
.default_state("minecraft:powder_snow")
.unwrap();
for x in 19..=21 {
for z in 19..=21 {
world.set(x, 10, z, snow).unwrap();
}
}
let start = Vec3::new(20.5, 11.0, 20.5);
let mut bare = ArmorStand::new(1, start, 0.0);
bare.on_ground = true;
for _ in 0..8 {
tick_stand(&mut bare, &world, &facts, &collision);
}
assert!(
bare.position.y < start.y - 0.2,
"bare feet must sink into powder snow, y={}",
bare.position.y
);
let mut booted = ArmorStand::new(2, start, 0.0);
booted.on_ground = true;
booted.equipment[2] = Some(ItemStack::new("minecraft:leather_boots", 1));
for _ in 0..8 {
tick_stand(&mut booted, &world, &facts, &collision);
}
assert!(
(booted.position.y - start.y).abs() < 1.0e-6,
"leather boots must walk on powder snow, y={}",
booted.position.y
);
}
}
+26
View File
@@ -355,6 +355,7 @@ pub fn mob_to_nbt(
),
("Air", Tag::Short(m.air as i16)),
("Health", Tag::Float(m.health.max(0.0).to_bits())),
("AbsorptionAmount", Tag::Float(m.absorption.to_bits())),
(
"HurtTime",
Tag::Short(m.hurt_time.clamp(0, i32::from(i16::MAX)) as i16),
@@ -1407,6 +1408,9 @@ pub fn mob_from_nbt(
add_modifier(&mut m, Attr::FollowRange, id, bonus, Op::AddMultipliedBase);
}
m.health = float(tag.get("Health")).map_or(m.attrs.max_health, |h| h as f32);
m.absorption = float(tag.get("AbsorptionAmount"))
.map_or(0.0, |h| h as f32)
.max(0.0);
// LivingEntity.setHealth clamps to the max health: a horse or llama is made
// with the type's and saved before it drops to the one it rolled. (Only
// the base is read here, so the clamp would cut a leader zombie's bonus.)
@@ -2019,6 +2023,28 @@ pub fn item_entity_to_nbt(e: &ItemEntity, uuid: [i32; 4], raw: Option<&Tag>) ->
}
tag
}
/// `PrimedTnt.addAdditionalSaveData`: its `fuse`.
pub fn tnt_to_nbt(
position: Vec3,
motion: Vec3,
on_ground: bool,
fuse: i32,
uuid: [i32; 4],
raw: Option<&Tag>,
) -> Tag {
let mut tag = Base {
id: "minecraft:tnt",
uuid,
position,
motion,
rotation: [0.0, 0.0],
on_ground,
fall_distance: 0.0,
}
.tag(raw);
put(&mut tag, "fuse", Tag::Short(fuse as i16));
tag
}
pub fn item_entity_from_nbt(tag: &Tag, id: u32, bob_offset: f32) -> Option<ItemEntity> {
let stack = item_from_nbt(tag.get("Item")?)?;
let position = position(tag)?;
+47 -17
View File
@@ -10,7 +10,16 @@ use crate::{
ui,
};
/// The English title: the fallback of [`title`], and what the screen's widgets are named by.
pub const TITLE: &str = "Play with Friends";
/// A string of this screen: the language table's, or the English text.
fn tr(key: &str, english: &str) -> String {
crate::lang::get_or(key, english)
}
/// The title in the current language.
pub fn title() -> String {
tr("horizon.friends.title", TITLE)
}
/// What the screen shows (game/friends.rs fills it).
#[derive(Clone, Debug, Default)]
pub struct FriendsView {
@@ -47,13 +56,18 @@ fn grey() -> u32 {
rgba(160, 160, 160, 255)
}
const WHITE: u32 = u32::MAX;
const HINT: &str = "Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.";
fn hint() -> String {
tr(
"horizon.friends.hint",
"Friends paste the code after the title screen's Friends button, in Direct Connection, or on the launcher's Play page.",
)
}
struct Button {
x: f32,
y: f32,
w: f32,
label: &'static str,
label: String,
action: Action,
active: bool,
}
@@ -76,7 +90,11 @@ fn layout(
};
let mut buttons = Vec::new();
if view.sharing {
lines.push(("Invite Code".to_owned(), grey(), y));
lines.push((
tr("horizon.multiplayer.invite_code", "Invite Code"),
grey(),
y,
));
y += 12.0;
match &view.code {
Some(code) => {
@@ -92,7 +110,11 @@ fn layout(
x: cx - 154.0,
y,
w: 150.0,
label: if view.copied { "Copied" } else { "Copy Code" },
label: if view.copied {
tr("horizon.friends.copied", "Copied")
} else {
tr("horizon.friends.copy_code", "Copy Code")
},
action: Action::Copy,
active: view.code.is_some(),
});
@@ -100,7 +122,7 @@ fn layout(
x: cx + 4.0,
y,
w: 150.0,
label: "Stop Sharing",
label: tr("horizon.friends.stop_sharing", "Stop Sharing"),
action: Action::Stop,
active: true,
});
@@ -113,32 +135,40 @@ fn layout(
y += 4.0;
}
let here = if view.guests.is_empty() {
"No friends here yet.".to_owned()
tr("horizon.friends.none_yet", "No friends here yet.")
} else {
format!("Here now: {}", view.guests.join(", "))
tr("horizon.friends.here_now", "Here now: %s").replacen(
"%s",
&view.guests.join(", "),
1,
)
};
add(&mut lines, &here, grey(), &mut y);
if let Some(notice) = &view.notice {
add(&mut lines, notice, WHITE, &mut y);
}
// Where friends paste the code, while it fits above the footer.
if y + 10.0 * wrap(HINT, width, atlas).len() as f32 <= h - 33.0 {
add(&mut lines, HINT, grey(), &mut y);
let hint = hint();
if y + 10.0 * wrap(&hint, width, atlas).len() as f32 <= h - 33.0 {
add(&mut lines, &hint, grey(), &mut y);
}
} else {
add(
&mut lines,
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
&tr(
"horizon.friends.share_intro",
"Share this world behind an invite code: it opens to LAN, and friends on the internet join with the code. No server of ours is involved.",
),
WHITE,
&mut y,
);
y += 4.0;
add(&mut lines, HINT, grey(), &mut y);
add(&mut lines, &hint(), grey(), &mut y);
buttons.push(Button {
x: cx - 100.0,
y: y + 8.0,
w: 200.0,
label: "Start Sharing",
label: tr("horizon.friends.start_sharing", "Start Sharing"),
action: Action::Share,
active: view.shown,
});
@@ -150,7 +180,7 @@ fn layout(
x: cx - 154.0,
y: h - 27.0,
w: 150.0,
label: "Troubleshooting...",
label: tr("horizon.friends.troubleshooting", "Troubleshooting..."),
action: Action::Help,
active: true,
});
@@ -158,7 +188,7 @@ fn layout(
x: cx + 4.0,
y: h - 27.0,
w: 150.0,
label: "gui.done",
label: "gui.done".to_owned(),
action: Action::Done,
active: true,
});
@@ -167,7 +197,7 @@ fn layout(
x: cx - 100.0,
y: h - 27.0,
w: 200.0,
label: "gui.done",
label: "gui.done".to_owned(),
action: Action::Done,
active: true,
});
@@ -204,7 +234,7 @@ pub fn draw(
) {
let (w, h) = (batch.width, batch.height);
ui::menu_background(batch, atlas, 0.0, 0.0, w, h, in_world);
let title = Text::literal(TITLE);
let title = Text::literal(title());
let tw = atlas.font.width_text(&title);
batch.text_styled(atlas, &title, ((w - tw) / 2.0).floor(), 15.0, WHITE, true);
let (lines, buttons) = layout(atlas, (w, h), view);
@@ -221,7 +251,7 @@ pub fn draw(
b.y,
b.w,
20.0,
&label(b.label),
&label(&b.label),
b.active,
hover,
);
+270 -46
View File
@@ -61,10 +61,12 @@ mod beacon;
mod beds;
mod beehives;
mod block_editor;
mod block_edits;
mod block_entity_anims;
mod block_entity_ticks;
mod block_items;
mod blockctl;
mod boat_authority_fixture;
mod boat_leads;
mod boat_passengers;
mod boats;
@@ -124,6 +126,7 @@ mod fangs;
mod fireworks;
mod fishing;
mod fov;
mod foxes;
mod frames;
mod friends;
mod functions;
@@ -209,6 +212,8 @@ mod native_recipes;
mod native_server;
mod native_shaders;
mod native_visuals;
mod native_world_columns;
mod native_world_edit;
mod native_world_read;
mod natural_spawning;
mod nautili;
@@ -2253,6 +2258,8 @@ struct Game<'a> {
screen: Screen,
window: Option<Arc<Window>>,
renderer: Option<Renderer>,
/// Avoid an endless rebuild loop if the new device is lost as well.
last_device_recovery: Option<Instant>,
/// CPU geometry storage retained across frames, including empty/title frames.
entity_vertices: Vec<Vertex>,
/// Horizon Extras' dynamic lights this frame (`dynamic_lights.rs`), and how brightly each
@@ -2832,9 +2839,10 @@ struct Game<'a> {
/// own client plays it.
guest_player_sounds: Vec<(String, [f64; 3], f32, f32)>,
/// `broadcastToAdmins` lines a guest's command queued, sent once it
/// returns: the line, whether operators get it and whether the console
/// logs it.
admin_feedback: Vec<(horizon_client::text::Text, bool, bool)>,
/// returns: the line, whether operators get it, whether the console
/// logs it and whether the world's own host is told too (a command
/// block's or minecart's line has no player it came from).
admin_feedback: Vec<(horizon_client::text::Text, bool, bool, bool)>,
/// The server console is running a command: whether its feedback goes to
/// the operators (`DedicatedServer.shouldInformAdmins`,
/// `broadcast-console-to-ops`).
@@ -2995,6 +3003,25 @@ fn view_vector(yaw: f32, pitch: f32) -> [f64; 3] {
let (y, p) = (f64::from(yaw).to_radians(), f64::from(pitch).to_radians());
[-y.sin() * p.cos(), -p.sin(), y.cos() * p.cos()]
}
/// `LivingEntity.canGlideUsing`: for each worn piece (head, chest, legs,
/// feet), whether it is a glider (its own component, or one the server
/// patched onto the stack) in the slot its `equippable` names, and will not
/// break with its next point of wear (`ItemStack.nextDamageWillBreak`).
fn gliding_pieces(items: &Items, armor: &[Option<ItemStack>; 4]) -> [bool; 4] {
const SLOTS: [&str; 4] = ["head", "chest", "legs", "feet"];
std::array::from_fn(|i| {
let Some(stack) = armor[i].as_ref() else {
return false;
};
let info = items.get(&stack.item);
let glider = info.is_some_and(|d| d.glider)
|| stack
.data()
.is_some_and(|d| d.other("minecraft:glider").is_some());
let will_break = info.is_some_and(|d| d.max_damage > 0 && stack.damage + 1 >= d.max_damage);
glider && !will_break && info.and_then(|d| d.equip_slot.as_deref()) == Some(SLOTS[i])
})
}
/// `Camera.setRotation` (`rotationYXZ(π - yaw, -pitch, 0)`) for the player's
/// view, turned round in third-person front view (`Camera.setup`).
fn camera_rotation(camera_type: perspective::CameraType, yaw: f32, pitch: f32) -> glam::Quat {
@@ -3233,15 +3260,31 @@ impl Game<'_> {
self.stack_in_mut(self.held_slot())
}
/// The slot of the hand that acts: the selected one, or `OFFHAND_SLOT`.
/// `LivingEntity.canGlideUsing`: a glider in the chest slot that will
/// not break with its next point of wear.
/// `LivingEntity.canGlide`: some worn piece `gliding_pieces` lets glide.
fn glider_worn(&self) -> bool {
self.armor[1].as_ref().is_some_and(|s| {
self.res
.items()
.get(&s.item)
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
})
gliding_pieces(self.res.items(), &self.armor).contains(&true)
}
/// `LivingEntity.updateFallFlying`: the glide's wear. `Util.getRandom`
/// picks one of the gliders that will not break (from the entity's own
/// random), and
/// `ItemStack.hurtAndBreak` takes `points` of its durability (Unbreaking
/// may save it). Callers skip creative.
pub(super) fn wear_glider(&mut self, points: u32) {
let gliding = gliding_pieces(self.res.items(), &self.armor);
let pieces: Vec<usize> = (0..4).filter(|&i| gliding[i]).collect();
if pieces.is_empty() {
return;
}
// `Util.getRandom(slotsWithGliders, this.random)`: the entity's own
// random, which never touches the level's stream.
let pick = dispenser::entity_random().next_int_bound(pieces.len() as i32);
let pick = pieces[pick.unwrap_or(0) as usize];
if let Some(stack) = self.armor[pick].clone() {
let loss = self.wear(&stack, points);
if let Some(piece) = self.armor[pick].as_mut() {
piece.damage += loss;
}
}
}
/// `LivingEntity.canFreeze`'s armor half: any worn piece in
/// `freeze_immune_wearables`.
@@ -6984,15 +7027,8 @@ impl Game<'_> {
lan_particles::send_soul_particle(host, at, velocity);
}
}
// `ItemStack.hurtAndBreak` for the glide: Unbreaking can save the wear.
if glider_wear > 0
&& !self.creative
&& let Some(stack) = self.armor[1].clone()
{
let loss = self.wear(&stack, glider_wear);
if let Some(elytra) = self.armor[1].as_mut() {
elytra.damage += loss;
}
if glider_wear > 0 && !self.creative {
self.wear_glider(glider_wear);
}
self.tick_avatar(before, riding);
self.end_spin_attack(spin_hit);
@@ -7288,6 +7324,7 @@ impl Game<'_> {
if running {
horizon_client::spans::time("tick_mobs", || self.tick_mobs())?;
horizon_client::spans::time("recheck_golem_openers", || self.recheck_golem_openers());
self.tick_mod_container_viewers();
let before = self.orb_ids();
horizon_client::spans::time("tick_experience", || self.tick_experience());
// Merged, despawned or picked up.
@@ -10932,7 +10969,7 @@ impl Game<'_> {
/// `Player.attack`'s sounds when a vehicle takes the hit (it always does,
/// and can't be hit critically): knockback when sprinting at full
/// strength, then strong or weak, or the sweep around the vehicle's
/// box `area`. Before `swing_arm` resets the charge.
/// box `area`. Before the hit resets the charge (`Player.onAttack`).
pub(super) fn vehicle_attack_sounds(&mut self, area: Aabb, magic: Option<i32>) {
self.attack_effects(area, None, mace::Spared::Nothing, magic);
}
@@ -11098,7 +11135,7 @@ impl Game<'_> {
if landed {
if knockback > 0.0 {
let (xd, zd) = self.extra_knockback_direction();
self.mobs[index].knockback(f64::from(knockback), xd, zd);
self.mobs[index].knockback(f64::from(knockback), xd, zd, &source);
self.stop_sprint_after_extra_knockback();
}
let mob = &self.mobs[index];
@@ -12242,6 +12279,46 @@ impl Game<'_> {
hit.pos[2] + hit.face[2],
]
}
/// The cauldron at `pos`, and whether water is above it
/// (`CauldronInteractions.isUnderWater`).
pub(super) fn cauldron_at(
&self,
pos: [i32; 3],
) -> Option<(horizon_world::chunk::BlockState, bool)> {
let registry = self.pack.assets.registry;
let id = self.world.block(pos[0], pos[1], pos[2]);
let state = registry
.state(id)
.filter(|s| s.name.ends_with("cauldron"))
.cloned()?;
let above = self.world.block(pos[0], pos[1] + 1, pos[2]);
let under_water = registry
.state(above)
.is_some_and(|s| s.name == "minecraft:water");
Some((state, under_water))
}
/// What the cauldron at `pos` answers to `held` on a client, which only
/// asks (`CauldronInteractions` acts `if (!level.isClientSide())`). A
/// sneak with something in hand skips `useItemOn`
/// (`Player.isSecondaryUseActive`).
pub(super) fn cauldron_outcome(
&self,
pos: [i32; 3],
held: Option<&ItemStack>,
) -> horizon_client::cauldron::Outcome {
use horizon_client::cauldron::{self, Outcome};
let Some(held) = held else {
return Outcome::Pass;
};
if self.keys.contains(&KeyCode::ShiftLeft) {
return Outcome::Pass;
}
let Some((state, under_water)) = self.cauldron_at(pos) else {
return Outcome::Pass;
};
let removes_dye = cauldron::removes_dye(self.items(), &held.item);
cauldron::outcome(&state, under_water, removes_dye, held)
}
/// `CauldronInteractions` for the held item aimed at a cauldron.
fn use_on_cauldron(&mut self, pos: [i32; 3]) -> Result<bool> {
use horizon_client::cauldron::{self, Outcome};
@@ -12276,22 +12353,17 @@ impl Game<'_> {
}
}
let registry = self.pack.assets.registry;
let id = self.world.block(pos[0], pos[1], pos[2]);
let Some(state) = registry
.state(id)
.filter(|s| s.name.ends_with("cauldron"))
.cloned()
else {
let Some((state, under_water)) = self.cauldron_at(pos) else {
return Ok(false);
};
let Some(held) = self.held() else {
return Ok(false);
};
// Player.isSecondaryUseActive with an item in hand skips useItemOn.
if self.keys.contains(&KeyCode::ShiftLeft) {
return Ok(false);
}
let removes_dye = cauldron::removes_dye(self.items(), &held.item);
let above = self.world.block(pos[0], pos[1] + 1, pos[2]);
let under_water = registry
.state(above)
.is_some_and(|s| s.name == "minecraft:water");
let result = cauldron::interact(&state, under_water, removes_dye, &mut Hand(self));
if result.outcome == Outcome::Pass {
return Ok(false);
@@ -12796,18 +12868,83 @@ impl Game<'_> {
let half = Vec3::new(0.5, 0.5, 0.5);
let area = Aabb::new(centre.subtract(half), centre.add_vector(half));
// A server's orbs are its own; a LAN or server guest only shows them.
let target = self.remote.is_none().then(|| {
self.orbs
.iter_mut()
.find(|o| o.can_merge(merge_id as u32, value) && o.bounds().intersects(area))
});
if let Some(Some(orb)) = target {
orb.count += 1;
orb.age = 0;
let mut target = None;
if self.remote.is_none() {
for i in 0..self.orbs.len() {
let o = &self.orbs[i];
if !o.bounds().intersects(area) {
continue;
}
let (id, orb_value, vanilla) = (o.id, o.value, o.can_merge(merge_id as u32, value));
if self.orb_merges(id, orb_value, 0, value, vanilla) {
target = Some(i);
break;
}
}
}
if let Some(i) = target {
self.orbs[i].count += 1;
self.orbs[i].age = 0;
} else {
self.add_orb(at, value);
}
}
/// Whether orb `into` takes orb `from` (0: a spawning orb of `from_value`),
/// given vanilla's `canMerge` answer: the mods may flip it, then cancel the merge.
fn orb_merges(
&mut self,
into: u32,
into_value: u32,
from: u32,
from_value: u32,
vanilla: bool,
) -> bool {
let dimension = self.dim.id();
let mut can_merge = vanilla;
self.native_experience_orb_can_merge(
dimension,
into,
from,
into_value,
from_value,
&mut can_merge,
);
if !can_merge {
return false;
}
let mut canceled = false;
self.native_experience_orb_merge(dimension, into, from, from_value, &mut canceled);
!canceled
}
/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`:
/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box,
/// adding their counts and keeping the smaller age. Server only.
fn merge_orbs(&mut self) {
let mut removed = vec![false; self.orbs.len()];
for i in 0..self.orbs.len() {
if removed[i] || self.orbs[i].ticks % 20 != 1 {
continue;
}
let area = self.orbs[i].bounds().inflate(Vec3::new(0.5, 0.5, 0.5));
for j in 0..self.orbs.len() {
if j == i || removed[j] || !self.orbs[j].bounds().intersects(area) {
continue;
}
let (a, b) = (&self.orbs[i], &self.orbs[j]);
let vanilla = b.can_merge(a.id, a.value);
let (into, into_value, from, from_value) = (a.id, a.value, b.id, b.value);
if !self.orb_merges(into, into_value, from, from_value, vanilla) {
continue;
}
let (count, age) = (self.orbs[j].count, self.orbs[j].age);
self.orbs[i].count = self.orbs[i].count.saturating_add(count);
self.orbs[i].age = self.orbs[i].age.min(age);
removed[j] = true;
}
}
let mut removed = removed.into_iter();
self.orbs.retain(|_| !removed.next().unwrap_or(false));
}
/// `Entity.unstuckIfPossible`, which `ExperienceOrb`'s constructor runs on an
/// orb whose box is in a block: the free spot nearest its box's centre. An
/// orb in open air keeps its position.
@@ -12968,10 +13105,12 @@ impl Game<'_> {
&mut next_float,
)
});
self.orbs = orbs;
// ExperienceOrb.scanForMerges runs on the server only.
if self.remote.is_none() {
horizon_client::entities::merge_orbs(&mut orbs);
self.merge_orbs();
}
let mut orbs = std::mem::take(&mut self.orbs);
let mut gained = 0;
let mut taken = Vec::new();
if let Some((min, max)) = bounds {
@@ -13387,6 +13526,67 @@ impl Game<'_> {
fn new_renderer(&self, window: Arc<Window>) -> Result<Renderer> {
self.renderer_for(window, &self.pack, &self.gui)
}
/// Rebuild GPU-owned state after the backend reports device loss.
fn recover_device(&mut self) -> Result<()> {
if !self.renderer.as_ref().is_some_and(Renderer::device_lost) {
return Ok(());
}
// Split-screen compositors and guests share the device; rebuilding one
// renderer alone would leave the other windows using invalid resources.
if self.options.paced_by_split {
return Err("the graphics device was lost during split-screen play".into());
}
let now = Instant::now();
if self
.last_device_recovery
.is_some_and(|last| now.duration_since(last) < Duration::from_secs(60))
{
return Err("the graphics device was lost again after recovery".into());
}
self.last_device_recovery = Some(now);
let window = self
.window
.clone()
.ok_or("no window for graphics recovery")?;
log::warn!("Horizon: rebuilding the renderer after graphics device loss");
// Drop the old surface and device before opening another on this window.
let mut old = self
.renderer
.take()
.ok_or("no renderer for graphics recovery")?;
let screenshot = old.screenshot.take();
let screenshot_result = old.screenshot_result.take();
drop(old);
let mut renderer = build_renderer(
window,
self.options.offscreen,
&self.pack,
&self.gui,
None,
None,
self.settings.horizon.api,
self.settings.horizon.translucent_batching,
shader_packs::picked_pack_draws_flat(&self.settings),
)?;
renderer.screenshot = screenshot;
renderer.screenshot_result = screenshot_result;
self.renderer = Some(renderer);
self.skin_slots.forget();
self.maps.slots.forget();
self.map_patches.clear();
self.kept_block_entities.get_mut().clear();
horizon_client::ui_theme::reset_atlas();
self.clear_lod();
self.pending_uploads.clear();
self.streaming
.reload_resources(&mut self.world, self.pack.context.clone());
self.native_shaders = native_shaders::NativeShaders::picking(&std::mem::take(
&mut self.native_shaders.picked,
));
self.apply_ui_settings();
log::info!("Horizon: graphics device recovered; rebuilding world meshes");
Ok(())
}
fn renderer_for(
&self,
window: Arc<Window>,
@@ -13442,6 +13642,14 @@ impl Game<'_> {
);
self.apply_ui_settings();
}
/// The sprite layers a shader pack draws in the translucent pass: every section is meshed
/// again when they change.
pub(super) fn set_translucent_layers(&mut self, layers: Vec<bool>) {
if *layers != *self.streaming.translucent_layers {
self.streaming.translucent_layers = layers.into();
self.all_sections_changed();
}
}
/// `LevelExtractor.allChanged`: every loaded section is rebuilt.
fn all_sections_changed(&mut self) {
for &(x, z) in self.world.chunks.keys() {
@@ -17106,6 +17314,7 @@ impl Game<'_> {
self.mapped_key_event(key, event.state == ElementState::Pressed, event.repeat)?;
}
WindowEvent::RedrawRequested => {
self.recover_device()?;
self.observe_fullscreen();
self.sync_ime();
let now = Instant::now();
@@ -17188,7 +17397,14 @@ impl Game<'_> {
self.advance_flight();
self.tick_world_icon();
let ticked = Instant::now();
let rendered = self.render()?;
let rendered = match self.render() {
// The callback can fire during this frame; rebuild on
// the next redraw rather than exiting on its error.
Err(_) if self.renderer.as_ref().is_some_and(Renderer::device_lost) => {
false
}
result => result?,
};
self.apply_cursor_icon();
if let Some(threshold) = self.options.spikes
&& let Some(r) = self.renderer.as_ref()
@@ -17571,6 +17787,7 @@ impl<'a> Game<'a> {
screen: Screen::Playing,
window: None,
renderer: None,
last_device_recovery: None,
entity_vertices: Vec::new(),
dynamic_lights: Vec::new(),
luminance: None,
@@ -18506,10 +18723,12 @@ fn load_core(
assets.reload(root)
}
.map_err(|e| e.to_string())?;
let item_renderer = horizon_client::item_render::ItemRenderer::load_with_library(
root,
render.model_library.clone(),
)
let library = render.model_library.clone();
let item_renderer = if headless {
horizon_client::item_render::ItemRenderer::load_for_server(root, library)
} else {
horizon_client::item_render::ItemRenderer::load_with_library(root, library)
}
.map_err(|e| e.to_string());
Ok((render, item_renderer))
});
@@ -19261,6 +19480,10 @@ fn start_game<'a>(
},
);
}
if let Some(directory) = std::env::var_os("HORIZON_BOAT_AUTHORITY_FIXTURE") {
game.run_boat_authority_fixture(std::path::Path::new(&directory))?;
return Ok(None);
}
if let Ok(spec) = std::env::var("HORIZON_DISPENSE_FIXTURE") {
game.run_component_harness(integrated::ComponentHarness::Dispenser(spec))?;
return Ok(None);
@@ -19849,6 +20072,7 @@ pub fn run() -> Result<()> {
}
let _ = horizon_client::health::HURT_SERVER.set(native_hooks::hurt_server);
let _ = horizon_client::block_contact::HOOK.set(native_hooks::block_contact);
let _ = horizon_client::health::KNOCKBACK.set(native_hooks::knockback);
// Before any worker starts: on Linux they go behind the server thread
// and the render thread.
horizon_threads::put_helpers_behind();
+1 -1
View File
@@ -699,7 +699,7 @@ impl Game<'_> {
push(
[orb.position.x, orb.position.y, orb.position.z],
"minecraft:experience_orb",
json!({ "kind": "orb", "value": orb.value }),
json!({ "kind": "orb", "value": orb.value, "velocity": [orb.velocity.x, orb.velocity.y, orb.velocity.z] }),
);
}
list.sort_by(|a, b| a.0.total_cmp(&b.0));
@@ -617,6 +617,9 @@ impl Game<'_> {
magic.then_some(id as i32),
);
});
// Player.onAttack: the hit starts the attack strength again, hurt or
// not (skipAttackInteraction only asks mayInteract).
self.attack_ticks = 0;
if hurt {
self.swing_arm();
}
@@ -148,6 +148,11 @@ impl BlockEditScreen {
dragging: None,
}
}
/// Whether one of the block's text boxes has the keyboard's focus: the
/// window asks the OS for an input method only then.
pub(super) fn text_focused(&self) -> bool {
self.focus.is_some()
}
}
impl Game<'_> {
@@ -0,0 +1,716 @@
//! A server mod's bulk block edit (`native_edit_blocks`): its palette and
//! filters read as commands read blocks, its heights read, and its runs made
//! at once through `World::edit_runs`, with what `fill ... strict` does for
//! the block entities and the watched blocks of each block changed.
use super::Game;
use super::native_world_columns::NativeColumnBlocks as ColumnBlocks;
use super::native_world_edit::{NativeBlockEdit as BlockEdit, NativeHeightmap as Heightmap};
use horizon_client::{
block_edit::Run,
chunk::ColumnLayout,
command::{Reader, args::BlockInput, block},
containers::BlockEntity,
fast_hash::{FxHashMap, FxHashSet},
world::World,
};
use horizon_world::{
heightmap::HeightmapType, registry::BlockStateId, worldgen::terrain::TerrainBlockStates,
};
use std::sync::Arc;
/// A change `World::edit_runs` reported: position, previous and new state.
type Tracked = ((i32, i32, i32), u32, u32);
impl Game<'_> {
/// Make a mod's block edit; how many blocks changed, or why none did.
/// Every palette entry, filter and run is checked before anything
/// changes: an entry or filter that doesn't read, one with NBT (`{...}`,
/// refused: the bulk path sets states only), a run naming a palette entry
/// or filter past the end, or more than 65536 filters, and nothing is
/// made.
pub(super) fn edit_blocks(&mut self, edit: &BlockEdit) -> Result<u64, String> {
let parse = |name: &str, testing: bool| -> Result<BlockInput, String> {
let mut reader = Reader::new(name);
let b = block::parse(&mut reader, &self.ui.registries, testing)
.map_err(|e| format!("{name:?}: {}", e.message.plain()))?;
if reader.can_read() {
return Err(format!("{name:?}: more after the block"));
}
if b.nbt.is_some() {
return Err(format!("{name:?}: block NBT is not supported here"));
}
Ok(b)
};
let mut palette = Vec::with_capacity(edit.palette.len());
for name in &edit.palette {
let b = parse(name, false)?;
let state = self
.block_state(&b)
.map_err(|e| format!("{name:?}: {}", e.message.plain()))?;
palette.push(state.0);
}
let filters = edit
.filters
.iter()
.map(|f| parse(f, true))
.collect::<Result<Vec<_>, _>>()?;
let runs = resolve_runs(edit, &palette, filters.len(), |h, x, z| {
self.edit_height(h, x, z)
})?;
// (what the registry, the tags and the facts tell of a state, beside the world being changed)
let registry = self.pack.assets.registry;
// (the tags as `block_matches` reads them for commands)
let world_items = match &self.world_tables {
Some(t) => &t.items,
None => self.res.items(),
};
let tags = [&self.res.items().block_tags, &world_items.block_tags];
let facts = self.res.facts();
let watched = std::mem::take(&mut self.world.watched);
let out = self.world.edit_runs(
&runs,
|f, s| {
super::commands::block_input_matches(
registry,
tags,
&filters[f as usize],
BlockStateId(s),
)
},
|s| {
let f = facts.get(BlockStateId(s));
watched.get(s as usize).copied().unwrap_or(false)
|| f.block_entity
|| BlockEntity::for_block(&f.name).is_some()
},
);
self.world.watched = watched;
// The block entities the new blocks bring, as `native_set_block` makes them (a mod block's own, API 15,
// included), in the order `replace_block_entities` takes them: the positions whose block changed.
let name = |s: u32| self.res.facts().get(BlockStateId(s)).name.clone();
let wanted: Vec<Arc<str>> = out
.tracked
.iter()
.filter(|&&(_, old, new)| name(old) != name(new))
.map(|&(_, _, new)| name(new))
.collect();
let mut made = make_block_entities(&wanted, |n| self.new_block_entity(n)).into_iter();
let facts = self.res.facts();
let removed = replace_block_entities(
&mut self.world,
&out.tracked,
|s| facts.get(BlockStateId(s)).name.clone(),
|_| made.next().flatten(),
);
for pos in removed {
self.jukebox_removed(pos);
}
Ok(out.changed)
}
/// Each column's height on `h` as `edit_height` reads it, none where its chunk is not loaded.
pub(super) fn surface_heights(&self, columns: &[(i32, i32)], h: Heightmap) -> Vec<Option<i32>> {
loaded_heights(&self.world, columns, |x, z| self.edit_height(h, x, z))
}
/// Each column's height on `h` (none where its chunk is not loaded) and its blocks from `below` under it to
/// `above` - 1 over it, bottom first (air outside the column and where it is not loaded), as indices into the
/// names seen, with the biome's grass, foliage and water tints at the column's top (0 where it is not loaded;
/// unblended). `Err`, reading nothing, when `below + above` is 0 or more than the level's height, or the read
/// would be more than `MAX_READ` blocks.
pub(super) fn read_columns(
&self,
columns: &[(i32, i32)],
h: Heightmap,
below: u32,
above: u32,
) -> Result<ColumnBlocks, String> {
read_size(columns.len(), below, above, self.world.layout().height())?;
let bases = self.surface_heights(columns, h);
let facts = self.res.facts();
let (palette, blocks) = column_blocks(&self.world, columns, &bases, below, above, |s| {
facts.get(s).name.clone()
})
.ok_or("the read is too big")?;
let biomes = self.res.biomes();
let mut tints = Vec::with_capacity(columns.len() * 3);
for (&(x, z), base) in columns.iter().zip(&bases) {
// (the biome tints at the column's top, as the block there is tinted)
match base.and_then(|b| {
self.world
.chunks
.get(&(x >> 4, z >> 4))
.map(|c| c.biome(x, b - 1, z))
}) {
Some(id) => {
let b = biomes.get(id);
tints.extend([
biomes.grass(id, f64::from(x), f64::from(z)),
b.foliage,
b.water,
]);
}
None => tints.extend([0, 0, 0]),
}
}
Ok(ColumnBlocks {
palette,
bases,
blocks,
tints,
})
}
/// One above the highest block of column (x, z) that counts on heightmap `h`, or the bottom of the column (also
/// where it is not loaded).
fn edit_height(&self, h: Heightmap, x: i32, z: i32) -> i32 {
let properties = &self.pack.assets.properties;
column_height(
&self.world,
h,
(x, z),
|s| self.res.facts().is_air(s),
|t, s| t.is_opaque(properties.heightmap_state(s)),
)
}
}
/// The most blocks one `read_columns` reads (2 MiB of indices): it runs on the
/// server tick, so a mod reads a big area in pieces.
pub(super) const MAX_READ: usize = 1 << 20;
/// The most columns one `surface_heights` reads.
pub(super) const MAX_COLUMNS: usize = 1 << 16;
/// The blocks a column read of `columns` columns, `below` + `above` each,
/// reads, or why it can't: a span of 0 or more than `height` (the level's),
/// or the whole more than `MAX_READ`.
fn read_size(columns: usize, below: u32, above: u32, height: i32) -> Result<usize, String> {
let per = below
.checked_add(above)
.filter(|&per| per > 0 && i64::from(per) <= i64::from(height))
.ok_or_else(|| format!("below + above must be 1 to {height}, the level's height"))?;
columns
.checked_mul(per as usize)
.filter(|&n| n <= MAX_READ)
.ok_or_else(|| {
format!("the read is more than {MAX_READ} blocks: read fewer columns a call")
})
}
/// One above the highest block of column `(x, z)` on heightmap `h`, or the
/// bottom of the column (also where it is not loaded): `air` says which
/// blocks the world surface passes over, and `counts(kind, state)` which
/// count on the other heightmaps.
fn column_height(
world: &World,
h: Heightmap,
(x, z): (i32, i32),
air: impl Fn(BlockStateId) -> bool,
counts: impl Fn(HeightmapType, BlockStateId) -> bool,
) -> i32 {
if h == Heightmap::WorldSurface {
return world.world_surface(x, z, air);
}
// (as `rain_height` reads it: both others count only blocks that block motion, so they are at or under it)
let top = world.motion_blocking(x, z, |s| counts(HeightmapType::MotionBlocking, s));
let kind = match h {
Heightmap::MotionBlockingNoLeaves => HeightmapType::MotionBlockingNoLeaves,
Heightmap::OceanFloor => HeightmapType::OceanFloor,
_ => return top,
};
let bottom = world
.chunks
.get(&(x >> 4, z >> 4))
.map_or(world.layout().min_y(), |c| c.min_y());
let mut y = top;
while y > bottom && !counts(kind, world.block(x, y - 1, z)) {
y -= 1;
}
y
}
/// `height(x, z)` of each column, none where its chunk is not loaded. (A
/// chunk is looked up once for a run of columns in it.)
fn loaded_heights(
world: &World,
columns: &[(i32, i32)],
height: impl Fn(i32, i32) -> i32,
) -> Vec<Option<i32>> {
let mut last: Option<((i32, i32), bool)> = None;
columns
.iter()
.map(|&(x, z)| {
let key = (x >> 4, z >> 4);
let loaded = match last {
Some((k, l)) if k == key => l,
_ => {
let l = world.chunks.get(&key).is_some();
last = Some((key, l));
l
}
};
loaded.then(|| height(x, z))
})
.collect()
}
/// The blocks of each column from `below` under its base to `above` - 1 over
/// it, bottom first, as indices into the names (`name`) seen: air outside the
/// column's chunk's own height and where there is no base. None, reading
/// nothing, past `read_size`'s bound.
fn column_blocks(
world: &World,
columns: &[(i32, i32)],
bases: &[Option<i32>],
below: u32,
above: u32,
name: impl Fn(BlockStateId) -> Arc<str>,
) -> Option<(Vec<String>, Vec<u16>)> {
let total = read_size(
columns.len(),
below,
above,
ColumnLayout::HIGHEST_Y + 1 - ColumnLayout::LOWEST_Y,
)
.ok()?;
let per = (below + above) as usize;
let mut blocks = Vec::with_capacity(total);
let mut palette: Vec<String> = Vec::new();
// (a state's index into the names, worked out once a state)
let mut seen: FxHashMap<u32, u16> = FxHashMap::default();
let mut index = |s: BlockStateId| -> u16 {
*seen.entry(s.0).or_insert_with(|| {
let name = name(s);
let i = palette
.iter()
.position(|n| **n == *name)
.unwrap_or_else(|| {
palette.push(name.to_string());
palette.len() - 1
});
i as u16
})
};
for (&(x, z), base) in columns.iter().zip(bases) {
let chunk = world.chunks.get(&(x >> 4, z >> 4));
match (base, chunk) {
(Some(b), Some(chunk)) => {
let (bottom, top) = (chunk.min_y(), chunk.max_y());
let y0 = i64::from(*b) - i64::from(below);
for y in y0..y0 + per as i64 {
let s = match i32::try_from(y) {
Ok(y) if (bottom..top).contains(&y) => chunk.block(x & 15, y, z & 15),
_ => world.air,
};
blocks.push(index(s));
}
}
_ => {
let air = index(world.air);
blocks.extend(std::iter::repeat_n(air, per));
}
}
}
Some((palette, blocks))
}
/// `edit`'s runs as `World::edit_runs` takes them: each palette entry `block`
/// as its state in `palette`, each `y` counted from `height(surface, x, z)`
/// when the run names a heightmap (read before any change, once a column).
/// Err, with nothing resolved, for a run naming a palette entry or a filter
/// past the end, or for more filters than a run can name (65536).
fn resolve_runs(
edit: &BlockEdit,
palette: &[u32],
filters: usize,
mut height: impl FnMut(Heightmap, i32, i32) -> i32,
) -> Result<Vec<Run>, String> {
if filters > usize::from(u16::MAX) + 1 {
return Err(format!("{filters} filters: 65536 at most"));
}
let mut heights: FxHashMap<(i32, i32, u8), i32> = FxHashMap::default();
let mut runs = Vec::with_capacity(edit.runs.len());
for (i, r) in edit.runs.iter().enumerate() {
let Some(&state) = usize::try_from(r.block).ok().and_then(|b| palette.get(b)) else {
return Err(format!(
"run {i}: palette entry {} of {}",
r.block,
palette.len()
));
};
let only = match r.only {
None => None,
Some(f) if (f as usize) < filters => Some(f as u16),
Some(f) => return Err(format!("run {i}: filter {f} of {filters}")),
};
if r.height == 0 {
continue;
}
let base = match r.surface {
None => i64::from(r.y),
Some(h) => {
let top = *heights
.entry((r.x, r.z, h as u8))
.or_insert_with(|| height(h, r.x, r.z));
i64::from(top) + i64::from(r.y)
}
};
let clamp = |v: i64| v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32;
runs.push(Run {
x: r.x,
z: r.z,
y0: clamp(base),
y1: clamp(base + i64::from(r.height) - 1),
state,
only,
});
}
Ok(runs)
}
/// What `fill ... strict` does for the block entities and the watched blocks
/// of `tracked`'s changes: the watched writes recorded, the block entities
/// told of their new state, and where a different block replaced the block,
/// its block entity removed (as `place_state` does) and `new_entity(name)`'s,
/// if any, put in its place. Returns the positions whose block was replaced
/// (a jukebox there stops).
/// A block entity for each new block, in order: made for each position, as vanilla calls
/// `EntityBlock.newBlockEntity` for each (a mod's may number its instances). A block that makes none (stone) is
/// asked once.
fn make_block_entities(
names: &[Arc<str>],
mut make: impl FnMut(&str) -> Option<BlockEntity>,
) -> Vec<Option<BlockEntity>> {
let mut none: FxHashSet<&str> = FxHashSet::default();
names
.iter()
.map(|n| {
if none.contains(&**n) {
return None;
}
let entity = make(n);
if entity.is_none() {
none.insert(n);
}
entity
})
.collect()
}
fn replace_block_entities(
world: &mut World,
tracked: &[Tracked],
name: impl Fn(u32) -> Arc<str>,
mut new_entity: impl FnMut(&str) -> Option<BlockEntity>,
) -> Vec<[i32; 3]> {
let mut replaced = Vec::new();
for &((x, y, z), old, new) in tracked {
let watches = |id: u32| world.watched.get(id as usize).copied().unwrap_or(false);
if watches(old) || watches(new) {
world.watched_writes.push(((x, y, z), old, new));
}
world.block_entities.state_changed((x, y, z), old, new);
let (was, now) = (name(old), name(new));
if was != now {
replaced.push([x, y, z]);
world.block_entities.remove(&(x, y, z));
if let Some(entity) = new_entity(&now) {
world.block_entities.insert((x, y, z), entity);
}
}
}
replaced
}
#[cfg(test)]
mod tests {
use super::*;
use crate::game::native_world_edit::NativeBlockRun as BlockRun;
use horizon_client::chunk::Chunk;
fn run(block: u32, only: Option<u32>) -> BlockRun {
BlockRun {
x: 1,
z: 2,
y: 3,
height: 4,
surface: None,
block,
only,
}
}
fn edit(runs: Vec<BlockRun>) -> BlockEdit {
BlockEdit {
palette: vec!["stone".into(), "air".into()],
filters: vec!["#dirt".into()],
runs,
}
}
#[test]
fn runs_naming_entries_past_the_end_are_refused_whole() {
let palette = [1, 0];
let ok = resolve_runs(&edit(vec![run(1, Some(0))]), &palette, 1, |_, _, _| 0).unwrap();
assert_eq!(
ok,
[Run {
x: 1,
z: 2,
y0: 3,
y1: 6,
state: 0,
only: Some(0),
}]
);
// a bad run after a good one: nothing at all, and no height read
let mut asked = 0;
let mut bad = |runs| {
resolve_runs(&edit(runs), &palette, 1, |_, _, _| {
asked += 1;
0
})
};
assert!(bad(vec![run(0, None), run(2, None)]).is_err());
assert!(bad(vec![run(0, None), run(u32::MAX, None)]).is_err());
assert!(bad(vec![run(0, Some(1))]).is_err());
assert!(bad(vec![run(0, Some(u32::MAX))]).is_err());
assert_eq!(asked, 0);
// more filters than a run's u16 names
assert!(resolve_runs(&edit(vec![]), &palette, 65_537, |_, _, _| 0).is_err());
assert!(resolve_runs(&edit(vec![]), &palette, 65_536, |_, _, _| 0).is_ok());
}
#[test]
fn a_run_counts_from_its_heightmap_read_once_a_column() {
let mut r = run(0, None);
r.surface = Some(Heightmap::OceanFloor);
r.y = -2;
let mut asked = Vec::new();
let runs = resolve_runs(&edit(vec![r, r]), &[7], 0, |h, x, z| {
asked.push((h, x, z));
70
})
.unwrap();
assert_eq!(asked, [(Heightmap::OceanFloor, 1, 2)]);
assert_eq!((runs[0].y0, runs[0].y1, runs[0].state), (68, 71, 7));
assert_eq!(runs[1], runs[0]);
// a run far past the world's height clamps rather than wraps
let mut high = run(0, None);
high.y = i32::MAX;
high.height = u32::MAX;
let runs = resolve_runs(&edit(vec![high]), &[7], 0, |_, _, _| 0).unwrap();
assert_eq!((runs[0].y0, runs[0].y1), (i32::MAX, i32::MAX));
}
fn world() -> World {
let mut w = World::new(BlockStateId(0), 0);
w.chunks.insert((0, 0), Arc::new(Chunk::empty(0, 0)));
w
}
fn names(s: u32) -> Arc<str> {
match s {
0 => "minecraft:air",
1 => "minecraft:stone",
2 => "minecraft:chest",
_ => "machines:press",
}
.into()
}
#[test]
fn a_chest_replaced_by_stone_loses_its_block_entity() {
let mut w = world();
w.set(1, 5, 1, BlockStateId(2)).unwrap();
let chest = BlockEntity::for_block("minecraft:chest").expect("a chest's");
w.block_entities.insert((1, 5, 1), chest);
let out = w.edit_runs(
&[Run {
x: 1,
z: 1,
y0: 4,
y1: 6,
state: 1,
only: None,
}],
|_, _| true,
|s| s == 2,
);
assert_eq!(out.tracked, [((1, 5, 1), 2, 1)]);
let replaced = replace_block_entities(&mut w, &out.tracked, names, BlockEntity::for_block);
assert_eq!(replaced, [[1, 5, 1]]);
assert!(w.block_entities.get(&(1, 5, 1)).is_none());
assert_eq!(w.block(1, 5, 1).0, 1);
}
#[test]
fn a_new_block_gets_the_block_entity_made_for_it() {
let mut w = world();
let out = w.edit_runs(
&[Run {
x: 3,
z: 3,
y0: 10,
y1: 10,
state: 3,
only: None,
}],
|_, _| true,
|s| s == 3,
);
// a mod block's, as `Game::new_block_entity` makes it
let made = |n: &str| {
(n == "machines:press").then(|| {
BlockEntity::Mod(Box::new(horizon_client::containers::ModBlockEntity {
id: "machines:press".into(),
..Default::default()
}))
})
};
replace_block_entities(&mut w, &out.tracked, names, made);
assert!(matches!(
w.block_entities.get(&(3, 10, 3)),
Some(BlockEntity::Mod(e)) if e.id == "machines:press"
));
}
/// air 0, stone 1, leaves 2, water 3: column (1, 1) stone to 60, water, then leaves to 64; column (2, 2)
/// stone to 60, leaves, then water to 64; at (3, 3) stone at the top of the world.
fn columns_world() -> World {
let mut w = world();
for (x, z, low, high) in [(1, 1, 3, 2), (2, 2, 2, 3)] {
for y in -64..=64 {
let s = match y {
..=60 => 1,
61..=62 => low,
_ => high,
};
w.set(x, y, z, BlockStateId(s)).unwrap();
}
}
w.set(3, 319, 3, BlockStateId(1)).unwrap();
w
}
fn height(w: &World, h: Heightmap, x: i32, z: i32) -> i32 {
column_height(
w,
h,
(x, z),
|s| s.0 == 0,
|kind, s| match kind {
HeightmapType::MotionBlockingNoLeaves => matches!(s.0, 1 | 3),
HeightmapType::OceanFloor => matches!(s.0, 1 | 2),
_ => s.0 != 0,
},
)
}
#[test]
fn each_heightmap_has_its_own_height() {
let w = columns_world();
assert_eq!(height(&w, Heightmap::WorldSurface, 1, 1), 65);
assert_eq!(height(&w, Heightmap::MotionBlocking, 1, 1), 65);
assert_eq!(height(&w, Heightmap::MotionBlockingNoLeaves, 1, 1), 63);
assert_eq!(height(&w, Heightmap::OceanFloor, 1, 1), 65);
assert_eq!(height(&w, Heightmap::OceanFloor, 2, 2), 63);
assert_eq!(height(&w, Heightmap::MotionBlockingNoLeaves, 2, 2), 65);
// an empty column, and one not loaded: the bottom
assert_eq!(height(&w, Heightmap::OceanFloor, 5, 5), -64);
assert_eq!(height(&w, Heightmap::WorldSurface, 100, 100), -64);
}
#[test]
fn columns_not_loaded_have_no_height() {
let w = columns_world();
let heights = loaded_heights(&w, &[(1, 1), (100, 100), (2, 2), (-1, 0)], |x, z| {
height(&w, Heightmap::OceanFloor, x, z)
});
assert_eq!(heights, [Some(65), None, Some(63), None]);
}
#[test]
fn column_blocks_read_air_past_the_column_and_where_not_loaded() {
let w = columns_world();
let (palette, blocks) = column_blocks(
&w,
&[(1, 1), (3, 3), (100, 100)],
&[Some(65), Some(319), None],
2,
3,
|s| names(s.0),
)
.unwrap();
let read: Vec<&str> = blocks.iter().map(|&i| &*palette[i as usize]).collect();
// (`names` calls state 2 a chest: here it stands for leaves)
let (leaves, stone, air) = ("minecraft:chest", "minecraft:stone", "minecraft:air");
assert_eq!(
read,
[
leaves, leaves, air, air, air, // (1, 1) from 63
air, air, stone, air, air, // (3, 3) from 317: stone at 319, nothing over 319
air, air, air, air, air, // not loaded
]
);
// names once each
assert_eq!(palette.len(), 3);
}
#[test]
fn a_read_is_bounded() {
assert_eq!(read_size(10, 200, 184, 384), Ok(3840));
assert!(
read_size(1, 200, 185, 384).is_err(),
"more than the level's height"
);
assert!(read_size(1, 0, 0, 384).is_err(), "an empty span");
assert!(read_size(1, u32::MAX, 1, 4064).is_err());
assert!(read_size(1 << 14, 100, 0, 384).is_err(), "over MAX_READ");
assert!(read_size(usize::MAX, 2, 0, 384).is_err());
assert_eq!(read_size(1 << 12, 256, 0, 384), Ok(MAX_READ));
let w = columns_world();
assert!(column_blocks(&w, &[(1, 1)], &[Some(0)], u32::MAX, 2, |s| names(s.0)).is_none());
}
#[test]
fn each_new_block_gets_its_own_block_entity() {
// a mod's constructor numbering its instances; stone makes none
let mut calls = Vec::new();
let made = make_block_entities(
&[
"machines:press",
"minecraft:stone",
"machines:press",
"minecraft:stone",
]
.map(Arc::<str>::from),
|n| {
calls.push(n.to_string());
(n == "machines:press").then(|| {
BlockEntity::Mod(Box::new(horizon_client::containers::ModBlockEntity {
id: format!("press-{}", calls.len()),
..Default::default()
}))
})
},
);
let ids: Vec<Option<String>> = made
.iter()
.map(|e| match e {
Some(BlockEntity::Mod(e)) => Some(e.id.to_string()),
_ => None,
})
.collect();
assert_eq!(
ids,
[
Some("press-1".to_string()),
None,
Some("press-3".to_string()),
None
]
);
assert_eq!(
calls,
["machines:press", "minecraft:stone", "machines:press"]
);
}
}
@@ -0,0 +1,51 @@
//! The native parity harness runs the actual client boat ticker without a
//! renderer, on an empty world; movement ownership changes between ticks.
use super::{Game, boats::Boat};
use horizon_client::Result;
use horizon_core::geometry::Vec3;
use serde::Deserialize;
use serde_json::json;
use std::path::Path;
#[derive(Deserialize)]
struct Case {
name: String,
velocity: [f64; 3],
spin: f32,
owned: Vec<bool>,
}
impl Game<'_> {
pub(super) fn run_boat_authority_fixture(&mut self, directory: &Path) -> Result<()> {
let cases: Vec<Case> =
serde_json::from_str(&std::fs::read_to_string(directory.join("inputs.json"))?)?;
let mut output = Vec::new();
for case in cases {
self.boats.clear();
let mut boat = Boat::new(1, "oak".into(), Vec3::new(0.0, 100.0, 0.0), 0.0);
let [x, y, z] = case.velocity;
boat.velocity = Vec3::new(x, y, z);
boat.delta_rotation = case.spin;
self.boats.push(boat);
let mut steps = Vec::new();
for owned in case.owned {
self.riding = owned.then_some(1);
if owned {
let boat = self.boats.remove(0);
let boat = self.tick_boat(boat, true);
self.boats.push(boat);
}
self.tick_remote_boat_bubbles();
let boat = &self.boats[0];
steps.push(json!({
"owned": owned,
"spin": format!("{:08x}", boat.delta_rotation.to_bits()),
"velocity": boat.velocity.components().map(|v| format!("{:016x}", v.to_bits())),
}));
}
output.push(json!({"name": case.name, "steps": steps}));
}
std::fs::write(directory.join("rust.json"), serde_json::to_vec(&output)?)?;
Ok(())
}
}
+2
View File
@@ -1300,6 +1300,8 @@ impl Game<'_> {
let (damage, _, _, magic) = self.melee_damage(&kind, false);
self.hurt_boat(i, damage, self.creative);
self.vehicle_attack_sounds(area, magic.then_some(id));
// Player.onAttack: the hit starts the attack strength again.
self.attack_ticks = 0;
self.swing_arm();
true
}
+28 -14
View File
@@ -252,13 +252,14 @@ fn block_properties(registry: &'static BlockRegistry) -> Arc<BlockProperties> {
properties
}
static COMMAND_EXPORTS: Mutex<Vec<(PathBuf, Option<Arc<serde_json::Value>>)>> =
Mutex::new(Vec::new());
static COMMAND_EXPORTS: Mutex<Vec<(PathBuf, Option<Arc<Registries>>)>> = Mutex::new(Vec::new());
/// A folder's `command-registries.json` (vanilla's built-in registries),
/// read once like the resource ids: parsing its 1.6 MB again took about
/// 15 ms of every world opening.
fn command_export(dir: &Path) -> Option<Arc<serde_json::Value>> {
/// What the registries take from a folder's `command-registries.json`
/// (vanilla's built-in registries), read once like the resource ids: parsing
/// its 1.6 MB again took about 15 ms of every world opening. Only the part
/// the registries fill from stays, not the parsed export (4.2 MB, against
/// 2.2 MB). A dedicated server opens its world once and keeps nothing.
fn command_export(dir: &Path, keep: bool) -> Option<Arc<Registries>> {
let mut cache = COMMAND_EXPORTS.lock().unwrap_or_else(|e| e.into_inner());
if let Some((_, export)) = cache.iter().find(|(cached, _)| cached == dir) {
return export.clone();
@@ -266,8 +267,10 @@ fn command_export(dir: &Path) -> Option<Arc<serde_json::Value>> {
let export = std::fs::read(dir.join("command-registries.json"))
.ok()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.map(Arc::new);
cache.push((dir.to_owned(), export.clone()));
.map(|export| Arc::new(Registries::from_export(&export)));
if keep {
cache.push((dir.to_owned(), export.clone()));
}
export
}
@@ -531,8 +534,8 @@ impl Game<'_> {
}
// Vanilla's built-in registries (mob effects, components, dialogs...).
for dir in [&self.options.resources, &self.ui.vanilla_root] {
if let Some(export) = command_export(dir) {
self.ui.registries.fill_from_export(&export);
if let Some(export) = command_export(dir, self.options.dedicated.is_none()) {
self.ui.registries.fill_from_exported(&export);
break;
}
}
@@ -1552,11 +1555,22 @@ mod tests {
let dir = std::env::temp_dir().join(format!("horizon-export-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("command-registries.json"), r#"{"effects":["a"]}"#).unwrap();
let first = command_export(&dir).unwrap();
assert_eq!(first["effects"][0], "a");
let first = command_export(&dir, true).unwrap();
assert_eq!(first.effects, ["a"]);
std::fs::write(dir.join("command-registries.json"), "{}").unwrap();
assert!(Arc::ptr_eq(&first, &command_export(&dir).unwrap()));
assert!(command_export(&dir.join("missing")).is_none());
assert!(Arc::ptr_eq(&first, &command_export(&dir, true).unwrap()));
assert!(command_export(&dir.join("missing"), true).is_none());
// A dedicated server's read is not kept.
let other = dir.join("other");
std::fs::create_dir_all(&other).unwrap();
std::fs::write(
other.join("command-registries.json"),
r#"{"effects":["b"]}"#,
)
.unwrap();
let once = command_export(&other, false).unwrap();
assert_eq!(once.effects, ["b"]);
assert!(!Arc::ptr_eq(&once, &command_export(&other, false).unwrap()));
let _ = std::fs::remove_dir_all(&dir);
}
@@ -9,7 +9,7 @@ use horizon_client::{
containers::BlockEntity,
hud::{HudBatch, rgba},
screen_draw::Measure,
text::{self, Text},
text::Text,
ui::{self, EditBox},
world::World,
};
@@ -546,9 +546,9 @@ impl Game<'_> {
}
}
/// `CommandSourceStack.sendSuccess` from a command block: its output when
/// `send_command_feedback`, and `broadcastToAdmins` when
/// `send_command_feedback`, and `broadcastToAdmins` when `broadcast` and
/// `command_block_output`.
pub(super) fn command_block_success(&mut self, pos: Pos, message: Text) {
pub(super) fn command_block_success(&mut self, pos: Pos, message: Text, broadcast: bool) {
let Some(name) = self
.command_block(pos)
.filter(|c| c.track_output)
@@ -560,11 +560,8 @@ impl Game<'_> {
if feedback {
self.command_block_output(pos, message.clone());
}
if feedback && self.ui.rules.bool("command_block_output") {
let broadcast = Text::translate("chat.type.admin", &[name, message])
.with_color(text::GRAY)
.italic();
self.system_message(broadcast);
if broadcast && feedback && self.ui.rules.bool("command_block_output") {
self.broadcast_source_to_admins(name, message);
}
}
@@ -920,7 +917,8 @@ impl Game<'_> {
conditional: p.flags & 2 != 0,
auto: p.flags & 4 != 0,
};
self.as_guest(host, conn, |game| {
// The confirmation (`advMode.setCommand.success`) is the editing player's alone.
self.as_guest_messaged(host, conn, |game| {
if allowed {
game.apply_command_block(&edit);
// BaseCommandBlock.onUpdated: the clients get the new command.
@@ -9,10 +9,7 @@ use super::{
lan::LanHost,
minecarts::{CartKind, Minecart},
};
use horizon_client::{
command_block::Mode,
text::{self, Text},
};
use horizon_client::{command_block::Mode, text::Text};
use horizon_protocol::packets::play::{self, Serverbound as S};
impl Game<'_> {
@@ -93,7 +90,7 @@ impl Game<'_> {
/// `CommandSourceStack.sendSuccess` from a command minecart: its output
/// when `send_command_feedback`, and `broadcastToAdmins` when
/// `command_block_output`.
pub(super) fn cart_command_success(&mut self, id: u32, message: Text) {
pub(super) fn cart_command_success(&mut self, id: u32, message: Text, broadcast: bool) {
let Some(name) = self
.command_cart_mut(id)
.filter(|c| c.command.track_output)
@@ -105,11 +102,8 @@ impl Game<'_> {
if feedback {
self.cart_command_output(id, message.clone());
}
if feedback && self.ui.rules.bool("command_block_output") {
let broadcast = Text::translate("chat.type.admin", &[name, message])
.with_color(text::GRAY)
.italic();
self.system_message(broadcast);
if broadcast && feedback && self.ui.rules.bool("command_block_output") {
self.broadcast_source_to_admins(name, message);
}
}
@@ -164,7 +158,7 @@ impl Game<'_> {
return;
};
let allowed = g.creative() && g.permission >= 2;
self.as_guest(host, conn, |game| {
self.as_guest_messaged(host, conn, |game| {
if allowed {
game.apply_cart_command(p.entity as u32, p.command, p.track_output);
} else {
+92 -26
View File
@@ -63,6 +63,8 @@ pub(super) enum Target {
Crystal(u32),
/// The Ender Dragon, by its entity id (`minecraft:ender_dragon`).
Dragon(u32),
/// A primed TNT, by its entity id (`minecraft:tnt`).
Tnt(u32),
}
/// An entity of a type Horizon does not simulate: its compound lives in the
/// entity memory (`persist.raw`), its position and rotation here.
@@ -135,7 +137,8 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
| Target::Cart(id)
| Target::Other(id)
| Target::Crystal(id)
| Target::Dragon(id)) = t
| Target::Dragon(id)
| Target::Tnt(id)) = t
else {
return None;
};
@@ -242,11 +245,11 @@ impl Game<'_> {
return;
}
if let Some(block) = self.command_source.and_then(|s| s.block) {
self.command_block_success(block, message);
self.command_block_success(block, message, false);
return;
}
if let Some(cart) = self.command_source.and_then(|s| s.cart) {
self.cart_command_success(cart, message);
self.cart_command_success(cart, message, false);
return;
}
if self.ui.rules.bool("send_command_feedback") {
@@ -259,11 +262,11 @@ impl Game<'_> {
return;
}
if let Some(block) = self.command_source.and_then(|s| s.block) {
self.command_block_success(block, message);
self.command_block_success(block, message, true);
return;
}
if let Some(cart) = self.command_source.and_then(|s| s.cart) {
self.cart_command_success(cart, message);
self.cart_command_success(cart, message, true);
return;
}
if self.ui.rules.bool("send_command_feedback") {
@@ -271,6 +274,36 @@ impl Game<'_> {
}
self.broadcast_to_admins(message);
}
/// `CommandSourceStack.broadcastToAdmins` for a command block or a command
/// minecart called `name`: `[name: message]`, gray and italic, for every
/// operator when `send_command_feedback` (the world's host too: no player
/// ran the command) and for a dedicated server's console when
/// `log_admin_commands`. Queued: `send_admin_feedback` sends it.
pub(super) fn broadcast_source_to_admins(&mut self, name: Text, message: Text) {
if message
.translation_key()
.is_some_and(|k| admin::QUIET.contains(&k))
{
return;
}
let line = Text::translate("chat.type.admin", &[name, message])
.with_color(text::GRAY)
.italic();
// Nobody else is in this game: its player is the only operator there is.
if self.lan.is_none()
&& self.lan_acting.is_none()
&& self.serving.is_none()
&& self.options.dedicated.is_none()
{
self.system_message(line);
return;
}
let ops = self.ui.rules.bool("send_command_feedback");
let console = self.options.dedicated.is_some() && self.ui.rules.bool("log_admin_commands");
if ops || console {
self.admin_feedback.push((line, ops, console, true));
}
}
/// CommandSourceStack.broadcastToAdmins (`sendSuccess` with `broadcast`)
/// for a guest's command, the host's in a world open to LAN and the
/// dedicated server console's when `broadcast-console-to-ops` is on:
@@ -310,7 +343,7 @@ impl Game<'_> {
let line = Text::translate("chat.type.admin", &[who, message])
.with_color(text::GRAY)
.italic();
self.admin_feedback.push((line, ops, console));
self.admin_feedback.push((line, ops, console, false));
}
/// Player.getDisplayName: the name (team-formatted), suggesting a
/// `/tell`, showing the player on hover, inserting the name.
@@ -479,6 +512,11 @@ impl Game<'_> {
.as_ref()
.filter(|d| d.id == id)
.map(|d| [d.position.x, d.position.y, d.position.z]),
Target::Tnt(id) => self
.tnts
.iter()
.find(|t| t.id == id)
.map(|t| [t.position.x, t.position.y, t.position.z]),
}
}
pub(super) fn target_type(&self, t: Target) -> String {
@@ -525,6 +563,7 @@ impl Game<'_> {
.map_or_else(String::new, |o| o.kind.clone()),
Target::Crystal(_) => "minecraft:end_crystal".into(),
Target::Dragon(_) => "minecraft:ender_dragon".into(),
Target::Tnt(_) => "minecraft:tnt".into(),
}
}
/// The name of the player the game acts for: a guest's own while their
@@ -801,6 +840,7 @@ impl Game<'_> {
if let Some(d) = &self.dragon {
candidates.push(Target::Dragon(d.id));
}
candidates.extend(self.tnts.iter().map(|t| Target::Tnt(t.id)));
candidates
}
/// The loaded levels, in the order the server holds them: the
@@ -832,6 +872,7 @@ impl Game<'_> {
Target::Other(id),
Target::Crystal(id),
Target::Dragon(id),
Target::Tnt(id),
]
.into_iter()
.find(|t| self.level_holds(*t))
@@ -853,6 +894,7 @@ impl Game<'_> {
Target::Other(id) => self.others.iter().any(|o| o.id == id),
Target::Crystal(id) => self.crystals.iter().any(|c| c.id == id),
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
Target::Tnt(id) => self.tnts.iter().any(|t| t.id == id),
}
}
/// Run `f` with the level that holds the entity a target names in place
@@ -1419,6 +1461,7 @@ impl Game<'_> {
Target::Stand(id) => {
if let Some(a) = self.armor_stands.iter_mut().find(|a| a.id == id) {
a.position = v;
a.previous = v;
if let Some([yaw, _]) = rotation {
a.yaw = yaw;
}
@@ -1462,6 +1505,12 @@ impl Game<'_> {
d.previous = v;
}
}
Target::Tnt(id) => {
if let Some(t) = self.tnts.iter_mut().find(|t| t.id == id) {
t.position = v;
t.previous = v;
}
}
// Wall-mounted entities do not move.
Target::Frame(_) | Target::Painting(_) => {}
}
@@ -1959,6 +2008,7 @@ impl Game<'_> {
Target::Other(id) => self.others.retain(|o| o.id != id),
Target::Crystal(id) => self.kill_crystal(id),
Target::Dragon(id) => self.kill_dragon(id),
Target::Tnt(id) => self.tnts.retain(|t| t.id != id),
}
// The orbs marked for removal are gone (Entity.kill removes them).
let before = self.orb_ids();
@@ -2161,26 +2211,12 @@ impl Game<'_> {
}
/// BlockPredicateArgument test.
fn block_matches(&self, b: &BlockInput, state: BlockStateId) -> bool {
let Some(s) = self.pack.assets.registry.state(state) else {
return false;
};
let name_ok = if b.tag {
let tag = b.id.trim_start_matches("minecraft:");
self.res
.items()
.block_tags
.get(tag)
.or_else(|| self.items().block_tags.get(&b.id))
.is_some_and(|set| {
set.contains(&s.name) || set.contains(s.name.trim_start_matches("minecraft:"))
})
} else {
s.name == b.id
};
name_ok
&& b.properties
.iter()
.all(|(k, v)| s.properties.get(k) == Some(v))
block_input_matches(
self.pack.assets.registry,
[&self.res.items().block_tags, &self.items().block_tags],
b,
state,
)
}
/// Place a state like BlockInput.place: false when nothing changed.
pub(super) fn place_state(
@@ -3586,6 +3622,35 @@ fn split_top_level(s: &str, sep: char) -> Vec<&str> {
out
}
/// BlockPredicateArgument test, against the registry and the block tags:
/// a `#tag` is looked up by its path in `tags[0]`, then by its whole id in
/// `tags[1]` (as `Game::block_matches` reads them).
pub(super) fn block_input_matches(
registry: &horizon_world::registry::BlockRegistry,
tags: [&std::collections::HashMap<String, std::collections::HashSet<String>>; 2],
b: &BlockInput,
state: BlockStateId,
) -> bool {
let Some(s) = registry.state(state) else {
return false;
};
let name_ok = if b.tag {
let tag = b.id.trim_start_matches("minecraft:");
tags[0]
.get(tag)
.or_else(|| tags[1].get(&b.id))
.is_some_and(|set| {
set.contains(&s.name) || set.contains(s.name.trim_start_matches("minecraft:"))
})
} else {
s.name == b.id
};
name_ok
&& b.properties
.iter()
.all(|(k, v)| s.properties.get(k) == Some(v))
}
#[cfg(test)]
mod summon_tests {
#[test]
@@ -3615,6 +3680,7 @@ mod look_at_tests {
Target::Cart(7),
Target::Crystal(7),
Target::Dragon(7),
Target::Tnt(7),
] {
assert_eq!(non_player_entity_id(t), Some(7), "{t:?}");
}
@@ -176,6 +176,18 @@ impl Game<'_> {
let (uuid, raw) = self.persist_anon("minecraft:ender_dragon", d.position);
d.to_nbt(uuid.unwrap_or_else(random_uuid), raw)
}
Target::Tnt(id) => {
let uuid = self.uuid_for(id);
let t = self.tnts.iter().find(|t| t.id == id)?;
entity_nbt::tnt_to_nbt(
t.position,
t.velocity,
t.on_ground,
t.fuse,
uuid,
self.persist.raw.get(&id),
)
}
})
}
/// `Entity.load` of new data (`EntityDataAccessor.setData`): the entity is
+184 -28
View File
@@ -9,6 +9,7 @@ use horizon_client::{
arrows::EntityHit,
blocks::Fluid,
collisions::SceneCollisions,
health::{HurtSource, Hurting, Knock, knockback_canceled},
items::ItemStack,
mesher::Vertex,
mobs::{
@@ -1109,6 +1110,8 @@ impl Game<'_> {
self.tick_fangs();
self.tick_allays();
self.tick_pandas();
self.tick_foxes();
self.tick_dolphins();
self.tick_mob_looting();
self.tick_breeze_effects();
self.tick_sniffer_effects();
@@ -1829,11 +1832,20 @@ impl Game<'_> {
power,
x,
z,
source,
} => match target {
Who::Player => self.knock_back_player(power, x, z),
// A player's source names no melee attacker, as its damage's doesn't.
Who::Player => {
let source = HurtSource {
damage_type: source_damage_type(source),
causing: 0,
direct: 0,
};
self.knock_back_player(power, x, z, source);
}
Who::Mob(id) => {
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) {
m.knockback(power, x, z);
m.knockback(power, x, z, &source);
}
}
},
@@ -2145,6 +2157,20 @@ impl Game<'_> {
self.finalize_converted(id);
}
}
MobEvent::ThrowItem {
mob,
stack,
at,
motion,
pickup_delay,
} => {
// `thrownItem.setThrower(the mob)`.
self.spawn_item(stack, at, motion, pickup_delay);
let thrower = self.mob_uuid(mob);
if let Some(thrown) = self.just_spawned_item() {
thrown.thrower = Some(thrower);
}
}
MobEvent::DropItem { item, count, at } => self.spawn_item(
ItemStack {
item,
@@ -3198,7 +3224,12 @@ impl Game<'_> {
if o.blocked <= 0.0 {
player.health.indicate_damage(xd, zd, player.yaw);
}
self.knock_back_player(f64::from(0.4_f32), xd, zd);
let source = HurtSource {
damage_type,
causing,
direct: 0,
};
self.knock_back_player(f64::from(0.4_f32), xd, zd, source);
if let Some(p) = self.player.as_mut()
&& let Some(r) = p.hurt_log.last_mut()
{
@@ -3245,38 +3276,30 @@ impl Game<'_> {
});
}
if raised {
self.after_block(outcome, attacker, projectile);
let source = HurtSource {
damage_type,
causing,
direct: 0,
};
self.after_block(outcome, (attacker, projectile), source);
}
outcome.hurt
}
/// `LivingEntity.knockback` on the player: pushed `power` away from the
/// direction (`xd`, `zd`), less its knockback resistance, and up only from
/// the ground.
pub(super) fn knock_back_player(&mut self, power: f64, mut xd: f64, mut zd: f64) {
let Some(p) = self.player.as_mut() else {
return;
};
let resistance = p.attribute("minecraft:knockback_resistance").unwrap_or(0.0);
let power = power * (1.0 - resistance);
if power <= 0.0 {
return;
/// The native knockback hook runs once the resistance is applied, with
/// `source`, the hit's.
pub(super) fn knock_back_player(
&mut self,
power: f64,
xd: f64,
zd: f64,
source: HurtSource<'_>,
) {
if let Some(p) = self.player.as_mut() {
knock_back(p, &mut self.world.random, power, (xd, zd), source);
}
while xd * xd + zd * zd < f64::from(1.0e-5_f32) {
let r = &mut self.world.random;
xd = (r.next_double() - r.next_double()) * 0.01;
zd = (r.next_double() - r.next_double()) * 0.01;
}
let l = (xd * xd + zd * zd).sqrt();
let v = p.velocity;
p.velocity = Vec3::new(
v.x / 2.0 - xd / l * power,
if p.ground.on_ground {
(v.y / 2.0 + power).min(0.4)
} else {
v.y
},
v.z / 2.0 - zd / l * power,
);
}
/// A status effect from a mob, arrow or splash potion (`addEffect`).
pub(super) fn give_player_effect(&mut self, effect: &'static str, ticks: i32, amplifier: i32) {
@@ -6684,6 +6707,48 @@ impl Game<'_> {
}
}
}
/// `LivingEntity.knockback` on `p`: [`Game::knock_back_player`].
fn knock_back(
p: &mut horizon_client::motion::PlayerMotion,
random: &mut horizon_core::random::LegacyRandom,
power: f64,
(xd, zd): (f64, f64),
source: HurtSource<'_>,
) {
let resistance = p.attribute("minecraft:knockback_resistance").unwrap_or(0.0);
let mut knock = Knock {
strength: power * (1.0 - resistance),
x: xd,
z: zd,
};
if knockback_canceled(Hurting::Player(p.entity_id, source), &mut knock) {
return;
}
let Knock {
strength: power,
x: mut xd,
z: mut zd,
} = knock;
if power <= 0.0 {
return;
}
while xd * xd + zd * zd < f64::from(1.0e-5_f32) {
xd = (random.next_double() - random.next_double()) * 0.01;
zd = (random.next_double() - random.next_double()) * 0.01;
}
let l = (xd * xd + zd * zd).sqrt();
let v = p.velocity;
p.velocity = Vec3::new(
v.x / 2.0 - xd / l * power,
if p.ground.on_ground {
(v.y / 2.0 + power).min(0.4)
} else {
v.y
},
v.z / 2.0 - zd / l * power,
);
}
#[cfg(test)]
mod tests {
use super::super::perspective::CameraType;
@@ -6959,3 +7024,94 @@ mod pearl_tests {
);
}
}
#[cfg(test)]
mod knockback_tests {
use super::*;
use horizon_client::health::KNOCKBACK;
/// What the hook of these tests saw: the player, its source (as damage
/// type, causing, direct) and the push.
type Pushes = std::sync::Mutex<Vec<(i32, (String, i32, i32), Knock)>>;
static PUSHES: Pushes = std::sync::Mutex::new(Vec::new());
/// Sets the native knockback hook of these tests (global: it stays set for
/// the process) for players 9201 to 9203: it records the push, cancels
/// 9201's and doubles 9202's strength and turns its direction around.
fn hook(pushed: Hurting<'_>, knock: &mut Knock) -> bool {
let Hurting::Player(entity @ 9201..=9203, s) = pushed else {
return false;
};
let source = (s.damage_type.to_owned(), s.causing, s.direct);
PUSHES.lock().unwrap().push((entity, source, *knock));
match entity {
9201 => return true,
9202 => {
knock.strength *= 2.0;
knock.z = -knock.z;
}
_ => {}
}
false
}
/// `LivingEntity.knockback` on a player: the hook runs after the
/// knockback resistance and before the motion changes; a mod's strength or
/// direction pushes, a cancel pushes nothing, and without a mod in the way
/// the push is vanilla's. Needs the prepared data in
/// `.local/horizon-client/26.3`; skipped without it.
#[test]
fn the_native_knockback_hook_changes_or_cancels_a_players_push() {
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 installed = *KNOCKBACK.get_or_init(|| hook);
assert!(std::ptr::fn_addr_eq(
installed,
hook as fn(Hurting<'_>, &mut Knock) -> bool
));
let assets = horizon_client::assets::Assets::load(&root).unwrap();
let resources = horizon_client::motion::MotionResources::load(&root, &assets).unwrap();
let mut random = horizon_core::random::LegacyRandom::new(1);
let hit = HurtSource {
damage_type: "minecraft:player_attack",
causing: 5,
direct: 5,
};
let mut push = |entity, resistance: f64| {
let mut p = horizon_client::motion::PlayerMotion::new(&resources, Vec3::ZERO).unwrap();
p.entity_id = entity;
p.ground.on_ground = true;
if resistance != 0.0 {
let attribute = "minecraft:knockback_resistance";
let set = p.set_modifier(attribute, "horizon:test", resistance, "add_value", true);
set.unwrap();
}
knock_back(&mut p, &mut random, 0.4, (0.0, 3.0), hit);
(p.velocity.x, p.velocity.y, p.velocity.z)
};
// No mod in the way: vanilla's push, away from z = 3.
assert_eq!(push(9200, 0.0), (0.0, 0.4, -0.4));
// Canceled: no motion. The hook saw the push and the hit.
assert_eq!(push(9201, 0.0), (0.0, 0.0, 0.0));
let made = Knock {
strength: 0.4,
x: 0.0,
z: 3.0,
};
let named = ("minecraft:player_attack".to_owned(), 5, 5);
let seen = |entity| -> Vec<_> {
let seen = PUSHES.lock().unwrap();
seen.iter().filter(|p| p.0 == entity).cloned().collect()
};
assert_eq!(seen(9201), [(9201, named, made)]);
// Twice the strength, direction turned around.
assert_eq!(push(9202, 0.0), (0.0, 0.4, 0.8));
// After the resistance: a 0.5 resistance hands the hook half the strength.
assert_eq!(push(9203, 0.5), (0.0, 0.2, -0.2));
assert_eq!(seen(9203)[0].2.strength, 0.2);
}
}
+3 -1
View File
@@ -359,8 +359,10 @@ impl Game<'_> {
if !self.before_block(d) || self.mob_hit().is_some_and(|m| m.1 < d) {
return false;
}
// `skipAttackInteraction` when `mayInteract` is false.
// `Cushion.hurtServer` refuses a player who may not build, so
// skipAttackInteraction is false and Player.attack goes on to onAttack.
if !self.may_build() {
self.attack_ticks = 0;
return true;
}
self.break_cushion(i, true);
+12 -1
View File
@@ -363,8 +363,19 @@ impl Game<'_> {
.map_or("0".into(), |mhz| {
debug_overlay::refresh_rate_label(f64::from(mhz) / 1000.0)
});
// Horizon: with frame generation running, the frames reaching the screen too.
let generated = self
.renderer
.as_ref()
.and_then(|r| r.frame_generation_share())
.map_or(String::new(), |share| {
format!(
" ({} fps FSR 3.1)",
(self.fps * (1.0 + share)).round() as i32
)
});
d.priority(format!(
"{} fps T: {}{present} @{refresh}Hz",
"{} fps{generated} T: {}{present} @{refresh}Hz",
self.fps.round() as i32,
if limit == 260 {
"inf".into()
+7 -1
View File
@@ -867,7 +867,13 @@ impl Game<'_> {
/// `EnderDragonFight.tick` and the fight's entities, while the End is
/// ticked (in place for its player, or on a server with players in it).
pub(super) fn tick_fight(&mut self) {
if self.dim != Dim::End || self.travelling.is_some() {
if self.travelling.is_some() {
return;
}
// A dragon outside the End (`/summon`) has no fight: `ServerLevel.getDragonFight` is
// null there, but `EnderDragon.aiStep` still flies it.
if self.dim != Dim::End {
self.tick_dragon();
return;
}
self.load_fight();
+3
View File
@@ -489,6 +489,9 @@ impl Game<'_> {
if self.dim == Dim::End {
self.tick_fight();
self.tick_dragon_fireballs();
} else {
// `/summon ender_dragon` outside the End: no fight, but the dragon flies.
self.tick_fight();
}
self.tick_crystals();
self.tick_eyes();
+73 -3
View File
@@ -441,7 +441,12 @@ impl Game<'_> {
}
self.combat.forget_noted(outcome.canceled);
if raised {
self.after_block(outcome, None, false);
let source = horizon_client::health::HurtSource {
damage_type,
causing: attacker.map_or(0, |(id, _)| *id as i32),
direct: 0,
};
self.after_block(outcome, (None, false), source);
}
pushed
}
@@ -670,9 +675,18 @@ impl Game<'_> {
);
}
self.tnts = tnts;
// `VehicleEntity.ignoreExplosion`: a mob's blast (not a primed TNT's)
// spares boats and minecarts while mob griefing is off.
let vehicles_ignore = self.combat.blast_owner.is_none()
&& self.blast_attacker().is_some()
&& !self.ui.rules.bool("mob_griefing");
// Minecarts: VehicleEntity.hurtServer (explosions ignite TNT carts),
// then the push from their eyes (0.85 of their height).
let ids: Vec<u32> = self.carts.iter().map(|c| c.id).collect();
let ids: Vec<u32> = if vehicles_ignore {
Vec::new()
} else {
self.carts.iter().map(|c| c.id).collect()
};
for id in ids {
let Some(i) = self.carts.iter().position(|c| c.id == id) else {
continue;
@@ -698,11 +712,67 @@ impl Game<'_> {
c.velocity.z + dir[2] * power,
);
}
// Boats: `VehicleEntity.hurtServer` (damage * 10, broken past 40), then the
// push from their eyes (`EntityType.OAK_BOAT & co`: the full 0.5625).
let ids: Vec<u32> = if vehicles_ignore {
Vec::new()
} else {
self.boats.iter().map(|b| b.id).collect()
};
for id in ids {
let Some(i) = self.boats.iter().position(|b| b.id == id) else {
continue;
};
let bounds = self.boats[i].bounds();
let feet = self.boats[i].position;
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
if dist > 1.0 {
continue;
}
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
self.hurt_boat(i, damage(dist, exposure), false);
let Some(b) = self.boats.iter_mut().find(|b| b.id == id) else {
continue;
};
let dir = direction([feet.x, feet.y + 0.5625, feet.z]);
let power = (1.0 - dist) * f64::from(exposure);
b.velocity = Vec3::new(
b.velocity.x + dir[0] * power,
b.velocity.y + dir[1] * power,
b.velocity.z + dir[2] * power,
);
}
// Falling blocks: `Entity.hurtServer` does nothing, `pushFromExplosion`
// moves them from their eyes (0.85 of the 0.98 box).
let mut falling = std::mem::take(&mut self.falling);
for f in &mut falling {
let feet = f.position;
let dist = distance([feet.x, feet.y, feet.z]) / diameter;
if dist > 1.0 {
continue;
}
let b = horizon_client::falling_block::bounds(feet);
let (lo, hi) = (b.min_position(), b.max_position());
let exposure = self.seen_percent(center, [lo.x, lo.y, lo.z], [hi.x, hi.y, hi.z]);
let eye = f64::from((hi.y - lo.y) as f32 * 0.85f32);
let dir = direction([feet.x, feet.y + eye, feet.z]);
let power = (1.0 - dist) * f64::from(exposure);
f.velocity = Vec3::new(
f.velocity.x + dir[0] * power,
f.velocity.y + dir[1] * power,
f.velocity.z + dir[2] * power,
);
}
self.falling = falling;
// Arrows, fire charges, snowballs and the rest: `Entity.pushFromExplosion`
// (`push`) from their eyes (0.85 of their height). `AbstractArrow.push`
// does nothing while it is in the ground; `hurtServer` never hurts them.
let mut projectiles = std::mem::take(&mut self.creatures.projectiles);
for p in projectiles.iter_mut().filter(|p| !p.stuck) {
for p in projectiles
.iter_mut()
.filter(|p| !p.stuck && !p.kind.ignores_blast())
{
let (lo, hi) = (p.bounds().min_position(), p.bounds().max_position());
let dist = distance([p.position.x, p.position.y, p.position.z]) / diameter;
if dist > 1.0 {
+166
View File
@@ -0,0 +1,166 @@
//! Foxes and dolphins on the game side: what they take from the ground (`Fox.pickUpItem`,
//! `Fox.canHoldItem`, `Dolphin.pickUpItem`) and where `FoxSearchForItemsGoal` and
//! `Dolphin.MoveToItemGoal` walk to.
use super::{
Game,
pickups::{Collector, Taken},
};
use horizon_client::mobs::{
Species, dolphin,
equip::Slot,
finalize::{self, set_drop_chance, set_item_stack},
fox,
};
use horizon_core::geometry::Vec3;
impl Game<'_> {
/// `Fox.ALLOWED_ITEMS` and the looting block of `Mob.aiStep` for foxes, once a tick.
pub(super) fn tick_foxes(&mut self) {
let griefing = self.ui.rules.bool("mob_griefing");
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Fox || !m.alive() || m.removed {
continue;
}
// `Mob.aiStep`: the items in the box grown by (1, 0, 1), in order.
if griefing && m.can_pick_up_loot() {
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
let mut j = 0;
while j < self.dropped.len() {
let e = &self.dropped[j];
if e.pickup_delay > 0 || e.stack.count == 0 || !reach.intersects(e.bounds()) {
j += 1;
continue;
}
let item_food = self.fox_food(&e.stack.item);
let held_food = self.mobs[i]
.extra
.main_hand
.as_deref()
.is_some_and(|held| self.fox_food(held));
if !fox::can_hold_item(&self.mobs[i], item_food, held_food) {
j += 1;
continue;
}
// The entity is discarded: the next one is at `j` now.
self.fox_pick_up(i, j);
}
}
// `FoxSearchForItemsGoal`: the first allowed item within 8 blocks.
let m = &self.mobs[i];
let area = m.bounds().inflate(Vec3::new(8.0, 8.0, 8.0));
let at = self
.dropped
.iter()
.find(|e| e.pickup_delay == 0 && e.health > 0 && area.intersects(e.bounds()))
.map(|e| [e.position.x, e.position.y, e.position.z]);
fox::set_item_at(&mut self.mobs[i], at);
}
}
/// `Dolphin.pickUpItem` in `Mob.aiStep`'s looting block, and the item
/// `Dolphin.MoveToItemGoal` swims to (`Dolphin.ALLOWED_ITEMS`: no pickup delay, alive, in water).
pub(super) fn tick_dolphins(&mut self) {
let griefing = self.ui.rules.bool("mob_griefing");
let in_water = |g: &Self, e: &horizon_client::entities::ItemEntity| {
g.res
.facts()
.get(g.world.block(
e.position.x.floor() as i32,
e.position.y.floor() as i32,
e.position.z.floor() as i32,
))
.fluid
== horizon_client::blocks::Fluid::Water
};
for i in 0..self.mobs.len() {
let m = &self.mobs[i];
if m.species() != Species::Dolphin || !m.alive() || m.removed {
continue;
}
// `Mob.aiStep`: the items in the box grown by (1, 0, 1); `Dolphin.pickUpItem`
// takes the whole stack when the mouth is empty (`Mob.canHoldItem` is true).
if griefing && m.can_pick_up_loot() {
let reach = m.bounds().inflate(Vec3::new(1.0, 0.0, 1.0));
let mut j = 0;
while j < self.dropped.len() {
let e = &self.dropped[j];
if e.pickup_delay > 0
|| e.stack.count == 0
|| !reach.intersects(e.bounds())
|| self.mobs[i].extra.main_hand.is_some()
{
j += 1;
continue;
}
let before = self.dropped[j].clone();
self.mob_item_pickup_trigger(i, &before);
let m = &mut self.mobs[i];
set_item_stack(m, Slot::MainHand.index(), before.stack.clone());
set_drop_chance(m, Slot::MainHand.index(), 2.0);
let id = m.id;
self.add_pickup(Taken::Item(Box::new(before)), Collector::Mob(id));
self.remove_dropped(j);
}
}
let area = self.mobs[i].bounds().inflate(Vec3::new(8.0, 8.0, 8.0));
let at = self
.dropped
.iter()
.find(|e| {
e.pickup_delay == 0
&& e.health > 0
&& area.intersects(e.bounds())
&& in_water(self, e)
})
.map(|e| [e.position.x, e.position.y, e.position.z]);
dolphin::set_item_at(&mut self.mobs[i], at);
}
}
/// `Fox.isConsumableFood`.
fn fox_food(&self, item: &str) -> bool {
self.items()
.get(item)
.is_some_and(|i| i.food.is_some() && i.consumable.is_some())
}
/// `Fox.pickUpItem`: the extra items drop where it stands, what it held is spat out
/// ahead of it, and it holds one of the item.
fn fox_pick_up(&mut self, fox_index: usize, item_index: usize) {
let before = self.dropped[item_index].clone();
let mut stack = before.stack.clone();
let count = stack.count;
let at = {
let m = &self.mobs[fox_index];
[m.position.x, m.position.y, m.position.z]
};
// `dropItemStack(itemStack.split(count - 1))`: a default ItemEntity at its feet.
if count > 1 {
let mut rest = stack.clone();
rest.count = count - 1;
stack.count = 1;
self.spawn_item(rest, at, super::dispenser::new_item_motion(), 10);
}
// `spitOutItem(getItemBySlot(MAINHAND))`.
let held = finalize::item_stack(&self.mobs[fox_index], Slot::MainHand.index());
if let Some(held) = held {
let look = self.mobs[fox_index].view_vector();
let from = [at[0] + look[0], at[1] + 1.0, at[2] + look[2]];
self.spawn_item(held, from, super::dispenser::new_item_motion(), 40);
let id = self.mob_uuid(self.mobs[fox_index].id);
if let Some(thrown) = self.just_spawned_item() {
thrown.thrower = Some(id);
}
self.audio
.play("minecraft:entity.fox.spit", Some(at), 1.0, 1.0);
}
// `onItemPickup`, then `setItemSlot(MAINHAND, split(1))`, `setGuaranteedDrop`.
self.mob_item_pickup_trigger(fox_index, &before);
let m = &mut self.mobs[fox_index];
set_item_stack(m, Slot::MainHand.index(), stack);
set_drop_chance(m, Slot::MainHand.index(), 2.0);
fox::took_item(m);
let id = m.id;
// `take(entity, count)` and `entity.discard()`.
self.add_pickup(Taken::Item(Box::new(before)), Collector::Mob(id));
self.remove_dropped(item_index);
}
}
+6
View File
@@ -255,6 +255,12 @@ impl Game<'_> {
return false;
}
if self.item_frames[i].fixed {
// A fixed frame refuses the hit of a player who isn't in creative
// (canHurtWhenFixed), so skipAttackInteraction is false and
// Player.attack goes on to onAttack.
if !self.creative {
self.attack_ticks = 0;
}
return true;
}
let with_frame = self.item_frames[i].item.is_none();
+27 -9
View File
@@ -11,6 +11,7 @@ use horizon_client::{
Result,
friends_screen::{self, Action, FriendsView},
hud::HudBatch,
lang,
text::Text,
};
use horizon_friends::{Host, HostConfig, HostEvent, PathMode, host::DEFAULT_PORT, reach, stun};
@@ -121,7 +122,10 @@ impl Game<'_> {
if sharing.opened_lan {
self.lan_close(true);
} else {
self.system_message(Text::literal("Stopped sharing the invite code."));
self.system_message(Text::literal(lang::get_or(
"horizon.friends.stopped",
"Stopped sharing the invite code.",
)));
}
}
@@ -142,9 +146,10 @@ impl Game<'_> {
match event {
HostEvent::Code(code) => {
if sharing.code.is_some() && sharing.wrong >= horizon_friends::host::MAX_WRONG {
sharing.notice = Some(
"Someone kept trying a wrong short code, so it changed.".to_owned(),
);
sharing.notice = Some(lang::get_or(
"horizon.friends.short_code_changed",
"Someone kept trying a wrong short code, so it changed.",
));
sharing.wrong = 0;
}
announce = sharing.code.is_none();
@@ -152,7 +157,10 @@ impl Game<'_> {
}
HostEvent::WrongCode { count } => {
sharing.wrong = count;
sharing.notice = Some("Someone tried a wrong code.".to_owned());
sharing.notice = Some(lang::get_or(
"horizon.friends.wrong_code",
"Someone tried a wrong code.",
));
}
HostEvent::Connected { direct: true, .. } => sharing.paths.0 += 1,
HostEvent::Connected { direct: false, .. } => sharing.paths.1 += 1,
@@ -160,9 +168,10 @@ impl Game<'_> {
}
}
if announce {
self.system_message(Text::literal(
self.system_message(Text::literal(lang::get_or(
"horizon.friends.code_ready",
"Your invite code is ready: Game Menu, Play with Friends. Copy it and send it to your friends.",
));
)));
}
}
@@ -198,7 +207,13 @@ impl Game<'_> {
match action {
Action::Share => {
if let Err(e) = self.friends_start() {
self.system_message(Text::literal(format!("Couldn't share the world: {e}")));
self.system_message(Text::literal(
lang::get_or(
"horizon.friends.share_failed",
"Couldn't share the world: %s",
)
.replacen("%s", &e.to_string(), 1),
));
}
}
Action::Copy => self.friends_copy(),
@@ -234,7 +249,10 @@ impl Game<'_> {
};
let state = s.host.state();
let mut status = if s.code.is_none() {
"Finding how friends can reach you…".to_owned()
lang::get_or(
"horizon.friends.finding_route",
"Finding how friends can reach you…",
)
} else {
state.summary.clone()
};
+10 -1
View File
@@ -15,7 +15,10 @@ impl Game<'_> {
return self.dialog_has_text_field();
}
if let Some(title) = self.title.as_mut() {
return title.focused_field().is_some();
// A caret field, an append-only one (`focused_field`), or a game
// rule's box (`has_caret_field` asks the rules screen itself, and
// `title_edit` types into it).
return title.has_caret_field() || title.focused_field().is_some();
}
if self.sign_edit.is_some() || matches!(self.book, Some(BookScreen::Edit { .. })) {
return true;
@@ -26,8 +29,12 @@ impl Game<'_> {
.debug_options
.as_ref()
.is_some_and(|s| s.search_focused()),
// The language list's search, the settings search and the pack
// screen's search (OptionsUi::typing).
Screen::Options => self.ui.options.typing(),
Screen::WorldOptions => self.world_options_typing(),
Screen::Creative => self.creative_screen.tab().search,
Screen::BlockEditor => self.block_editor.as_ref().is_some_and(|s| s.text_focused()),
Screen::Inventory => {
self.mod_browser_typing() || self.recipe_search_focused() || self.station_typing()
}
@@ -119,8 +126,10 @@ impl Game<'_> {
Screen::CommandBlock => self.command_block_type(&text),
Screen::Social => self.social_type(&text),
Screen::DebugOptions => self.debug_options_type(&text),
Screen::Options => self.ui.options.type_text(&self.gui.atlas, &text),
Screen::WorldOptions => self.world_options_text(Some(&text)),
Screen::Creative => self.creative_search_edit(Some(&text)),
Screen::BlockEditor => self.block_editor_type(&text),
Screen::Inventory if self.mod_browser_typing() => {
self.mod_browser_text(Some(&text), false)
}
+7
View File
@@ -2219,6 +2219,13 @@ impl Game<'_> {
for d in &mut self.dropped {
d.needs_sync = false;
}
// The step command reaches clients before the events its tick
// generated, as ServerTickRateManager.updateStepTicks does.
host.hub.sync_ticking(
self.clock.rate(),
self.clock.frozen(),
self.clock.step_state(),
);
// `runBlockEvents`' broadcast precedes the tick's block changes
// (`ChunkHolder.broadcastChanges` in `tick_level` below): the
// guest re-runs a piston's `triggerEvent` while its level still
+17 -65
View File
@@ -32,37 +32,8 @@ use horizon_protocol::{
text::literal,
};
use horizon_world::entity_move::EntityMoveWorld;
use std::collections::HashMap;
use winit::keyboard::KeyCode;
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
/// changes as `(pos, previous, viewers)`, in position order. A container that
/// is no longer counted goes to 0 and leaves `known`.
pub(super) fn viewer_changes(
known: &mut HashMap<(&'static str, (i32, i32, i32)), u32>,
dim: &'static str,
mut now: HashMap<(i32, i32, i32), u32>,
) -> Vec<((i32, i32, i32), u32, u32)> {
let mut changes = Vec::new();
known.retain(|&(d, pos), previous| {
if d != dim {
return true;
}
let viewers = now.remove(&pos).unwrap_or(0);
if viewers != *previous {
changes.push((pos, *previous, viewers));
*previous = viewers;
}
viewers > 0
});
for (pos, viewers) in now {
known.insert((dim, pos), viewers);
changes.push((pos, 0, viewers));
}
changes.sort_unstable();
changes
}
/// Per-guest state of the gameplay code that lives outside `Guest`.
#[derive(Default)]
pub(super) struct GuestPlay {
@@ -1395,27 +1366,6 @@ impl Game<'_> {
}
}
/// `ContainerOpenersCounter` for the mod containers of the level in place:
/// the players with each one open, recounted from their menus, so a
/// change is heard whichever way a player left (`native_mod_container_viewers`).
fn tick_mod_container_viewers(&mut self, host: &LanHost) {
let mut now: HashMap<(i32, i32, i32), u32> = HashMap::new();
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
// `BarrelBlockEntity.startOpen`: a spectator does not open it.
if !g.spectator()
&& let Some(m) = &g.menu
&& let Slots::Block(pos) = m.slots
&& self.mod_menu_at(pos).is_some()
{
*now.entry(pos).or_default() += 1;
}
}
let dim = self.dim.id();
for (pos, previous, viewers) in viewer_changes(&mut self.mod_container_viewers, dim, now) {
self.native_mod_container_viewers(dim, [pos.0, pos.1, pos.2], viewers, previous);
}
}
/// `stillValid` of the guest's open container: its blocks are still there
/// and in reach, or its vehicle exists and is in reach. True with none open.
fn container_menu_valid(&self, g: &horizon_client::lan::guest::Guest) -> bool {
@@ -3019,12 +2969,12 @@ impl Game<'_> {
pub(super) fn send_admin_feedback(&mut self, host: &mut LanHost, source: Option<u32>) {
// PlayerList.isOp for the host of a world open to LAN: the owner when
// the world allows commands, everyone when the LAN does.
let host_op = source.is_some()
&& !host.hub.config.dedicated
&& (self.allow_commands() || host.hub.config.guest_commands);
let host_ops =
!host.hub.config.dedicated && (self.allow_commands() || host.hub.config.guest_commands);
let host_op = source.is_some() && host_ops;
// server.toml's `admin_feedback_to_ops` off: the console's log only.
let to_ops = host.hub.admin_feedback_to_ops;
for (line, ops, console) in std::mem::take(&mut self.admin_feedback) {
for (line, ops, console, no_player) in std::mem::take(&mut self.admin_feedback) {
if ops && to_ops {
let content = horizon_client::lan::data::component_nbt(&line.component());
for g in host
@@ -3038,7 +2988,8 @@ impl Game<'_> {
overlay: false,
}));
}
if host_op {
// A line no player's command made (a command block's) reaches the host too.
if host_op || (no_player && host_ops) {
self.system_message(line.clone());
}
}
@@ -3305,7 +3256,6 @@ impl Game<'_> {
self.lan_stations_tick(host);
self.lan_tables_tick(host);
self.lan_containers_tick(host);
self.tick_mod_container_viewers(host);
self.tick_lan_mount_menus(host);
self.announce_guest_advancements(host);
let conns: Vec<u32> = host
@@ -3978,6 +3928,15 @@ impl Game<'_> {
}
}
MotionEvent::Fall(event) => self.landing_block_updates(&event),
// PowderSnowBlock.entityInside: every player around sees the
// snowflake (`ServerLevel.sendParticles`).
MotionEvent::Snowflake { position, velocity } => {
super::lan_particles::send_snowflake_particle(
host,
[position.x, position.y, position.z],
[velocity.x, velocity.y, velocity.z],
);
}
// LivingEntity.baseTick's entity event 67, to every client
// around, the guest's own included; this window draws it too.
MotionEvent::Drown => {
@@ -4096,15 +4055,8 @@ impl Game<'_> {
if next % 10 != 0 {
return;
}
if (next / 10) % 2 == 0
&& !creative
&& let Some(stack) = game.armor[1].clone()
{
// `ItemStack.hurtAndBreak`: Unbreaking can save the wear.
let loss = game.wear(&stack, 1);
if let Some(elytra) = game.armor[1].as_mut() {
elytra.damage += loss;
}
if (next / 10) % 2 == 0 && !creative {
game.wear_glider(1);
}
let ctx = game.player_context(None);
game.post_game_event("minecraft:elytra_glide", at, ctx);
@@ -121,6 +121,39 @@ pub(super) fn send_soul_particle(host: &super::lan::LanHost, at: [f64; 3], veloc
}
}
/// `PowderSnowBlock.entityInside`'s `ServerLevel.sendParticles` of one
/// snowflake (`count` 1, no spread, `RandomizationType.ALTERNATIVE`).
pub(super) fn send_snowflake_particle(
host: &super::lan::LanHost,
at: [f64; 3],
velocity: [f64; 3],
) {
let Some(id) = host
.hub
.registry_id("minecraft:particle_type", "minecraft:snowflake")
else {
return;
};
let packet = C::LevelParticles(play::LevelParticles {
particle: Particle { id, data: None },
override_limiter: false,
always_show: false,
pos: at,
dist: [0.0; 3],
max_speed: velocity.map(|v| v as f32),
count: 1,
randomization: 1,
});
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
let distance: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
if distance < 32.0 * 32.0 {
g.send(&packet);
}
}
}
impl Game<'_> {
/// `SonicBoom.tick` uses `ServerLevel.sendParticles` for each ray point.
/// Keep the local event's rendering; connected players receive one at rest.
+12 -2
View File
@@ -9,6 +9,7 @@ use super::{Game, lan::LanHost, lan_mobs::guest_bounds, mace::Spared, ray_box};
use horizon_client::{
Result,
health::HurtSource,
mobs::Who,
projectiles::{Hit, Projectile, ProjectileKind},
text::Text,
@@ -69,6 +70,15 @@ pub(super) struct Strike {
item: Option<horizon_client::text::Text>,
}
/// A player's blow on another player as the native hooks name it: the hit's
/// own push and its damage name no attacker yet, so the extra and the sweep's
/// pushes don't either.
const PVP_SOURCE: HurtSource<'static> = HurtSource {
damage_type: "minecraft:player_attack",
causing: 0,
direct: 0,
};
/// A sweep attack's reach to the other players (`Player.doSweepAttack`),
/// worked out as the attacker.
#[derive(Clone)]
@@ -355,7 +365,7 @@ impl Game<'_> {
g.combat.player_attacker = None;
let (strength, xd, zd) = s.knockback;
if hurt && strength > 0.0 {
g.knock_back_player(f64::from(strength), xd, zd);
g.knock_back_player(f64::from(strength), xd, zd, PVP_SOURCE);
}
// The victim's thorns, dealt to the attacker below.
let thorns = if hurt { g.thorns_damage() } else { Vec::new() };
@@ -552,7 +562,7 @@ impl Game<'_> {
g.note_named_attacker("minecraft:player_attack", *id, name.clone());
}
if g.mob_hits_player(sweep.damage, sweep.from, "minecraft:player_attack") {
g.knock_back_player(0.4, xd, zd);
g.knock_back_player(0.4, xd, zd, PVP_SOURCE);
}
};
for (victim, id, team) in victims {
+1 -1
View File
@@ -570,7 +570,7 @@ impl Game<'_> {
if old_health > 0.0 && !self.mobs[i].alive() {
self.native_kill_event(i);
}
self.mobs[i].knockback(0.4, xd, zd);
self.mobs[i].knockback(0.4, xd, zd, &source);
self.last_hurt_by_player
.insert(self.mobs[i].id, self.lan_acting);
self.post_attack(i);
+15 -2
View File
@@ -532,10 +532,21 @@ impl Game<'_> {
}
out
}
/// `Entity.isVehicle` for a cart: the local player, a LAN guest or a mob
/// sits in it. `getSlowdownFactor` (0.997) and the 0.75 speed scale key on
/// this, not on who rides.
fn cart_is_vehicle(&self, id: u32) -> bool {
self.riding == Some(id)
|| self.ridden_by_guest(id)
|| self
.mobs
.iter()
.any(|m| !m.removed && m.extra.mount == Some(id))
}
/// `OldMinecartBehavior.moveAlongTrack`.
fn move_along_track(&mut self, c: &mut Minecart, pos: [i32; 3], rail: &Rail, intent: [f64; 2]) {
c.fall_distance = 0.0;
let ridden = Some(c.id) == self.riding;
let ridden = self.cart_is_vehicle(c.id);
let (x, z) = (c.position.x, c.position.z);
let old = self.rail_pos(x, c.position.y, z);
let mut y = f64::from(pos[1]);
@@ -700,7 +711,7 @@ impl Game<'_> {
/// recomputed per rail (slope, halt, boost).
fn move_along_track_new(&mut self, c: &mut Minecart, _rail: &Rail, intent: [f64; 2]) {
c.fall_distance = 0.0;
let ridden = Some(c.id) == self.riding;
let ridden = self.cart_is_vehicle(c.id);
// `TrackIteration`: the slope, halt and boost speeds each apply once
// per tick, and the iteration keeps moving while distance is left.
let mut first = true;
@@ -1730,6 +1741,8 @@ impl Game<'_> {
let (damage, _, _, magic) = self.melee_damage(self.carts[i].kind.id(), false);
self.hurt_cart(i, damage, self.creative, burning);
self.vehicle_attack_sounds(area, magic.then_some(id));
// Player.onAttack: the hit starts the attack strength again.
self.attack_ticks = 0;
self.swing_arm();
true
}
@@ -113,7 +113,8 @@ fn target_id(t: Target) -> Option<u32> {
| Target::Cart(id)
| Target::Other(id)
| Target::Crystal(id)
| Target::Dragon(id) => Some(id),
| Target::Dragon(id)
| Target::Tnt(id) => Some(id),
Target::Player | Target::Guest(_) => None,
}
}
+174 -1
View File
@@ -292,10 +292,11 @@ use horizon_client::{
extras_hud::Corners,
fast_hash::FxHashMap,
first_person::Hand,
health::{HurtSource, Hurting},
health::{HurtSource, Hurting, Knock},
hud::{HudBatch, rgba},
items::ItemStack,
keybinds,
lan::menu::Slots,
mobs::{Damage, Who},
mod_list::{self, Installed, NativePage},
music::{Music, MusicLayer},
@@ -315,6 +316,7 @@ use horizon_world::{
registry::{BlockRegistry, BlockStateId},
};
use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
sync::Arc,
@@ -2615,6 +2617,41 @@ impl Game<'_> {
) {
let _ = (dimension, entity, kind, pos, loaded_from_disk, canceled);
}
/// Whether the experience orb `into` takes the orb `from` in (vanilla's
/// `ExperienceOrb.canMerge`): the same value, and ids a multiple of 40 apart. Asked
/// for each pair whose boxes meet, when an orb spawns (`tryMergeToExisting`: `from` is
/// 0, the new orb isn't an entity yet, and only `from_value` is known) and every 20
/// ticks as an orb scans for neighbours (`scanForMerges`). `can_merge` holds vanilla's
/// answer; change it either way. A merged orb counts as `count` orbs of `into`'s
/// value, so forcing orbs of different values together drops points unless the mod
/// raises `into`'s value with `native_set_experience_orb` in `native_experience_orb_merge`.
/// Server-side. API @API:experience_orb_merge@.
pub(super) fn native_experience_orb_can_merge(
&mut self,
dimension: &str,
into: u32,
from: u32,
into_value: u32,
from_value: u32,
can_merge: &mut bool,
) {
let _ = (dimension, into, from, into_value, from_value, can_merge);
}
/// The experience orb `into` is about to take `from` in, after
/// `native_experience_orb_can_merge` said yes (`from` is 0 and `from_value` the
/// points of a new orb that is spawning). Set `canceled` to stop it: both orbs stay, and
/// a spawning orb tries the next orb in reach, then spawns on its own. Server-side.
/// API @API:experience_orb_merge@.
pub(super) fn native_experience_orb_merge(
&mut self,
dimension: &str,
into: u32,
from: u32,
from_value: u32,
canceled: &mut bool,
) {
let _ = (dimension, into, from, from_value, canceled);
}
/// `player`'s melee blow killed the mob `entity` of `kind` (other deaths
/// aren't told yet). It only hears, after the blow landed. Server-side.
/// API 40.
@@ -2774,6 +2811,51 @@ impl Game<'_> {
) {
let _ = (entity, kind, damage_type, causing, direct, amount, canceled);
}
/// `LivingEntity.knockback` is about to push entity `entity` of type `kind`
/// (`minecraft:zombie`, `minecraft:player`) `strength` away from the direction (`x`, `z`),
/// after the entity's knockback resistance took its share and before its motion changes
/// (docs/native-mods.md, API @API:living_knockback@). Vanilla's direction points at the
/// source of a hit (the attacker's side), and the entity is pushed the other way. Change
/// `strength`, `x` or `z`, or set `canceled`, and the push follows them: canceled, the
/// entity's motion is left alone. A `strength` of 0 or less after the hook is no push, and a
/// direction too short to tell draws a random one after it, as in vanilla. A value a mod
/// leaves that is not a finite number is put back as it was. `damage_type`
/// (`minecraft:player_attack`), `causing` (`DamageSource.getEntity`) and `direct`
/// (`getDirectEntity`) are the push's `DamageSource`, named as `native_living_incoming_damage`
/// names a hit's, with the same entity ids left at 0. It fires for the players and mobs the
/// game knocks back: a hit's own push, a sprinting or Knockback hit, a sweep, a spear, a
/// shield's push on its holder and a goat's ram. It does not fire for a sulfur cube with a
/// block that a hit with a causing entity knocks back (`SulfurCube.knockback` does not call
/// `super` there), for the pushes vanilla doesn't make with `LivingEntity.knockback` (the
/// ender dragon's wings, Punch arrows, explosions, wind charges, a mace smash's area, a
/// hoglin's throw), nor yet for the ones Horizon makes another way: a mob's melee push from
/// its attack knockback or a Knockback weapon, an armor stand's, the ender dragon's. It runs
/// on the thread that ticks the world (the server thread; the window's in-frame): state
/// shared with render thread hooks needs synchronising. No `self`: the game is in the middle
/// of the hit. API @API:living_knockback@.
pub(super) fn native_living_knockback(
entity: i32,
kind: &str,
damage_type: &str,
causing: i32,
direct: i32,
strength: &mut f64,
x: &mut f64,
z: &mut f64,
canceled: &mut bool,
) {
let _ = (
entity,
kind,
damage_type,
causing,
direct,
strength,
x,
z,
canceled,
);
}
/// `Block.stepOn` for a mod block, on its authoritative server level.
/// No self: the simulation owns the entity during this callback.
@@ -2813,6 +2895,31 @@ impl Game<'_> {
}
}
/// `native_living_knockback` for the knockback code (`health::KNOCKBACK`): whether it
/// canceled the push. Its one caller. The pushed entity and the source are named as the
/// damage hook names them.
pub(super) fn knockback(pushed: Hurting<'_>, knock: &mut Knock) -> bool {
let (entity, kind, source) = hurt_server_args(pushed);
let HurtSource {
damage_type,
causing,
direct,
} = source;
let mut canceled = false;
Game::native_living_knockback(
entity,
kind,
damage_type,
causing,
direct,
&mut knock.strength,
&mut knock.x,
&mut knock.z,
&mut canceled,
);
canceled
}
pub(super) fn block_contact(
dimension: &str,
event: horizon_client::block_contact::Event<'_>,
@@ -2905,6 +3012,34 @@ fn sound_id(id: &str) -> bool {
&& path.chars().all(|c| allowed(c, true))
}
/// Sets the viewer counts `known` holds for `dim` to `now` and returns the
/// changes as `(pos, previous, viewers)`, in position order. A container that
/// is no longer counted goes to 0 and leaves `known`.
pub(super) fn viewer_changes(
known: &mut HashMap<(&'static str, (i32, i32, i32)), u32>,
dim: &'static str,
mut now: HashMap<(i32, i32, i32), u32>,
) -> Vec<((i32, i32, i32), u32, u32)> {
let mut changes = Vec::new();
known.retain(|&(d, pos), previous| {
if d != dim {
return true;
}
let viewers = now.remove(&pos).unwrap_or(0);
if viewers != *previous {
changes.push((pos, *previous, viewers));
*previous = viewers;
}
viewers > 0
});
for (pos, viewers) in now {
known.insert((dim, pos), viewers);
changes.push((pos, 0, viewers));
}
changes.sort_unstable();
changes
}
fn dimension_id(dimension: LevelDimension) -> &'static str {
match dimension {
LevelDimension::Overworld => "minecraft:overworld",
@@ -4311,6 +4446,44 @@ impl Game<'_> {
}
true
}
/// `ContainerOpenersCounter` for the mod containers of the level in place:
/// everyone with one open, recounted every tick, so a change is heard
/// whichever way an opener left (`native_mod_container_viewers`). The
/// openers are the guests (but not a spectator, `BarrelBlockEntity.startOpen`),
/// the host's own player, and the copper golems that opened it
/// (`chest_openers`).
pub(super) fn tick_mod_container_viewers(&mut self) {
let mut now: HashMap<(i32, i32, i32), u32> = HashMap::new();
let mut open = |game: &Self, pos: (i32, i32, i32), n: u32| {
if n > 0 && game.mod_menu_at(pos).is_some() {
*now.entry(pos).or_default() += n;
}
};
if let Some(host) = &self.lan {
for g in host
.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && !g.spectator())
{
if let Some(m) = &g.menu
&& let Slots::Block(pos) = m.slots
{
open(self, pos, 1);
}
}
}
if let Some(pos) = self.container.filter(|_| !self.spectator()) {
open(self, pos, 1);
}
for (&pos, &(golems, _)) in &self.world.chest_openers {
open(self, pos, golems.max(0) as u32);
}
let dim = self.dim.id();
for (pos, previous, viewers) in viewer_changes(&mut self.mod_container_viewers, dim, now) {
self.native_mod_container_viewers(dim, [pos.0, pos.1, pos.2], viewers, previous);
}
}
/// The menu type of the mod container at `key`, if it has one.
pub(super) fn mod_menu_at(&self, key: Pos) -> Option<&'static str> {
let state = self.world.block(key.0, key.1, key.2).0;
@@ -817,6 +817,34 @@ impl Game<'_> {
.filter(|o| orb_meets(o.position, min, max))
.collect()
}
/// Writes the experience orb `id`'s `value` (the points a pickup gives), `count`
/// (orbs merged into it, each worth `value`) and `age` (ticks lived; an orb
/// despawns at 6000); a `None` is left as it is. API @API:experience_orb_merge@.
/// False, and nothing written, for an orb not in the server level `dimension`, a
/// `value` or `count` of 0, and on a client of a server.
pub(super) fn native_set_experience_orb(
&mut self,
dimension: &str,
id: u32,
value: Option<u32>,
count: Option<u32>,
age: Option<u32>,
) -> bool {
if (self.serving.is_none() && self.options.dedicated.is_none())
|| dimension != self.dim.id()
|| value == Some(0)
|| count == Some(0)
{
return false;
}
let Some(orb) = self.orbs.iter_mut().find(|o| o.id == id) else {
return false;
};
orb.value = value.unwrap_or(orb.value);
orb.count = count.unwrap_or(orb.count);
orb.age = age.unwrap_or(orb.age);
true
}
/// Gives `points` of experience to the player `uuid` as an orb they touch
/// does (`ExperienceOrb.playerTouch`): Mending repairs their damaged items
/// with it first (`repairPlayerItems`), then the rest goes to their
@@ -67,7 +67,24 @@ impl Game<'_> {
layers.map(|(name, layer)| (name.as_str(), *layer))
};
let tags = pack.manifest.sprite_tags(sprites());
let materials = pack.manifest.sprite_materials(sprites());
let mut materials = pack.manifest.sprite_materials(sprites());
// `blocks` lists: the sprites their models draw with, where no pattern named them.
let listed = pack.manifest.material_blocks();
if !listed.is_empty() {
let macros = horizon_shaders::Macros::standard();
let properties = horizon_shaders::Properties::parse(&listed, &macros);
let ids = horizon_shaders::BlockIds::parse(&properties);
let table = super::shader_packs::block_id_table(&self.pack, &ids);
for (layer, id) in table.iter().step_by(2).enumerate() {
let layer = layer as u32;
if *id > 0 && !materials.iter().any(|m| m.0 == layer) {
materials.push((layer, *id as u32));
}
}
materials.sort_unstable();
}
let layers = sprites().map(|(_, l)| l as usize + 1).max().unwrap_or(0);
let translucent = pack.manifest.translucent_layers(&materials, layers);
// Each light-giving block state's `[[lights]]` colour, with its level.
let mut lights = horizon_client::gfx::block_light::LightTable::default();
if !pack.manifest.lights.is_empty() {
@@ -83,11 +100,11 @@ impl Game<'_> {
let labels = renderer
.set_native_shader_pack(Some((report, constants, tags, materials, lights)))
.map_err(|e| vec![e])?;
Ok((pack.manifest, labels))
Ok((pack.manifest, labels, translucent))
});
// The resources were loaded without a native pack, so without the resource packs'
// LabPBR maps its material stage reads: load them once, now that one runs.
if result.as_ref().is_ok_and(|(m, _)| m.material.is_some())
if result.as_ref().is_ok_and(|(m, _, _)| m.material.is_some())
&& !horizon_client::sprites::labpbr_maps()
{
horizon_client::sprites::set_labpbr_maps(true);
@@ -96,7 +113,8 @@ impl Game<'_> {
}
let name = path.file_name().unwrap_or_default().to_string_lossy();
match result {
Ok((m, labels)) => {
Ok((m, labels, translucent)) => {
self.set_translucent_layers(translucent);
log::info!(
"shader pack {} {}: {} in {} ms",
m.id,
@@ -0,0 +1,71 @@
//! Column reads for server mods, as helpers on the level being ticked: many columns' heights, and the blocks round
//! them, in one call.
use super::Game;
use super::block_edits::MAX_COLUMNS;
use super::native_world_edit::NativeHeightmap;
/// What `native_read_columns` read: the blocks round each column's height (`bases`, none where it isn't loaded),
/// as indexes into `palette`, a column after another, and three biome tints a column (grass, foliage, water).
#[derive(Clone, Debug, PartialEq, Default)]
pub(crate) struct NativeColumnBlocks {
pub(crate) palette: Vec<String>,
pub(crate) bases: Vec<Option<i32>>,
pub(crate) blocks: Vec<u16>,
pub(crate) tints: Vec<u32>,
}
#[allow(dead_code)]
impl Game<'_> {
/// For a server mod: each column's height by `heightmap` in the authoritative level `dimension` (the one being
/// ticked), none for a column not loaded. `Err` says why nothing was read: another dimension or a client of a
/// server, or more than 65,536 columns (`MAX_COLUMNS`). API @API:native_read_columns@.
pub(super) fn native_surface_heights(
&self,
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
) -> Result<Vec<Option<i32>>, String> {
if !self.native_level_open(dimension) {
return Err(format!("{dimension} is not the level being ticked"));
}
if columns.len() > MAX_COLUMNS {
return Err(format!(
"{} columns, more than {MAX_COLUMNS} a call",
columns.len()
));
}
let started = std::time::Instant::now();
let out = self.surface_heights(columns, heightmap);
self.native_read_logged(started, "height query", columns.len());
Ok(out)
}
/// For a server mod: `below` blocks under and `above` blocks from each column's height by `heightmap`, as a
/// palette and indexes, with the biome's grass, foliage and water tints (air outside the column and in columns
/// not loaded). The tints are the biome's at the column's top, not blended over the biome blend radius as the
/// game draws them: near a biome border they can differ from what players see. `Err` says why nothing was
/// read: another dimension or a client of a server, `below + above` of 0 or more than the level's height, or
/// a read of more than 1,048,576 blocks (`MAX_READ`; it runs on the server tick, so read big areas in pieces).
/// API @API:native_read_columns@.
pub(super) fn native_read_columns(
&self,
dimension: &str,
columns: &[(i32, i32)],
heightmap: NativeHeightmap,
(below, above): (u32, u32),
) -> Result<NativeColumnBlocks, String> {
if !self.native_level_open(dimension) {
return Err(format!("{dimension} is not the level being ticked"));
}
let started = std::time::Instant::now();
let out = self.read_columns(columns, heightmap, below, above)?;
self.native_read_logged(started, "column read", columns.len());
Ok(out)
}
/// A read's time logged with `HORIZON_SPIKES`, as an edit's is.
fn native_read_logged(&self, started: std::time::Instant, what: &str, columns: usize) {
let ms = started.elapsed().as_secs_f32() * 1000.0;
if self.options.spikes.is_some_and(|threshold| ms > threshold) {
log::info!("Horizon: slow mod {what} {ms:.1} ms ({columns} columns)");
}
}
}
@@ -0,0 +1,68 @@
//! Bulk block edits for server mods: a helper on the level being ticked, as `native_set_block` is, that makes many
//! runs of blocks up columns at once, straight into the chunks.
use super::Game;
/// Which of a column's heights (`Heightmap.Types`) a run's `y` counts from.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum NativeHeightmap {
WorldSurface,
MotionBlocking,
MotionBlockingNoLeaves,
OceanFloor,
}
/// A run: `height` blocks up a column from `y` (from the column's `surface` height when one is
/// given), set to palette entry `block`, only where filter `only` matches when one is given.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct NativeBlockRun {
pub(crate) x: i32,
pub(crate) z: i32,
pub(crate) y: i32,
pub(crate) height: u32,
pub(crate) surface: Option<NativeHeightmap>,
pub(crate) block: u32,
pub(crate) only: Option<u32>,
}
/// A bulk edit: block states and block predicates as commands write them (without NBT), and the runs naming them
/// by index.
#[derive(Clone, Debug, PartialEq, Default)]
pub(crate) struct NativeBlockEdit {
pub(crate) palette: Vec<String>,
pub(crate) filters: Vec<String>,
pub(crate) runs: Vec<NativeBlockRun>,
}
#[allow(dead_code)]
impl Game<'_> {
/// For a server mod: the runs made at once, straight into the chunks of the authoritative level `dimension`
/// (the one being ticked, as for `native_set_block`), with what `fill ... strict` does for block entities (a
/// block replaced by another loses its block entity, and a new block gets the one `native_set_block` would
/// give it, a mod block's included); no neighbour is told, and the chunks changed go to the players whole.
/// Runs in chunks not loaded, and the parts of runs outside the column, are left out. The blocks changed, or
/// why the edit was left out, with nothing changed: another dimension, a client of a server, a palette entry
/// or filter that doesn't read or carries NBT (refused), a run naming a palette entry or filter past the end,
/// or more than 65536 filters. API @API:native_edit_blocks@.
pub(super) fn native_edit_blocks(
&mut self,
dimension: &str,
edit: &NativeBlockEdit,
) -> Result<u64, String> {
if !self.native_level_open(dimension) {
return Err(format!("{dimension} is not the level being ticked"));
}
let started = std::time::Instant::now();
let changed = self.edit_blocks(edit)?;
let ms = started.elapsed().as_secs_f32() * 1000.0;
if self.options.spikes.is_some_and(|threshold| ms > threshold) {
log::info!(
"Horizon: slow mod block edit {ms:.1} ms ({} runs, {changed} blocks)",
edit.runs.len()
);
}
Ok(changed)
}
/// Whether `dimension` is the authoritative level being ticked: where a server mod's world helpers act.
pub(super) fn native_level_open(&self, dimension: &str) -> bool {
(self.serving.is_some() || self.options.dedicated.is_some()) && dimension == self.dim.id()
}
}
+110 -3
View File
@@ -108,6 +108,14 @@ fn rotate_y(v: Vec3, degrees: f32) -> Vec3 {
);
Vec3::new(v.x * cos + v.z * sin, v.y, v.z * cos - v.x * sin)
}
/// Whether a projectile is an arrow, which goes through a portal like the
/// other entities.
fn is_arrow(p: &horizon_client::projectiles::Projectile) -> bool {
matches!(
p.kind,
horizon_client::projectiles::ProjectileKind::Arrow { .. }
)
}
fn doubles(values: [f64; 3]) -> Tag {
Tag::List(values.iter().map(|v| Tag::Double(v.to_bits())).collect())
}
@@ -657,6 +665,9 @@ impl Game<'_> {
for c in &self.carts {
self.portals.before.insert(c.id, c.position);
}
for t in &self.tnts {
self.portals.before.insert(t.id, t.position);
}
// Only an entity in a portal, or cooling down from one, has a state
// here: whether the others could use a portal is not worth asking
// for each of them on every tick.
@@ -673,12 +684,22 @@ impl Game<'_> {
ids.push((m.id, self.mob_can_use_portal(m)));
}
}
for t in &self.tnts {
if tracked.contains_key(&t.id) {
ids.push((t.id, self.tnt_can_use_portal(t.id)));
}
}
let vehicles = self.boats.iter().map(|b| b.id);
for id in vehicles.chain(self.carts.iter().map(|c| c.id)) {
if tracked.contains_key(&id) {
ids.push((id, self.vehicle_can_use_portal(id)));
}
}
for p in &self.creatures.projectiles {
if tracked.contains_key(&p.id) && is_arrow(p) {
ids.push((p.id, true));
}
}
}
let present: std::collections::HashSet<u32> = ids.iter().map(|(id, _)| *id).collect();
self.portals.entities.retain(|id, _| present.contains(id));
@@ -752,6 +773,11 @@ impl Game<'_> {
}
/// `canUsePortal(false)` of a boat or minecart: empty, or ridden by
/// server players only, whom it carries along.
/// `Entity.canUsePortal(false)` for a primed TNT: it carries no mob.
fn tnt_can_use_portal(&self, id: u32) -> bool {
// Id 0: not tracked yet (the in-frame path), nothing to key its portal state by.
id != 0 && !self.mobs.iter().any(|m| m.extra.mount == Some(id))
}
fn vehicle_can_use_portal(&self, id: u32) -> bool {
if !self.vehicle_occupied(id) {
return true;
@@ -799,8 +825,8 @@ impl Game<'_> {
|| self.mobs.iter().any(|m| m.extra.mount == Some(id))
}
/// `NetherPortalBlock.entityInside` and `EndPortalBlock.entityInside`
/// for the items, mobs and empty boats and minecarts that moved this
/// tick: the portal blocks their box swept through (an End portal's
/// for the items, mobs, empty boats and minecarts, and arrows that moved
/// this tick: the portal blocks their box swept through (an End portal's
/// transition time is 0 too).
pub(super) fn find_entity_portals(&mut self) {
if self.remote.is_some() {
@@ -812,6 +838,13 @@ impl Game<'_> {
.iter()
.map(|e| (e.id, e.position, 0.25, 0.25))
.collect();
// PrimedTnt is 0.98 across; one that carries a mob stays (the rider would be left behind).
let tnts: Vec<(u32, Vec3, f32, f32)> = self
.tnts
.iter()
.filter(|t| self.tnt_can_use_portal(t.id))
.map(|t| (t.id, t.position, 0.98, 0.98))
.collect();
let mobs: Vec<(u32, Vec3, f32, f32)> = self
.mobs
.iter()
@@ -834,12 +867,31 @@ impl Game<'_> {
.map(|(id, at, w, h)| (id, at, w as f32, h as f32))
.collect();
let mut portal_sections = std::mem::take(&mut self.portals.sections);
for (id, to, width, height) in items.into_iter().chain(mobs).chain(vehicles) {
for (id, to, width, height) in items.into_iter().chain(tnts).chain(mobs).chain(vehicles) {
let from = self.portals.before.get(&id).copied().unwrap_or(to);
if let Some(pos) = self.swept_portal(from, to, width, height, &mut portal_sections) {
inside.push((id, pos));
}
}
// The arrow's `previous` is where it stood at the start of its tick,
// which is also where an arrow shot this tick began. A stuck arrow
// that did not move this tick enters no block.
for p in self
.creatures
.projectiles
.iter()
.filter(|p| is_arrow(p) && (!p.stuck || p.previous != p.position))
{
let (width, height) = (
horizon_client::arrows::WIDTH,
horizon_client::arrows::HEIGHT,
);
if let Some(pos) =
self.swept_portal(p.previous, p.position, width, height, &mut portal_sections)
{
inside.push((p.id, pos));
}
}
self.portals.sections = portal_sections;
for (id, pos) in inside {
self.portals
@@ -957,6 +1009,26 @@ impl Game<'_> {
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.tnts.iter().position(|t| t.id == id) {
let t = &self.tnts[i];
let plan = self.entity_plan(entry, t.position, 0.98, 0.98, false);
if !self.may_enter(plan.dim) {
return;
}
let uuid = self.uuid_for(id);
let t = &self.tnts[i];
let tag = horizon_client::entity_nbt::tnt_to_nbt(
t.position,
t.velocity,
t.on_ground,
t.fuse,
uuid,
self.persist_raw(id),
);
self.tnts.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
let m = &self.mobs[i];
let plan = self.entity_plan(entry, m.position, m.width(), m.height(), true);
@@ -1026,6 +1098,41 @@ impl Game<'_> {
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self
.creatures
.projectiles
.iter()
.position(|p| p.id == id && is_arrow(p))
{
let p = &self.creatures.projectiles[i];
let (width, height) = (
horizon_client::arrows::WIDTH,
horizon_client::arrows::HEIGHT,
);
let plan = self.entity_plan(entry, p.position, width, height, false);
if !self.may_enter(plan.dim) {
return;
}
let uuid = self.uuid_for(id);
let p = &self.creatures.projectiles[i];
let in_block = p
.arrow
.as_ref()
.and_then(|a| a.last_state)
.map(|s| self.state_to_nbt(s));
let Some(tag) = horizon_client::entity_nbt::arrow_to_nbt(
p,
uuid,
p.owner_uuid,
in_block,
self.persist_raw(id),
) else {
return;
};
self.creatures.projectiles.remove(i);
self.forget_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
}
}
/// The chunks of the arrivals still waiting to land in `dim`, for the
@@ -471,6 +471,9 @@ impl Game<'_> {
s.horizon.taa,
s.horizon.upscaling.ratio(),
);
r.set_frame_generation(
s.horizon.frame_generation != horizon_client::graphics::FrameGeneration::Off,
);
r.set_hdr(
s.horizon.hdr,
s.horizon.paper_white_nits() as f32,
@@ -373,6 +373,11 @@ impl Game<'_> {
a.last_state = in_block.and_then(|t| self.state_from_nbt(&t));
}
self.claim_owner(&mut p);
// An arrow that came through a portal keeps its
// cooldown: it does not go straight back through.
self.next_entity += 1;
p.id = self.next_entity;
self.restore_portal_cooldown(p.id, &tag);
self.add_projectile(p);
}
}
@@ -453,6 +458,8 @@ impl Game<'_> {
owner: None,
guest: None,
});
// `Entity.load`: the `PortalCooldown` of one that just came through.
self.remember(id, uuid, tag);
for rider in riders {
self.load_mob(rider, Some(id));
}
+1 -6
View File
@@ -50,12 +50,7 @@ impl Game<'_> {
// hears it, and the replay follows, but nothing counts.
let waiting = g.awaiting_teleport.is_some();
// The server's copy of a guest doesn't follow its armor (Game.glider_worn).
let glider = g.armor[1].as_ref().is_some_and(|s| {
self.res
.items()
.get(&s.item)
.is_some_and(|i| i.glider && (i.max_damage == 0 || s.damage + 1 < i.max_damage))
});
let glider = super::gliding_pieces(self.res.items(), &g.armor).contains(&true);
if let Some(motion) = g.motion.as_deref_mut() {
motion.glider = glider;
motion.item_use_speed = item_use.map(|(speed, _)| speed);
+27 -13
View File
@@ -5484,19 +5484,21 @@ impl Game<'_> {
self.tick_breeze_effects();
}
self.tick_remote_guardian_eyes(running);
// ClientLevel.tickBlockEntities: chest lids, shulker boxes, bells,
// and the moving pistons a block event's client-side moveBlocks made
// (their progress advances between the server's block updates).
self.tick_block_entity_anims();
self.tick_remote_spawners();
self.client_tick_beacons();
self.tick_remote_potent_sulfur();
self.with_level(|b, level, ticks| {
b.client_tick_block_entities(level, ticks);
});
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
self.push_piston_entities(pushes);
self.sync_moving_pistons();
// Level.tickBlockEntities: a frozen level ticks no block entities.
// A step advances the moving pistons created by the block event once,
// rather than finishing them during the frozen ticks between steps.
if running {
self.tick_block_entity_anims();
self.tick_remote_spawners();
self.client_tick_beacons();
self.tick_remote_potent_sulfur();
self.with_level(|b, level, ticks| {
b.client_tick_block_entities(level, ticks);
});
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
self.push_piston_entities(pushes);
self.sync_moving_pistons();
}
self.tick_hand();
if running {
self.tick_particles();
@@ -7029,6 +7031,18 @@ impl Game<'_> {
} else {
(result, changes)
};
// AbstractCauldronBlock.useItemOn, either hand: CauldronInteractions
// answers on the client as on the server and only the server acts,
// so a bucket the cauldron takes is not used again (UseItem).
let result = if result.consumes_action() || spectator {
result
} else {
match self.cauldron_outcome(hit.pos, held.as_ref()) {
horizon_client::cauldron::Outcome::Success => InteractionResult::Success,
horizon_client::cauldron::Outcome::Consume => InteractionResult::Consume,
horizon_client::cauldron::Outcome::Pass => result,
}
};
let reacts = result.consumes_action();
// useItemOn on this client, unless a sneak with something in hand
// skips it: RedStoneOreBlock's dust, and the wick smoke of a lit
@@ -282,7 +282,14 @@ impl Game<'_> {
}
};
let (x, z) = (p.x, p.z);
self.streaming.insert_remote(&mut self.world, chunk);
// A column sent again while it is resident (a bulk edit): only what changed is lit and meshed again.
if self.world.chunks.contains_key(&(x, z)) {
horizon_client::spans::time("replace_remote", || {
self.streaming.replace_remote(&mut self.world, chunk)
});
} else {
self.streaming.insert_remote(&mut self.world, chunk);
}
self.world
.block_entities
.retain(|k, _| (k.0 >> 4, k.2 >> 4) != (x, z));
@@ -161,6 +161,17 @@ pub(super) fn remote_projectile_tick(p: &mut Projectile, w: &impl ProjectileWorl
}
}
/// `Entity.getRandomX(0.5)` / `getRandomY` / `getRandomZ(0.5)` for a shulker
/// bullet (`EntityType.SHULKER_BULLET` is 0.3125 x 0.3125).
fn shulker_bullet_trail_at(at: Vec3, random: &mut impl RandomSource) -> [f64; 3] {
const SIZE: f64 = 0.3125;
[
at.x + SIZE * (2.0 * random.next_double() - 1.0) * 0.5,
at.y + SIZE * random.next_double(),
at.z + SIZE * (2.0 * random.next_double() - 1.0) * 0.5,
]
}
/// `ClientPacketListener.handleProjectilePowerPacket`: the power a hurting
/// projectile (`AbstractHurtingProjectile`) accelerates by, found by id;
/// anything else is ignored.
@@ -268,6 +279,25 @@ impl Game<'_> {
}
}
/// `ShulkerBullet.tick` on a client: two `END_ROD` particles in its
/// 0.3125 box (`getRandomX(0.5)`, `getRandomY`, `getRandomZ(0.5)`).
pub(super) fn shulker_bullet_trails(&mut self) {
let bullets: Vec<Vec3> = self
.creatures
.projectiles
.iter()
.filter(|p| matches!(p.kind, ProjectileKind::ShulkerBullet { .. }))
.map(|p| p.position)
.collect();
let options = ParticleOptions::simple(ParticleType::EndRod);
for at in bullets {
for _ in 0..2 {
let p = shulker_bullet_trail_at(at, self.particles.random());
self.particles.add(&options, p, [0.0; 3]);
}
}
}
/// `handleAddEntity` for these types; false when `kind` isn't one.
pub(super) fn spawn_remote_projectile(
&mut self,
@@ -615,6 +645,7 @@ impl Game<'_> {
.map(|p| p.position)
.collect();
self.smoke_trails(trails);
self.shulker_bullet_trails();
// FireworkRocketEntity.tick: a rocket attached to an entity follows it
// and boosts it client-side too (attachedToEntity.isFallFlying), and
// every rocket leaves its spark trail on a client.
@@ -751,7 +782,13 @@ impl Game<'_> {
out.push(json!({"id": t.id, "kind": "minecraft:tnt", "position": at(t.position), "fuse": t.fuse}));
}
for f in &self.falling {
out.push(json!({"id": f.id, "kind": "minecraft:falling_block", "position": at(f.position), "state": f.state.0}));
out.push(json!({"id": f.id, "kind": "minecraft:falling_block", "position": at(f.position), "velocity": at(f.velocity), "state": f.state.0}));
}
for b in &self.boats {
out.push(json!({"id": b.id, "kind": "minecraft:boat", "position": at(b.position), "velocity": at(b.velocity)}));
}
for c in &self.carts {
out.push(json!({"id": c.id, "kind": "minecraft:minecart", "position": at(c.position), "velocity": at(c.velocity)}));
}
for c in &self.crystals {
out.push(json!({"id": c.id, "kind": "minecraft:end_crystal", "position": at(c.position), "show_bottom": c.show_bottom}));
@@ -808,6 +845,18 @@ mod tests {
Projectile { id, ..thrown(kind) }
}
#[test]
fn a_shulker_bullet_trail_stays_inside_its_box() {
let at = Vec3::new(10.0, 20.0, 30.0);
let mut random = horizon_core::random::LegacyRandom::new(1);
for _ in 0..32 {
let [x, y, z] = shulker_bullet_trail_at(at, &mut random);
assert!((x - at.x).abs() <= 0.3125 * 0.5 + 1e-12, "{x}");
assert!((0.0..0.3125 + 1e-12).contains(&(y - at.y)), "{y}");
assert!((z - at.z).abs() <= 0.3125 * 0.5 + 1e-12, "{z}");
}
}
#[test]
fn a_thrown_item_flies_on_between_the_server_updates() {
// A thrown egg, an ender pearl and a wind charge (#1762): the server
@@ -322,6 +322,10 @@ impl Game<'_> {
for i in 0..self.boats.len() {
let driven = self.riding == Some(self.boats[i].id);
if !driven {
// `AbstractBoat.tick`: the non-authoritative copy carries no
// cached motion into the next time this player drives it.
self.boats[i].velocity = Vec3::ZERO;
self.boats[i].delta_rotation = 0.0;
self.refresh_boat_status(i);
}
let touches = self.boat_bubble_columns(i);
@@ -179,6 +179,7 @@ impl Game<'_> {
| Target::Cart(id)
| Target::Crystal(id)
| Target::Dragon(id)
| Target::Tnt(id)
| Target::Other(id) => Some(uuid_text(self.uuid_for(id))),
// Orbs and projectiles have no UUID here.
Target::Orb(_) | Target::Projectile(_) => None,
@@ -211,6 +212,7 @@ impl Game<'_> {
| Target::Painting(id)
| Target::Boat(id)
| Target::Cart(id)
| Target::Tnt(id)
| Target::Other(id) => self.persist.raw.get(&id),
Target::Player => self.level.player_extra.as_ref(),
Target::Guest(_)
+1 -1
View File
@@ -325,7 +325,7 @@ impl Game<'_> {
batch.sprite_scaled(atlas, sprite, x + 3.0, y + 3.0, 15.0, 15.0, u32::MAX);
if hover {
tooltip = Some(if i == FRIENDS_ICON {
Text::literal(horizon_client::friends_screen::TITLE)
Text::literal(horizon_client::friends_screen::title())
} else {
Text::translate(ICON_LABELS[i], &[])
});
@@ -158,10 +158,11 @@ impl Game<'_> {
self.streaming.shader_pack_lighting = pack_lighting;
self.all_sections_changed();
}
let picked = picked_pack(&self.settings);
let picked = picked_pack(&self.settings).to_owned();
if self.native_shaders.picked != picked {
// Another pack, or none: drop what ran.
self.native_shaders = super::native_shaders::NativeShaders::picking(picked);
self.native_shaders = super::native_shaders::NativeShaders::picking(&picked);
self.set_translucent_layers(Vec::new());
self.pack_ids = None;
if let Some(renderer) = &mut self.renderer {
renderer.drop_shader_packs();
@@ -170,7 +171,7 @@ impl Game<'_> {
if picked.is_empty() {
return PackInputs::default();
}
let name = picked.to_owned();
let name = picked;
let dimension = match self.dim {
Dim::Overworld => 0,
Dim::Nether => -1,
@@ -533,7 +534,7 @@ fn name_id(name: &str) -> i32 {
/// their sprite layer, not their block. A layer takes the id of the block named like its sprite
/// (`block/oak_leaves` for `oak_leaves`), else the id most blocks drawing it share (ties: the
/// lowest); -1 when no block drawing it has one. y is 1 for the fluid sprites, -1 otherwise.
fn block_id_table(pack: &super::PackResources, ids: &BlockIds) -> Vec<i32> {
pub(super) fn block_id_table(pack: &super::PackResources, ids: &BlockIds) -> Vec<i32> {
use std::collections::{BTreeMap, BTreeSet};
let layers = pack
.render
+15 -24
View File
@@ -5,7 +5,7 @@
//! the shield (`Player.blockUsingItem`, `BlocksAttacks.disable`).
use super::Game;
use horizon_client::{
health::{BlockOutcome, Blocking},
health::{BlockOutcome, Blocking, HurtSource},
items::ItemStack,
};
use horizon_core::random::RandomSource;
@@ -114,12 +114,12 @@ impl Game<'_> {
})
}
/// After a hurt with the shield raised: `hurtBlockingItem`, `onBlocked`
/// and, for melee from a mob, `blockUsingItem`.
/// and, for melee from a mob, `blockUsingItem`. `source` is the hit's.
pub(super) fn after_block(
&mut self,
outcome: BlockOutcome,
attacker: Option<u32>,
projectile: bool,
(attacker, projectile): (Option<u32>, bool),
source: HurtSource<'_>,
) {
let Some((slot, _)) = self.using else { return };
let Some(stack) = self.stack_in(slot).cloned() else {
@@ -185,7 +185,8 @@ impl Game<'_> {
// Another player's melee hit has no mob behind it.
if let Some((from, disable)) = self.combat.player_attacker {
let fully = outcome.fully_blocked;
self.blocked_by_attacker(&component, [from[0], from[2]], fully, disable, at);
let attacker = ([from[0], from[2]], disable);
self.blocked_by_attacker(&component, attacker, fully, at, source);
}
return;
};
@@ -231,36 +232,26 @@ impl Game<'_> {
let _ = self.handle_mob_events(events);
return;
}
self.blocked_by_attacker(&component, [mx, mz], fully, disable, at);
self.blocked_by_attacker(&component, ([mx, mz], disable), fully, at, source);
}
/// What a blocked melee hit does to the defender once the shield took
/// it: `LivingEntity.blockedByItem` knocks them back unless fully
/// blocked, then `Player.blockUsingItem` disables the shield.
/// blocked, then `Player.blockUsingItem` disables the shield. `attacker`
/// is where the attacker stands (x, z) and its seconds of disabling.
fn blocked_by_attacker(
&mut self,
component: &horizon_client::items::BlocksAttacks,
attacker: [f64; 2],
([mx, mz], disable): ([f64; 2], f32),
fully: bool,
disable: f32,
at: Option<[f64; 3]>,
source: HurtSource<'_>,
) {
let [mx, mz] = attacker;
// LivingEntity.blockedByItem: defender.knockback(0.5, defender - attacker),
// which moves the defender along -(xd, zd).
if !fully && let Some(p) = self.player.as_mut() {
// LivingEntity.blockedByItem: defender.knockback(0.5, defender - attacker,
// source), which moves the defender along -(xd, zd).
if !fully && let Some(p) = self.player.as_ref() {
let (xd, zd) = (p.position.x - mx, p.position.z - mz);
let len = (xd * xd + zd * zd).sqrt();
if len >= 1.0e-5 {
let (dx, dz) = (xd / len * 0.5, zd / len * 0.5);
let v = p.velocity;
let y = if p.ground.on_ground {
(v.y / 2.0 + 0.5).min(0.4)
} else {
v.y
};
p.velocity = horizon_core::geometry::Vec3::new(v.x / 2.0 - dx, y, v.z / 2.0 - dz);
}
self.knock_back_player(0.5, xd, zd, source);
}
// BlocksAttacks.disable: item cooldown and stopUsingItem.
let ticks = disable * component.disable_scale;
+3 -2
View File
@@ -227,9 +227,10 @@ impl Game<'_> {
// causeExtraKnockback: a push of 0.4, then the weapon's Knockback.
if deals_knockback {
let (xd, zd) = self.extra_knockback_direction();
self.mobs[index].knockback(0.4, xd, zd);
let source = Damage::spear(from);
self.mobs[index].knockback(0.4, xd, zd, &source);
let (strength, _) = self.extra_knockback(&kind, 0.0);
self.mobs[index].knockback(f64::from(strength), xd, zd);
self.mobs[index].knockback(f64::from(strength), xd, zd, &source);
// ...and the attacker stops sprinting.
self.stop_sprint_after_extra_knockback();
}
+83 -2
View File
@@ -70,6 +70,15 @@ 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(
world,
assets.registry,
facts,
collision,
self.bounds(),
&mut self.velocity,
);
self.velocity = Vec3::new(
self.velocity.x * 0.98,
self.velocity.y * 0.98,
@@ -158,8 +167,10 @@ impl Game<'_> {
f64::from(pos.y),
f64::from(pos.z) + 0.5,
);
// An id from the entity counter at once, so a command can name it.
self.next_entity += 1;
self.tnts.push(PrimedTnt {
id: 0,
id: self.next_entity,
position,
previous: position,
velocity: Vec3::new(-rot.sin() * 0.02, f64::from(0.2f32), -rot.cos() * 0.02),
@@ -613,7 +624,7 @@ mod tests {
}
world.set(20, 4, 20, water).unwrap();
world.set(21, 4, 20, flowing).unwrap();
let at = Vec3::new(20.5, 4.0, 20.5);
let at = Vec3::new(20.5, 6.0, 20.5);
let mut tnt = PrimedTnt {
id: 0,
position: at,
@@ -672,4 +683,74 @@ mod tests {
"TNT sinks; water does not add buoyancy"
);
}
#[test]
fn a_soul_sand_bubble_column_lifts_primed_tnt() {
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 column = assets
.registry
.default_state("minecraft:bubble_column")
.unwrap();
for y in 3..=8 {
world.set(20, y, 20, column).unwrap();
}
world.set(20, 2, 20, soul_sand).unwrap();
world.set(20, 9, 20, water).unwrap();
// BubbleColumnBlock.entityInside: the columns its box touches lift it
// (Entity.handleOnInsideBubbleColumn / handleOnAboveBubbleColumn).
let at = Vec3::new(20.5, 6.0, 20.5);
let mut tnt = PrimedTnt {
id: 0,
position: at,
previous: at,
velocity: Vec3::ZERO,
fuse: 80,
on_ground: false,
owner: None,
guest: None,
};
for _ in 0..10 {
tnt.tick(&world, &collision, &assets, &facts).unwrap();
}
assert!(
tnt.velocity.y > 0.0,
"soul sand lifts primed TNT, got {:?}",
tnt.velocity
);
assert!(
tnt.position.y > at.y,
"the column carries it up, got {}",
tnt.position.y
);
// Without the column the same TNT only falls.
for y in 3..=8 {
world.set(20, y, 20, water).unwrap();
}
tnt.position = at;
tnt.velocity = Vec3::ZERO;
tnt.on_ground = false;
for _ in 0..4 {
tnt.tick(&world, &collision, &assets, &facts).unwrap();
}
assert!(
tnt.position.y < at.y,
"water alone does not lift it, got {}",
tnt.position.y
);
}
}
+99
View File
@@ -361,6 +361,105 @@ impl Game<'_> {
push(t.position, &mut t.velocity, self);
}
self.tnts = tnts;
// The rest of the entities in reach take `Entity.pushFromExplosion`
// alike: their distance is from their feet, their direction from their
// eyes (0.85 of the box height unless a type says otherwise).
let push_box =
|game: &Game<'_>, feet: Vec3, lo: [f64; 3], hi: [f64; 3], eye: f64, v: &mut Vec3| {
let feet = [feet.x, feet.y, feet.z];
let dist = distance(feet) / diameter;
if dist > 1.0 {
return;
}
let exposure = game.seen_percent(center, lo, hi);
let power = (1.0 - dist) * f64::from(exposure) * multiplier;
let dir = direction([feet[0], feet[1] + eye, feet[2]]);
*v = Vec3::new(
v.x + dir[0] * power,
v.y + dir[1] * power,
v.z + dir[2] * power,
);
};
// ItemEntity.ignoreExplosion: items feel only a burst that affects
// block-like entities (a mace's, never a wind charge's).
if blocklike {
let mut items = std::mem::take(&mut self.dropped);
for item in &mut items {
let p = item.position;
let half = 0.125;
push_box(
self,
p,
[p.x - half, p.y, p.z - half],
[p.x + half, p.y + 0.25, p.z + half],
0.2125,
&mut item.velocity,
);
}
self.dropped = items;
}
let mut orbs = std::mem::take(&mut self.orbs);
for o in &mut orbs {
let p = o.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.5f32 * 0.85f32),
&mut o.velocity,
);
}
self.orbs = orbs;
let mut falling = std::mem::take(&mut self.falling);
for f in &mut falling {
let b = horizon_client::falling_block::bounds(f.position);
let (lo, hi) = (b.min_position(), b.max_position());
let eye = f64::from((hi.y - lo.y) as f32 * 0.85f32);
push_box(
self,
f.position,
[lo.x, lo.y, lo.z],
[hi.x, hi.y, hi.z],
eye,
&mut f.velocity,
);
}
self.falling = falling;
// 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")) {
let mut boats = std::mem::take(&mut self.boats);
for b in &mut boats {
let bounds = b.bounds();
let (lo, hi) = (bounds.min_position(), bounds.max_position());
// EntityType.OAK_BOAT & co: eyeHeight is the full 0.5625.
push_box(
self,
b.position,
[lo.x, lo.y, lo.z],
[hi.x, hi.y, hi.z],
0.5625,
&mut b.velocity,
);
}
self.boats = boats;
let mut carts = std::mem::take(&mut self.carts);
for c in &mut carts {
let bounds = c.bounds();
let (lo, hi) = (bounds.min_position(), bounds.max_position());
let eye = f64::from((hi.y - lo.y) as f32 * 0.85f32);
push_box(
self,
c.position,
[lo.x, lo.y, lo.z],
[hi.x, hi.y, hi.z],
eye,
&mut c.velocity,
);
}
self.carts = carts;
}
pushed
}
/// `ServerExplosion.hurtEntities` for the player (not a spectator, nor a
+151 -1
View File
@@ -35,7 +35,9 @@ pub struct HurtSource<'a> {
pub causing: i32,
pub direct: i32,
}
/// What `LivingEntity.hurtServer` is about to hurt, for [`HURT_SERVER`].
/// What `LivingEntity.hurtServer` is about to hurt, for [`HURT_SERVER`], or
/// `LivingEntity.knockback` to push, for [`KNOCKBACK`]: the entity and the
/// hit's source.
pub enum Hurting<'a> {
/// A player, by its entity id.
Player(i32, HurtSource<'a>),
@@ -52,6 +54,38 @@ pub static HURT_SERVER: OnceLock<fn(Hurting<'_>, &mut f32) -> bool> = OnceLock::
pub fn hurt_server_canceled(hurting: Hurting<'_>, amount: &mut f32) -> bool {
HURT_SERVER.get().is_some_and(|hook| hook(hurting, amount))
}
/// A knockback's strength and direction, as `LivingEntity.knockback` has them
/// once the knockback resistance is applied. The entity is pushed away from
/// the direction (`x`, `z`), which vanilla points at the source of the hit.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Knock {
pub strength: f64,
pub x: f64,
pub z: f64,
}
/// The game's native knockback hook (`native_living_knockback`), set once by
/// the game: `LivingEntity.knockback`, after the knockback resistance and
/// before the motion changes. It may change the [`Knock`]; true cancels it.
pub static KNOCKBACK: OnceLock<fn(Hurting<'_>, &mut Knock) -> bool> = OnceLock::new();
/// Whether [`KNOCKBACK`] canceled the knockback (never while it is unset). A
/// value it leaves that is not a finite number is put back as it was.
pub fn knockback_canceled(pushed: Hurting<'_>, knock: &mut Knock) -> bool {
let Some(hook) = KNOCKBACK.get() else {
return false;
};
let before = *knock;
let canceled = hook(pushed, knock);
for (now, was) in [
(&mut knock.strength, before.strength),
(&mut knock.x, before.x),
(&mut knock.z, before.z),
] {
if !now.is_finite() {
*now = was;
}
}
canceled
}
pub struct HealthResources {
pub damage: Catalog,
pub ids: PlayerFloatIds,
@@ -1127,6 +1161,122 @@ mod tests {
assert_eq!(seen(9013), [("minecraft:arrow 44".to_owned(), 5.0)]);
}
/// What the knockback hook of these tests saw: the entity, the hit that
/// causes it (a mob's damage kind) and the push it was handed.
static PUSHES: std::sync::Mutex<Vec<(i32, String, super::Knock)>> =
std::sync::Mutex::new(Vec::new());
/// Sets the native knockback hook of these tests (global: it stays set for
/// the process), for mobs 9101 to 9106 only: it records the push, cancels
/// 9101's, doubles 9102's strength and turns its direction around, leaves
/// 9103 non-finite numbers, and gives 9105 (whose resistance leaves
/// nothing) a strength of 0.4.
fn set_knockback_hook() -> fn(i32) -> Vec<(String, super::Knock)> {
use super::{Hurting, Knock};
fn hook(pushed: Hurting<'_>, knock: &mut Knock) -> bool {
let Hurting::Mob(mob, source) = pushed else {
return false;
};
let entity = mob.id as i32;
if !(9101..=9106).contains(&entity) {
return false;
}
let hit = format!("{:?}", source.kind);
PUSHES.lock().unwrap().push((entity, hit, *knock));
match entity {
9101 => return true,
9102 => {
knock.strength *= 2.0;
knock.x = -knock.x;
}
9103 => {
knock.strength = f64::NAN;
knock.x = f64::INFINITY;
knock.z = f64::NEG_INFINITY;
}
9105 => knock.strength = 0.4,
_ => {}
}
false
}
let installed = *super::KNOCKBACK.get_or_init(|| hook);
assert!(std::ptr::fn_addr_eq(
installed,
hook as fn(Hurting<'_>, &mut Knock) -> bool
));
|entity| {
let seen = PUSHES.lock().unwrap();
let pushes = seen.iter().filter(|p| p.0 == entity);
pushes.map(|p| (p.1.clone(), p.2)).collect()
}
}
/// `LivingEntity.knockback`: the hook runs once the knockback resistance
/// took its share and before the motion changes; a mod's strength or
/// direction is what pushes, a cancel pushes nothing, and with no mod in
/// the way the push is vanilla's. Expected values are worked out by hand
/// from vanilla's formula: the horizontal speed halves and the push is
/// subtracted along the normalized direction; on the ground the vertical
/// speed becomes `min(0.4, vy / 2 + strength)`.
#[test]
fn the_native_knockback_hook_changes_or_cancels_a_mobs_push() {
use super::Knock;
use crate::mobs::{Damage, Mob};
let seen = set_knockback_hook();
let mob = |id, resistance| {
let mut m = Mob::new(
id,
crate::mobs::sensor_scan_tests::kind(),
Default::default(),
0.0,
);
m.on_ground = true;
m.attrs.knockback_resistance = resistance;
m
};
let velocity = |m: &Mob| (m.velocity.x, m.velocity.y, m.velocity.z);
let hit = Damage::player([3.0, 0.0, 0.0]);
let push = |id, resistance| {
let mut m = mob(id, resistance);
m.knockback(0.4, 3.0, 0.0, &hit);
m
};
// No mod in the way (the hook leaves 9100 alone): vanilla's push, away from x = 3.
let plain = push(9100, 0.0);
assert_eq!(velocity(&plain), (-0.4, 0.4, 0.0));
assert!(plain.needs_sync);
// Canceled: no motion, no resync. The hook saw the push the game made.
let kept = push(9101, 0.0);
assert_eq!(velocity(&kept), (0.0, 0.0, 0.0));
assert!(!kept.needs_sync);
let made = Knock {
strength: 0.4,
x: 3.0,
z: 0.0,
};
assert_eq!(seen(9101), [("PlayerAttack".to_owned(), made)]);
// A stronger push the other way: twice the strength, x turned around.
let turned = push(9102, 0.0);
assert_eq!(velocity(&turned), (0.8, 0.4, 0.0));
// Numbers that are not finite are put back as the game made them.
assert_eq!(velocity(&push(9103, 0.0)), (-0.4, 0.4, 0.0));
// After the resistance: half of 0.4 is what the hook is handed.
let resisted = push(9104, 0.5);
assert_eq!(velocity(&resisted), (-0.2, 0.2, 0.0));
assert_eq!(seen(9104)[0].1.strength, 0.2);
// A full resistance leaves a strength of 0, which a mod may raise.
let raised = push(9105, 1.0);
assert_eq!(seen(9105)[0].1.strength, 0.0);
assert_eq!(velocity(&raised), (-0.4, 0.4, 0.0));
// The game's own hit on a mob reaches the hook with its damage.
let mut hurt = mob(9106, 0.0);
assert!(hurt.damage(2.0, hit));
// dealDefaultKnockback pushes with the float 0.4F.
let strength = f64::from(0.4_f32);
assert_eq!(velocity(&hurt), (-strength, 0.4, 0.0));
let made = Knock { strength, ..made };
assert_eq!(seen(9106), [("PlayerAttack".to_owned(), made)]);
}
/// #4301: `ServerPlayer.isInvulnerableTo`'s `!connection.hasClientLoaded`
/// term spares a respawned player the void until the client has loaded the
/// world; once it has, the void hurts again.
+59 -5
View File
@@ -547,13 +547,34 @@ impl ItemRenderer {
pub fn load_with_library(
root: &Path,
library: std::result::Result<Arc<ModelLibrary>, String>,
) -> crate::Result<Self> {
Self::load_tables(root, library, true)
}
/// What a dedicated server reads: the item prototypes (the stacks'
/// default components) and ids, without the client item definitions,
/// the special renderers' entity models and the grass colormap, which
/// only drawing reads (1.4 MB and ~1,500 files).
pub fn load_for_server(
root: &Path,
library: std::result::Result<Arc<ModelLibrary>, String>,
) -> crate::Result<Self> {
Self::load_tables(root, library, false)
}
fn load_tables(
root: &Path,
library: std::result::Result<Arc<ModelLibrary>, String>,
drawn: bool,
) -> crate::Result<Self> {
let prototypes: BTreeMap<String, Components> = horizon_world::item_defaults::read(root)?
.into_iter()
.map(|(item, components)| Ok((item, serde_json::from_value(components)?)))
.collect::<serde_json::Result<_>>()?;
let models = ItemModels {
definitions: load_definitions(root),
definitions: if drawn {
load_definitions(root)
} else {
BTreeMap::new()
},
prototypes,
};
let library = library?;
@@ -561,10 +582,18 @@ impl ItemRenderer {
&fs::read_to_string(root.join("assets/minecraft/atlases/items.json"))
.unwrap_or_default(),
);
let parts = special::load_parts(root);
let grass = crate::hud::decode(&root.join("assets/minecraft/textures/colormap/grass.png"))
.map(|(_, _, rgba)| argb_pixels(&rgba).into_iter().map(|p| p as i32).collect())
.unwrap_or_default();
let parts = if drawn {
special::load_parts(root)
} else {
HashMap::new()
};
let grass = if drawn {
crate::hud::decode(&root.join("assets/minecraft/textures/colormap/grass.png"))
.map(|(_, _, rgba)| argb_pixels(&rgba).into_iter().map(|p| p as i32).collect())
.unwrap_or_default()
} else {
Vec::new()
};
let item_ids = horizon_protocol::registry::Builtin::find(root)
.and_then(|p| horizon_protocol::registry::Builtin::load(&p).ok())
.and_then(|b| {
@@ -993,6 +1022,31 @@ mod tests {
Ok(())
}
/// A dedicated server keeps the stacks' default components but reads
/// none of the client item definitions.
#[test]
fn a_server_keeps_prototypes_without_definitions() -> crate::Result<()> {
let root = std::env::temp_dir().join(format!("horizon-item-server-{}", std::process::id()));
let definitions = root.join("assets/horizon/items");
fs::create_dir_all(&definitions)?;
fs::write(
definitions.join("top.json"),
r#"{"model":{"type":"minecraft:empty"}}"#,
)?;
fs::write(
root.join("items.json"),
r#"{"horizon:top":{"minecraft:max_stack_size":16}}"#,
)?;
let library = crate::model_library::ParsedModels::load(&root).into_item_library();
let client = ItemRenderer::load_with_library(&root, library.clone())?;
let server = ItemRenderer::load_for_server(&root, library)?;
let _ = fs::remove_dir_all(&root);
assert!(client.models.definitions.contains_key("horizon:top"));
assert!(server.models.definitions.is_empty());
assert_eq!(server.models.prototypes, client.models.prototypes);
Ok(())
}
#[test]
fn rendered_amounts_follow_the_count_bands() {
let bands: Vec<u32> = [0, 1, 2, 16, 17, 32, 33, 48, 49, 64]
+16 -5
View File
@@ -19,8 +19,10 @@ pub struct ServerAdvancements {
tree: std::sync::Arc<Tree>,
kids: Vec<Vec<usize>>,
layout: Vec<Option<(i32, f32)>>,
/// Recipe files by id.
recipes: BTreeMap<String, Value>,
/// Recipe files by id, as compact JSON text: a book entry parses its
/// recipe when a player gets it (a few µs), and the ~1,400 parsed files
/// would keep about 5 MB alive for the server's whole run.
recipes: BTreeMap<String, Box<str>>,
displays: RecipeIndex,
/// RecipeManager.unpackRecipeInfo's group numbers.
groups: HashMap<String, i32>,
@@ -48,6 +50,10 @@ impl ServerAdvancements {
groups.entry(g.to_owned()).or_insert(next);
}
}
let recipes = recipes
.into_iter()
.map(|(id, r)| (id, r.to_string().into_boxed_str()))
.collect();
Self {
tree,
kids,
@@ -351,14 +357,19 @@ impl ServerAdvancements {
/// ClientboundRecipeBookAddPacket.Entry for each display of a recipe:
/// highlighted, and with a notification unless the recipe turns it off.
fn book_entries(&self, id: &str, sync: &SyncData, flags: i8) -> Vec<Vec<u8>> {
let Some(r) = self.recipes.get(id) else {
return Vec::new();
};
let ids = self.displays.display_ids(id);
if ids.len() != 1 {
// Special recipes have no display; transmutations aren't unlocked on joining.
return Vec::new();
}
let Some(r) = self
.recipes
.get(id)
.and_then(|text| serde_json::from_str::<Value>(text).ok())
else {
return Vec::new();
};
let r = &r;
let kind = r["type"].as_str().unwrap_or("");
let item = |n: &str| sync.id("minecraft:item", n).unwrap_or(0);
let slot = |n: &str| sync.id("minecraft:slot_display", n).unwrap_or(0);
+85 -16
View File
@@ -144,9 +144,8 @@ pub struct TickCtx<'a> {
/// TickRateManager: the tick rate and whether the game is frozen.
pub tick_rate: f32,
pub tick_frozen: bool,
/// `/tick step`: a count that changes with each step started or
/// stopped, and the ticks the frozen game still runs (`FixedStep::step_state`).
pub tick_steps: (u32, i32),
/// The last step command's broadcast and the remaining ticks for joins.
pub tick_steps: crate::clock::StepState,
/// Block collisions, for ServerGamePacketListenerImpl's moved-wrongly
/// check (none: moves are not checked against blocks).
pub collision: Option<&'a BlockCollisionRegistry<'a>>,
@@ -593,6 +592,7 @@ impl Hub {
stack_to_network(item_id, |registry, name| sync.id(registry, name), s)
}
/// `PlayerList.broadcastAll`: send a packet to every placed player.
pub fn broadcast(&self, packet: &C) {
let body = packet.encode();
for g in self.guests.iter().filter(|g| g.placed) {
@@ -1288,7 +1288,7 @@ impl Hub {
// TickRateManager.updateJoiningPlayer.
g.send(&ticking_state(ctx.tick_rate, ctx.tick_frozen));
g.send(&C::TickingStep(play::TickingStep {
steps: ctx.tick_steps.1,
steps: ctx.tick_steps.remaining,
}));
}
@@ -1480,7 +1480,7 @@ impl Hub {
// (`updateJoiningPlayer`).
if !self.guests.iter().any(|g| g.placed) {
self.ticking = (ctx.tick_rate, ctx.tick_frozen);
self.stepping = ctx.tick_steps.0;
self.stepping = ctx.tick_steps.revision;
}
if !self
.guests
@@ -1492,6 +1492,7 @@ impl Hub {
if let Some(far) = self.far.as_mut() {
far.forget_level();
}
ctx.world.bulk_log.clear();
}
return events;
}
@@ -1585,6 +1586,22 @@ impl Hub {
g
}
/// `ServerTickRateManager.updateStepTicks` / `updateStateToClients`:
/// send clock changes before the block events caused by their ticks.
pub fn sync_ticking(&mut self, rate: f32, frozen: bool, steps: crate::clock::StepState) {
if (rate, frozen) != self.ticking {
self.ticking = (rate, frozen);
self.broadcast(&ticking_state(rate, frozen));
}
// ServerTickRateManager.updateStepTicks: a step started or stopped.
if steps.revision != self.stepping {
self.stepping = steps.revision;
self.broadcast(&C::TickingStep(play::TickingStep {
steps: steps.requested,
}));
}
}
/// Time and weather (ServerLevel.tickTime / advanceWeatherCycle broadcasts).
fn sync_world(&mut self, ctx: &TickCtx) {
let world = &*ctx.world;
@@ -1619,17 +1636,7 @@ impl Hub {
}
self.last_clock = day;
self.last_rate = rate;
if (ctx.tick_rate, ctx.tick_frozen) != self.ticking {
self.ticking = (ctx.tick_rate, ctx.tick_frozen);
self.broadcast(&ticking_state(ctx.tick_rate, ctx.tick_frozen));
}
// ServerTickRateManager.updateStepTicks: a step started or stopped.
if ctx.tick_steps.0 != self.stepping {
self.stepping = ctx.tick_steps.0;
self.broadcast(&C::TickingStep(play::TickingStep {
steps: ctx.tick_steps.1,
}));
}
self.sync_ticking(ctx.tick_rate, ctx.tick_frozen, ctx.tick_steps);
let w = &world.weather;
let (raining, rain, thunder) = self.weather;
if w.raining != raining {
@@ -1922,9 +1929,71 @@ impl Hub {
}
fn broadcast_block_changes(&mut self, world: &mut World) {
// Chunks changed in bulk (`World::edit_runs`) go whole to those who see them: one packet each, where a
// change of each block would be thousands (the client lights and meshes them once, on its workers).
let bulk: Vec<(i32, i32)> = if world.bulk_log.is_empty() {
Vec::new()
} else {
let mut keys: Vec<_> = world.bulk_log.drain().collect();
keys.sort_unstable();
keys
};
if !bulk.is_empty() {
let recording = self.recording();
let columns: Vec<&Chunk> = bulk
.iter()
.filter_map(|k| world.chunks.get(k).map(|c| &**c))
.collect();
self.encoder.warm(&columns);
for chunk in columns {
let key = (chunk.x, chunk.z);
if let Some(far) = self.far.as_mut() {
far.changed(key.0, key.1);
}
let viewers: Vec<usize> = (0..self.guests.len())
.filter(|&i| {
self.here(&self.guests[i]) && self.guests[i].sent_chunks.contains(&key)
})
.collect();
if viewers.is_empty() {
continue;
}
let body = self.encoder.body_in(chunk, world);
for &i in &viewers {
self.guests[i].sender.send(body.clone());
}
if recording {
// The prediction's replay excuses a client near the column: any of its blocks may have
// changed, so the one nearest the player stands for them.
for &i in &viewers {
let g = &mut self.guests[i];
let Some(record) = g.anticheat.record.as_deref_mut() else {
continue;
};
let slack = super::prediction::slack(std::array::from_fn(|i| {
g.position[i] - g.previous_position[i]
}));
let column =
|c: f64, at: i32| (c.floor() as i32).clamp(at * 16, at * 16 + 15);
let at = [
column(g.position[0], key.0),
g.position[1].floor() as i32,
column(g.position[2], key.1),
];
if super::prediction::near(g.position, at, slack) {
record.changed_near(self.ticks, at);
}
}
}
}
}
let Some(log) = world.block_log.as_mut() else {
return;
};
if !bulk.is_empty() {
// (what changed in them one at a time went with them)
log.retain(|p| bulk.binary_search(&(p.0 >> 4, p.2 >> 4)).is_err());
}
if log.is_empty() {
return;
}
@@ -85,6 +85,9 @@ pub fn load_owner(guest: &mut Guest, world: &Path, saved: Option<&Level>) -> boo
/// then to the file level.dat names, which it loads ([`load_owner`]), and to
/// its own file, so that the one it loads is never the older of the two.
pub fn save_owner(guest: &Guest, level: &mut Level, world: &Path) {
// `MinecraftServer.saveAllChunks`: `singleplayer_uuid` is the integrated server's own profile,
// never another player's file the world happened to hold.
level.player_uuid.get_or_insert(uuid_ints(guest.uuid));
store(guest, level, None);
let _ = level.write_player(world);
save(guest, world);
@@ -586,4 +589,35 @@ mod tests {
assert_eq!(other.flying_speed, 0.05);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_world_without_singleplayer_uuid_gives_the_owner_a_new_player_not_anothers() {
// A world from a vanilla server: no `singleplayer_uuid`, and the players' files are other
// people's. The owner has none: vanilla starts them new (PlayerList.loadPlayerData).
let owner = horizon_protocol::crypto::offline_uuid("Owner");
let friend = horizon_protocol::crypto::offline_uuid("Friend");
let dir = world("server-world");
save(&holding(friend, "minecraft:netherite_ingot", 9), &dir);
let mut level = Level::default();
level.write(&dir).unwrap();
let _ = std::fs::remove_file(dir.join("level.dat_old"));
let mut tag = read_dat(&dir.join("level.dat")).unwrap();
if let Some(Tag::Compound(data)) = tag.get_mut("Data") {
data.remove(&"singleplayer_uuid".into());
}
write_dat(&dir.join("level.dat"), tag).unwrap();
let read = Level::read(&dir).unwrap();
let mut g = holding(owner, "minecraft:dirt", 1);
g.inventory[0] = None;
let known = load_owner(&mut g, &dir, Some(&read));
assert!(!known, "the owner has not been here before");
assert_eq!(first_item(&g), None, "no inventory of the friend's");
// The owner's save names the owner, and leaves the friend's file alone.
let before = std::fs::read(path(&dir, friend)).unwrap();
let mut level = read;
save_owner(&g, &mut level, &dir);
assert_eq!(level.player_uuid, Some(uuid_ints(owner)));
assert_eq!(std::fs::read(path(&dir, friend)).unwrap(), before);
assert!(exists(&dir, owner));
}
}
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more